"""
SBColonyBlockKernel — The Colony Sound Block GPU kernel.

Collapses the entire colony simulation pipeline into a single GPU-resident
iterative FM modulation loop. Instead of 500 cells × 150 generations × 100 ticks,
this runs P probes × N passes until convergence.

Each probe is a frequency-domain state vector: one amplitude per factor dimension.
The influence matrix IS the FM carrier. Probe velocities modulate through it.
Attractor positions ARE concept frequencies. Gravity IS resonance pull.

The physics are identical to the production colony engine (sb_colony.wgsl).
The representation is different: probes replace cells, amplitudes replace positions.

Pipeline:
    1. Build world model from input (factors, relationships, options)
    2. Build influence matrix (the FM carrier)
    3. Initialize probes near attractor centroid
    4. GPU modulation loop: dispatch sb_colony_block.wgsl per pass
       - Noise generated on GPU (PCG hash, zero CPU transfer)
       - Convergence checked per pass (max delta readback, 4 bytes)
       - Fused multi-pass dispatch when not checking convergence
    5. Final readback: probe amplitudes → cluster → strategies
    6. Single readback at the end (P × D × 4 bytes)

Usage:
    kernel = SBColonyBlockKernel(num_dims=10, max_probes=64, max_attractors=8)
    result = kernel.run(input_dict)
    # result = { strategies, summary, totalMs, passCount, converged, ... }

    # Or step-by-step:
    kernel.setup(factors, relationships, options)
    kernel.initialize_probes()
    kernel.modulation_loop()
    strategies = kernel.extract_strategies()
"""

import numpy as np
import struct
import time
from pathlib import Path
from typing import Optional

try:
    import wgpu
    _WGPU_AVAILABLE = True
except ImportError:
    _WGPU_AVAILABLE = False


def gpu_available() -> bool:
    """Check if WebGPU compute is available on this system."""
    if not _WGPU_AVAILABLE:
        return False
    try:
        device = wgpu.utils.get_default_device()
        return device is not None
    except Exception:
        return False


_SHADER_DIR = Path(__file__).parent

# Params struct: 8 u32 + 16 f32 = 96 bytes (must match WGSL struct Params)
# Alignment: WGSL uniform buffers require 16-byte alignment.
#   u32 num_probes, u32 num_dims, u32 num_attractors, u32 pass_index     (16 bytes)
#   u32 total_passes, u32 _pad0, u32 _pad1, u32 _pad2                   (16 bytes)
#   f32 gravity_strength, f32 gravity_sharpness, f32 gravity_range, f32 softening  (16 bytes)
#   f32 force_scale, f32 tradeoff_amplifier, f32 thermal_noise_level, f32 momentum (16 bytes)
#   f32 max_amplitude, f32 boundary_margin, f32 boundary_strength, f32 dt_norm     (16 bytes)
#   f32 settling_start, f32 settling_strength, f32 _pad3, f32 _pad4               (16 bytes)
# Total: 96 bytes
# NOTE: gravity_ramp and settling_damp are now DERIVED IN-SHADER from pass_index.
#       settling_start and settling_strength are run-level constants uploaded once.
_PARAMS_SIZE = 96
_PARAMS_PACK_FORMAT = "IIIIIIII" + "f" * 16  # 8 u32 + 16 f32 = 24 values


# ─── Default Config ───────────────────────────────────────────────────────────

DEFAULT_CONFIG = {
    # Probe parameters — no hard caps; scales to GPU VRAM limits
    "max_passes": 60,
    "convergence_threshold": 0.001,
    "min_passes": 8,
    "convergence_check_interval": 4,  # check every N passes (avoids per-pass readback)
    "fused_passes": 4,                # dispatches per fused batch

    # Probe population — scales from 16 (fast) to 1M+ (research)
    "probe_count": 32,
    "probe_perturbation": 0.25,

    # Physics (identical to production colony engine)
    "gravity_strength": 5.0,
    "gravity_sharpness": 3.0,
    "gravity_range": None,   # auto-computed from dimensionality
    "softening": 0.05,
    "force_scale": 0.10,
    "tradeoff_amplifier": 1.5,
    "thermal_noise": 0.02,
    "momentum": 0.7,
    "max_amplitude": 1.0,
    "boundary_margin": 0.08,
    "boundary_strength": 0.05,
    "dt_norm": 0.01667,  # 16.67ms / 1000

    # Settling
    "settling_start": 0.85,
    "settling_strength": 0.7,

    # Clustering — max_strategies scales with probe count
    "max_strategies": 32,
    "peak_merge_radius": 0.12,

    # Scalable clustering threshold — switch from O(N²) to grid at this probe count
    "clustering_grid_threshold": 2000,
}


# ─── Gravity Ramp + Settling (matches production engine) ─────────────────────

def compute_gravity_ramp(pass_idx: int, total_passes: int) -> float:
    if total_passes <= 0:
        return 1.0
    progress = min(1.0, pass_idx / total_passes)
    t = max(0.0, min(1.0, (progress - 0.1) / 0.8))
    return 0.05 + 0.95 * t * t * (3 - 2 * t)


def compute_settling_damp(pass_idx: int, total_passes: int,
                          settling_start: float = 0.85,
                          settling_strength: float = 0.7) -> float:
    if total_passes <= 0:
        return 1.0
    progress = min(1.0, pass_idx / total_passes)
    if progress <= settling_start:
        return 1.0
    settling = (progress - settling_start) / (1.0 - settling_start)
    return 1.0 - settling_strength * settling


# ─── SBColonyBlockKernel ─────────────────────────────────────────────────────

class SBColonyBlockKernel:
    """GPU Colony Sound Block kernel using sb_colony_block.wgsl.

    Scales from 16 probes (real-time, <10ms) to 1M+ probes (research).
    No hard caps — buffer sizes set at construction, limited only by GPU VRAM.

    VRAM per probe = num_dims × 5 buffers × 4 bytes (amplitudes, velocities,
    forces, prev_amplitudes, noise) + 4 bytes (delta).
    Example: 10K probes × 100 dims × 20 bytes = 20 MB.
             1M probes × 10 dims × 20 bytes = 200 MB.

    Args:
        num_dims: Number of factor dimensions (D). No upper limit.
        max_probes: Pre-allocated probe capacity. Set to your target probe count.
        max_attractors: Pre-allocated attractor capacity. Set to your option count.
    """

    def __init__(self, num_dims: int, max_probes: int = 1024, max_attractors: int = 64):
        if not _WGPU_AVAILABLE:
            raise ImportError("wgpu package required: pip install wgpu")

        self.num_dims = num_dims
        self.max_probes = max_probes
        self.max_attractors = max_attractors

        self._num_probes = 0
        self._num_attractors = 0
        self._bind_group_dirty = True

        self._device = wgpu.utils.get_default_device()
        self._load_shader()
        self._create_pipeline()
        self._create_buffers()

        self._bg = None
        self._noise_bg = None

    # Inline reduction shader (separate from main shader to avoid bind group conflicts)
    _REDUCE_SHADER = """
@group(0) @binding(0) var<uniform> reduce_params: vec4<u32>;
@group(0) @binding(1) var<storage, read> reduce_deltas: array<f32>;
@group(0) @binding(2) var<storage, read_write> max_delta_out: array<atomic<u32>>;

var<workgroup> shared_deltas: array<f32, 256>;

@compute @workgroup_size(256)
fn reduce_max_delta(@builtin(global_invocation_id) gid: vec3<u32>,
                    @builtin(local_invocation_id) lid: vec3<u32>) {
    let idx = gid.x;
    let local_idx = lid.x;
    let num_probes = reduce_params.x;

    if (idx < num_probes) {
        shared_deltas[local_idx] = reduce_deltas[idx];
    } else {
        shared_deltas[local_idx] = 0.0;
    }
    workgroupBarrier();

    for (var stride = 128u; stride > 0u; stride = stride >> 1u) {
        if (local_idx < stride) {
            shared_deltas[local_idx] = max(shared_deltas[local_idx],
                                            shared_deltas[local_idx + stride]);
        }
        workgroupBarrier();
    }

    if (local_idx == 0u) {
        let bits = bitcast<u32>(shared_deltas[0]);
        atomicMax(&max_delta_out[0], bits);
    }
}
"""

    def _load_shader(self):
        self._shader_src = (_SHADER_DIR / "sb_colony_block.wgsl").read_text()
        self._clustering_src = (_SHADER_DIR / "sb_clustering.wgsl").read_text()

    def _create_pipeline(self):
        d = self._device
        module = d.create_shader_module(code=self._shader_src)

        # 7 bindings (prev_amplitudes ELIMINATED by velocity energy convergence):
        # 0: params, 1: amplitudes, 2: velocities, 3: forces,
        # 4: matrix, 5: attractors, 6: deltas (velocity energy per probe)
        self._bgl = d.create_bind_group_layout(entries=[
            {"binding": 0, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.uniform}},
            {"binding": 1, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.storage}},
            {"binding": 2, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.storage}},
            {"binding": 3, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.storage}},
            {"binding": 4, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.read_only_storage}},
            {"binding": 5, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.read_only_storage}},
            {"binding": 6, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.storage}},
        ])

        pl = d.create_pipeline_layout(bind_group_layouts=[self._bgl])
        self._pipeline = d.create_compute_pipeline(
            layout=pl, compute={"module": module, "entry_point": "main"})

        # GPU convergence reduction pipeline (separate shader module)
        reduce_module = d.create_shader_module(code=self._REDUCE_SHADER)
        self._reduce_bgl = d.create_bind_group_layout(entries=[
            {"binding": 0, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.uniform}},
            {"binding": 1, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.read_only_storage}},
            {"binding": 2, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.storage}},
        ])
        reduce_pl = d.create_pipeline_layout(bind_group_layouts=[self._reduce_bgl])
        self._reduce_pipeline = d.create_compute_pipeline(
            layout=reduce_pl,
            compute={"module": reduce_module, "entry_point": "reduce_max_delta"})

        # GPU clustering pipeline (sb_clustering.wgsl — count_neighbors + assign_cluster + find_seed)
        cluster_module = d.create_shader_module(code=self._clustering_src)

        # Bindings 0-3: shared by count_neighbors and assign_cluster
        self._cluster_bgl = d.create_bind_group_layout(entries=[
            {"binding": 0, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.uniform}},
            {"binding": 1, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.read_only_storage}},
            {"binding": 2, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.storage}},
            {"binding": 3, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.storage}},
        ])
        cluster_pl = d.create_pipeline_layout(bind_group_layouts=[self._cluster_bgl])
        self._count_neighbors_pipeline = d.create_compute_pipeline(
            layout=cluster_pl,
            compute={"module": cluster_module, "entry_point": "count_neighbors"})
        self._assign_cluster_pipeline = d.create_compute_pipeline(
            layout=cluster_pl,
            compute={"module": cluster_module, "entry_point": "assign_cluster"})

        # Bindings 0-4: find_seed needs seed_result at binding 4
        self._find_seed_bgl = d.create_bind_group_layout(entries=[
            {"binding": 0, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.uniform}},
            {"binding": 1, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.read_only_storage}},
            {"binding": 2, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.storage}},
            {"binding": 3, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.storage}},
            {"binding": 4, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.storage}},
        ])
        find_seed_pl = d.create_pipeline_layout(bind_group_layouts=[self._find_seed_bgl])
        self._find_seed_pipeline = d.create_compute_pipeline(
            layout=find_seed_pl,
            compute={"module": cluster_module, "entry_point": "find_seed"})

    def _create_buffers(self):
        d = self._device
        D = self.num_dims
        P_max = self.max_probes
        A_max = self.max_attractors

        STORAGE_RW = (wgpu.BufferUsage.STORAGE |
                      wgpu.BufferUsage.COPY_SRC |
                      wgpu.BufferUsage.COPY_DST)
        STORAGE_RO = (wgpu.BufferUsage.STORAGE |
                      wgpu.BufferUsage.COPY_DST)

        # Per-probe buffers (max_probes * num_dims * 4 bytes)
        # NOTE: noise buffer eliminated (Phase 1, inline PCG)
        # NOTE: prev_amplitudes buffer eliminated (Phase 1b, velocity energy convergence)
        probe_buf_size = P_max * D * 4
        self._buf_amplitudes = d.create_buffer(size=probe_buf_size, usage=STORAGE_RW)
        self._buf_velocities = d.create_buffer(size=probe_buf_size, usage=STORAGE_RW)
        self._buf_forces = d.create_buffer(size=probe_buf_size, usage=STORAGE_RW)

        # Per-probe convergence deltas
        self._buf_deltas = d.create_buffer(size=P_max * 4, usage=STORAGE_RW)

        # GPU convergence reduction output (single f32 as u32 for atomicMax)
        self._buf_max_delta = d.create_buffer(size=4, usage=STORAGE_RW)
        self._buf_reduce_params = d.create_buffer(
            size=16,  # vec4<u32>
            usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST)

        # Influence matrix (D × D) — needs COPY_SRC for readback in build_matrix_gpu
        self._buf_influence_matrix = d.create_buffer(size=D * D * 4, usage=STORAGE_RW)

        # Attractor positions (A × D)
        self._buf_attractor_positions = d.create_buffer(
            size=A_max * D * 4, usage=STORAGE_RO)

        # Params uniform (STATIC for entire run — only pass_index changes per dispatch)
        self._buf_params = d.create_buffer(
            size=_PARAMS_SIZE,
            usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST)

        # GPU clustering buffers (sb_clustering.wgsl)
        self._buf_neighbor_counts = d.create_buffer(size=P_max * 4, usage=STORAGE_RW)
        self._buf_cluster_assignments = d.create_buffer(size=P_max * 4, usage=STORAGE_RW)
        self._buf_cluster_params = d.create_buffer(
            size=32,  # 8 u32 = 32 bytes (ClusterParams struct)
            usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST)
        self._buf_seed_result = d.create_buffer(size=4, usage=STORAGE_RW)  # packed (count<<20)|idx

    def _rebuild_bind_group(self):
        P = self._num_probes
        A = self._num_attractors
        D = self.num_dims
        d = self._device

        self._bg = d.create_bind_group(layout=self._bgl, entries=[
            {"binding": 0, "resource": {"buffer": self._buf_params}},
            {"binding": 1, "resource": {"buffer": self._buf_amplitudes,
                                        "size": P * D * 4}},
            {"binding": 2, "resource": {"buffer": self._buf_velocities,
                                        "size": P * D * 4}},
            {"binding": 3, "resource": {"buffer": self._buf_forces,
                                        "size": P * D * 4}},
            {"binding": 4, "resource": {"buffer": self._buf_influence_matrix}},
            {"binding": 5, "resource": {"buffer": self._buf_attractor_positions,
                                        "size": A * D * 4}},
            {"binding": 6, "resource": {"buffer": self._buf_deltas,
                                        "size": P * 4}},
        ])

        # Reduction bind group (for reduce_max_delta)
        self._reduce_bg = d.create_bind_group(layout=self._reduce_bgl, entries=[
            {"binding": 0, "resource": {"buffer": self._buf_reduce_params}},
            {"binding": 1, "resource": {"buffer": self._buf_deltas,
                                        "size": P * 4}},
            {"binding": 2, "resource": {"buffer": self._buf_max_delta}},
        ])

        self._bind_group_dirty = False

    # ─── Data Upload ──────────────────────────────────────────────────────────

    def upload_matrix(self, influence_matrix: np.ndarray):
        assert influence_matrix.shape == (self.num_dims, self.num_dims)
        self._device.queue.write_buffer(
            self._buf_influence_matrix, 0,
            influence_matrix.astype(np.float32).tobytes())

    def upload_attractors(self, attractor_positions: np.ndarray):
        A, D = attractor_positions.shape
        assert D == self.num_dims and A <= self.max_attractors
        if A != self._num_attractors:
            self._num_attractors = A
            self._bind_group_dirty = True
        self._device.queue.write_buffer(
            self._buf_attractor_positions, 0,
            attractor_positions.astype(np.float32).tobytes())

    def upload_probes(self, amplitudes: np.ndarray, velocities: np.ndarray):
        P, D = amplitudes.shape
        assert D == self.num_dims and P <= self.max_probes
        assert velocities.shape == (P, D)
        if P != self._num_probes:
            self._num_probes = P
            self._bind_group_dirty = True
        d = self._device
        d.queue.write_buffer(self._buf_amplitudes, 0,
                             amplitudes.astype(np.float32).tobytes())
        d.queue.write_buffer(self._buf_velocities, 0,
                             velocities.astype(np.float32).tobytes())

    # ─── GPU Dispatch (Optimized — static params, inline noise, GPU reduction) ──

    def _pack_static_params(self, total_passes: int, cfg: dict) -> bytes:
        """Pack params ONCE for entire run. Only pass_index changes per dispatch.

        Gravity ramp and settling damp are now derived IN-SHADER from pass_index.
        settling_start and settling_strength are run-level constants.
        """
        return struct.pack(_PARAMS_PACK_FORMAT,
            self._num_probes,
            self.num_dims,
            self._num_attractors,
            0,  # pass_index — overwritten per dispatch (4 bytes at offset 12)
            total_passes,
            0, 0, 0,  # pads
            cfg["gravity_strength"],
            cfg["gravity_sharpness"],
            cfg["gravity_range"],
            cfg["softening"],
            cfg["force_scale"],
            cfg["tradeoff_amplifier"],
            cfg["thermal_noise"],
            cfg["momentum"],
            cfg["max_amplitude"],
            cfg["boundary_margin"],
            cfg["boundary_strength"],
            cfg["dt_norm"],
            cfg.get("settling_start", 0.85),    # was gravity_ramp (per-pass)
            cfg.get("settling_strength", 0.7),   # was settling_damp (per-pass)
            0.0, 0.0,  # pads
        )

    def dispatch_fused(self, start_pass: int, count: int, total_passes: int, cfg: dict):
        """Fused multi-pass dispatch: one encoder, N dispatches, one submit.

        Optimized: no noise dispatch (inline PCG in shader), no per-pass params
        repack (only 4-byte pass_index write). Single params upload before loop.
        """
        if self._bind_group_dirty:
            self._rebuild_bind_group()

        d = self._device
        P = self._num_probes
        workgroups = (P + 255) // 256

        encoder = d.create_command_encoder()

        for i in range(count):
            pass_idx = start_pass + i
            if pass_idx >= total_passes:
                break

            # ONLY per-pass write: 4 bytes for pass_index at offset 12
            d.queue.write_buffer(self._buf_params, 12,
                                 struct.pack("I", pass_idx))

            cp = encoder.begin_compute_pass()
            cp.set_pipeline(self._pipeline)
            cp.set_bind_group(0, self._bg)
            cp.dispatch_workgroups(workgroups)
            cp.end()

        d.queue.submit([encoder.finish()])

    def dispatch_reduce_max_delta(self) -> float:
        """GPU parallel reduction: max delta across all probes → single f32.

        Uses atomicMax with bitcast (works for non-negative f32).
        Returns the max delta value. Replaces readback_deltas() for convergence.
        """
        d = self._device
        P = self._num_probes
        workgroups = (P + 255) // 256

        # Zero the output buffer
        d.queue.write_buffer(self._buf_max_delta, 0, b'\x00\x00\x00\x00')

        # Upload reduce params (num_probes)
        d.queue.write_buffer(self._buf_reduce_params, 0,
                             struct.pack("IIII", P, 0, 0, 0))

        encoder = d.create_command_encoder()
        cp = encoder.begin_compute_pass()
        cp.set_pipeline(self._reduce_pipeline)
        cp.set_bind_group(0, self._reduce_bg)
        cp.dispatch_workgroups(workgroups)
        cp.end()
        d.queue.submit([encoder.finish()])

        # Read back single f32 (4 bytes — vs P×4 bytes before)
        d._poll_wait()
        data = d.queue.read_buffer(self._buf_max_delta, size=4)
        bits = struct.unpack("I", data)[0]
        return struct.unpack("f", struct.pack("I", bits))[0]

    def readback_deltas(self) -> np.ndarray:
        """Read per-probe convergence deltas. Fallback for small probe counts."""
        P = self._num_probes
        self._device._poll_wait()
        data = self._device.queue.read_buffer(self._buf_deltas, size=P * 4)
        return np.frombuffer(data, dtype=np.float32)[:P].copy()

    def readback_amplitudes(self) -> np.ndarray:
        """Read final probe amplitudes from GPU."""
        P = self._num_probes
        D = self.num_dims
        self._device._poll_wait()
        data = self._device.queue.read_buffer(self._buf_amplitudes, size=P * D * 4)
        return np.frombuffer(data, dtype=np.float32).reshape(P, D).copy()

    # ─── GPU Clustering (sb_clustering.wgsl) ─────────────────────────────────

    def _cluster_probes_gpu(self, n_dims: int, cfg: dict) -> list:
        """Run density-based clustering entirely on GPU.

        Uses sb_clustering.wgsl — three passes per cluster:
          Pass 1 (count_neighbors): Each probe counts unassigned neighbors (GPU parallel)
          Pass 2 (find_seed): GPU argmax reduction → single 4-byte packed result
          Pass 3 (assign_cluster): GPU assigns all neighbors of seed to cluster

        Per-cluster CPU work: read 4 bytes, unpack, update params. No P×4 readback.
        """
        P = self._num_probes
        D = self.num_dims
        d = self._device
        max_strategies = cfg.get("max_strategies", 32)

        radius = cfg["peak_merge_radius"] * 2.0 + 0.06 * np.log(n_dims)
        radius_sq = radius * radius

        workgroups = (P + 255) // 256

        # Zero out cluster assignments
        d.queue.write_buffer(self._buf_cluster_assignments, 0,
                             np.zeros(P, dtype=np.uint32).tobytes())

        # Build bind groups
        cluster_bg = d.create_bind_group(layout=self._cluster_bgl, entries=[
            {"binding": 0, "resource": {"buffer": self._buf_cluster_params}},
            {"binding": 1, "resource": {"buffer": self._buf_amplitudes,
                                        "size": P * D * 4}},
            {"binding": 2, "resource": {"buffer": self._buf_neighbor_counts,
                                        "size": P * 4}},
            {"binding": 3, "resource": {"buffer": self._buf_cluster_assignments,
                                        "size": P * 4}},
        ])

        find_seed_bg = d.create_bind_group(layout=self._find_seed_bgl, entries=[
            {"binding": 0, "resource": {"buffer": self._buf_cluster_params}},
            {"binding": 1, "resource": {"buffer": self._buf_amplitudes,
                                        "size": P * D * 4}},
            {"binding": 2, "resource": {"buffer": self._buf_neighbor_counts,
                                        "size": P * 4}},
            {"binding": 3, "resource": {"buffer": self._buf_cluster_assignments,
                                        "size": P * 4}},
            {"binding": 4, "resource": {"buffer": self._buf_seed_result}},
        ])

        clusters = []
        for cluster_id in range(1, max_strategies + 1):
            # Pass 1 + 2: count_neighbors → find_seed in one encoder
            params = struct.pack("IIfiIIII",
                P, D, radius_sq, 1,
                max_strategies, cluster_id, 0, 0)
            d.queue.write_buffer(self._buf_cluster_params, 0, params)

            # Zero seed result
            d.queue.write_buffer(self._buf_seed_result, 0, b'\x00\x00\x00\x00')

            encoder = d.create_command_encoder()

            # Pass 1: count neighbors
            cp1 = encoder.begin_compute_pass()
            cp1.set_pipeline(self._count_neighbors_pipeline)
            cp1.set_bind_group(0, cluster_bg)
            cp1.dispatch_workgroups(workgroups)
            cp1.end()

            # Pass 2: find seed (GPU argmax)
            cp2 = encoder.begin_compute_pass()
            cp2.set_pipeline(self._find_seed_pipeline)
            cp2.set_bind_group(0, find_seed_bg)
            cp2.dispatch_workgroups(workgroups)
            cp2.end()

            d.queue.submit([encoder.finish()])

            # Read 4 bytes: packed (count << 20) | index
            d._poll_wait()
            seed_data = d.queue.read_buffer(self._buf_seed_result, size=4)
            packed = struct.unpack("I", seed_data)[0]

            best_count = (packed >> 20) & 0xFFF
            best_idx = packed & 0xFFFFF

            if best_count < 1:
                break

            # Pass 3: Assign neighbors of seed
            params = struct.pack("IIfiIIII",
                P, D, radius_sq, 1,
                max_strategies, cluster_id, best_idx, 0)
            d.queue.write_buffer(self._buf_cluster_params, 0, params)

            encoder = d.create_command_encoder()
            cp3 = encoder.begin_compute_pass()
            cp3.set_pipeline(self._assign_cluster_pipeline)
            cp3.set_bind_group(0, cluster_bg)
            cp3.dispatch_workgroups(workgroups)
            cp3.end()
            d.queue.submit([encoder.finish()])

            clusters.append({
                "cluster_id": cluster_id,
                "seed_idx": best_idx,
                "estimated_count": best_count,
            })

        # Final readback: assignments + amplitudes (once at the end)
        d._poll_wait()
        assignments_data = d.queue.read_buffer(
            self._buf_cluster_assignments, size=P * 4)
        assignments = np.frombuffer(assignments_data, dtype=np.uint32)[:P].copy()

        amplitudes = self.readback_amplitudes()

        result_clusters = []
        for cl in clusters:
            member_mask = assignments == cl["cluster_id"]
            member_indices = list(np.where(member_mask)[0])
            if not member_indices:
                continue
            centroid = amplitudes[member_indices].mean(axis=0)
            result_clusters.append({
                "centroid": centroid,
                "cell_count": len(member_indices),
                "member_indices": member_indices,
            })

        result_clusters.sort(key=lambda c: c["cell_count"], reverse=True)
        return result_clusters

    # ─── Full Pipeline ────────────────────────────────────────────────────────

    def run(self, input_dict: dict, config: Optional[dict] = None) -> dict:
        """Run the complete Colony Sound Block pipeline.

        Args:
            input_dict: { question, options, factors, relationships }
                Same format as afs_decide / SoundBlockSecondOpinion.
            config: Override DEFAULT_CONFIG values.

        Returns:
            { strategies, summary, total_ms, pass_count, converged,
              convergence_history, influence_matrix, cluster_count }
        """
        t0 = time.perf_counter()
        cfg = {**DEFAULT_CONFIG, **(config or {})}
        timings = {}

        # ── Phase 1: Build world model ──
        t1 = time.perf_counter()
        factors = input_dict.get("factors", [])
        relationships = input_dict.get("relationships", [])
        options = input_dict.get("options", [])

        factor_names = [f["name"] for f in factors]
        n_dims = len(factors)
        assert n_dims > 0, "At least one factor required"

        # Auto-compute gravity range
        if cfg["gravity_range"] is None:
            cfg["gravity_range"] = max(2.0, min(6.0, 1.5 * np.sqrt(n_dims)))

        timings["build_world_model"] = time.perf_counter() - t1

        # ── Phase 2: Build influence matrix ──
        t2 = time.perf_counter()
        name_to_idx = {f["name"]: i for i, f in enumerate(factors)}
        matrix = np.zeros((n_dims, n_dims), dtype=np.float32)

        for rel in relationships:
            i = name_to_idx.get(rel.get("from"))
            j = name_to_idx.get(rel.get("to"))
            if i is not None and j is not None:
                strength = rel.get("strength", 0.5)
                matrix[i, j] = strength

        # Auto-generate weak feedback for leaf factors
        outgoing = set(r.get("from") for r in relationships)
        for fn in factor_names:
            if fn not in outgoing:
                incoming = [r for r in relationships if r.get("to") == fn]
                for inc in incoming:
                    fi = name_to_idx.get(fn)
                    fj = name_to_idx.get(inc.get("from"))
                    if fi is not None and fj is not None and matrix[fi, fj] == 0:
                        matrix[fi, fj] = inc.get("strength", 0.5) * 0.4

        # Calibrate
        self._calibrate(cfg, n_dims, matrix)

        self.upload_matrix(matrix)
        timings["build_matrix"] = time.perf_counter() - t2

        # ── Phase 3: Build attractors + initialize probes ──
        t3 = time.perf_counter()
        attractor_positions = self._build_attractors(factors, options, n_dims)
        self._attractor_cache = attractor_positions  # cached for resonance clustering
        self.upload_attractors(attractor_positions)

        probe_count = cfg["probe_count"]
        if n_dims >= 8 and probe_count < n_dims * 4:
            # Scale probes with dimensionality — more dims need more coverage
            probe_count = max(probe_count, n_dims * 4)

        amplitudes, velocities = self._init_probes(
            probe_count, n_dims, attractor_positions, cfg["probe_perturbation"])
        self.upload_probes(amplitudes, velocities)
        timings["initialize"] = time.perf_counter() - t3

        # ── Phase 4: GPU modulation loop (optimized) ──
        # Static params uploaded ONCE. Only pass_index (4 bytes) changes per dispatch.
        # Convergence via GPU reduction (4-byte readback vs P×4 before).
        # Adaptive check interval: frequent early, sparse later.
        t4 = time.perf_counter()
        total_passes = cfg["max_passes"]
        check_interval = cfg["convergence_check_interval"]
        fused_count = cfg["fused_passes"]
        convergence_history = []
        converged = False
        pass_count = 0

        # Upload static params ONCE (96 bytes, entire run)
        d = self._device
        d.queue.write_buffer(self._buf_params, 0,
                             self._pack_static_params(total_passes, cfg))

        # Use GPU reduction for convergence when probe count is large
        use_gpu_reduction = probe_count >= 64

        while pass_count < total_passes:
            batch_size = min(fused_count, total_passes - pass_count)
            self.dispatch_fused(pass_count, batch_size, total_passes, cfg)
            pass_count += batch_size

            # Adaptive check interval: every 4 early, every 16 after pass 20
            effective_interval = check_interval if pass_count < 20 else check_interval * 4

            if pass_count >= cfg["min_passes"] and pass_count % effective_interval < batch_size:
                if use_gpu_reduction:
                    max_delta = self.dispatch_reduce_max_delta()
                else:
                    deltas = self.readback_deltas()
                    max_delta = float(np.max(deltas))

                convergence_history.append(max_delta)

                if max_delta < cfg["convergence_threshold"]:
                    converged = True
                    break

        timings["modulation"] = time.perf_counter() - t4

        # ── Phase 5: Clustering ──
        # GPU clustering for large probe counts (> threshold) — O(N) per GPU thread
        # CPU clustering for small counts — simpler, no readback overhead
        t5 = time.perf_counter()
        gpu_cluster_threshold = cfg.get("clustering_grid_threshold", 2000)
        if probe_count >= gpu_cluster_threshold:
            clusters = self._cluster_probes_gpu(n_dims, cfg)
            final_amplitudes = self.readback_amplitudes()
        else:
            final_amplitudes = self.readback_amplitudes()
            clusters = self._cluster_probes(final_amplitudes, n_dims, cfg)
        timings["clustering"] = time.perf_counter() - t5

        # ── Phase 6: Strategy extraction ──
        t6 = time.perf_counter()
        strategies = self._extract_strategies(
            clusters, factors, factor_names, options, attractor_positions, n_dims)
        timings["extraction"] = time.perf_counter() - t6

        total_ms = (time.perf_counter() - t0) * 1000

        # Summary
        emergent_count = sum(1 for s in strategies if s.get("is_emergent"))
        strongest = max(strategies, key=lambda s: s["cell_count"]) if strategies else None

        summary = {
            "total_strategies": len(strategies),
            "emergent_count": emergent_count,
            "strongest_outcome": strongest["label"] if strongest else None,
            "strongest_cells": strongest["cell_count"] if strongest else 0,
            "total_cells": probe_count,
            "generations_run": pass_count,
            "dimensions": n_dims,
            "converged": converged,
            "convergence_pass": pass_count if converged else None,
        }

        return {
            "strategies": strategies,
            "summary": summary,
            "total_ms": total_ms,
            "timings": timings,
            "pass_count": pass_count,
            "converged": converged,
            "convergence_history": convergence_history,
            "influence_matrix": matrix.tolist(),
            "cluster_count": len(clusters),
            "config": cfg,
        }

    # ─── Internal Helpers ─────────────────────────────────────────────────────

    def _calibrate(self, cfg: dict, n_dims: int, matrix: np.ndarray):
        """Auto-calibrate physics based on dimensionality and matrix properties."""
        # Analyze matrix
        upper = matrix[np.triu_indices(n_dims, k=1)]
        non_zero = upper[np.abs(upper) > 0.001]
        if len(non_zero) > 0:
            neg_ratio = np.sum(non_zero < 0) / len(non_zero)
            mean_abs = np.mean(np.abs(non_zero))
            if neg_ratio < 0.2 and cfg["tradeoff_amplifier"] <= 1.5:
                cfg["tradeoff_amplifier"] = 2.5
            if mean_abs < 0.3 and cfg["force_scale"] < 0.15:
                cfg["force_scale"] = 0.15

    def _build_attractors(self, factors: list, options: list,
                          n_dims: int) -> np.ndarray:
        """Build normalized attractor positions from options."""
        if not options:
            return np.full((1, n_dims), 0.5, dtype=np.float32)

        positions = np.full((len(options), n_dims), 0.5, dtype=np.float32)
        for oi, opt in enumerate(options):
            if isinstance(opt, str):
                continue
            for di, f in enumerate(factors):
                fn = f["name"]
                if fn in opt and fn != "name":
                    lo, hi = f.get("range", [0, 100])
                    if hi != lo:
                        positions[oi, di] = np.clip((opt[fn] - lo) / (hi - lo), 0, 1)
        return positions

    def _init_probes(self, count: int, n_dims: int,
                     attractor_positions: np.ndarray,
                     perturbation: float) -> tuple:
        """Initialize probes distributed across attractors.

        ~60% near specific attractors (basin seeding), ~40% near centroid (exploration).
        This ensures probes can separate into distinct strategy clusters.
        """
        n_att = attractor_positions.shape[0]
        amplitudes = np.zeros((count, n_dims), dtype=np.float32)
        velocities = np.zeros((count, n_dims), dtype=np.float32)

        if n_att == 0:
            amplitudes[:] = 0.5 + (np.random.rand(count, n_dims).astype(np.float32) - 0.5) * 2 * perturbation
            return np.clip(amplitudes, 0.05, 0.95), velocities

        # Allocate: ~60% across attractors, ~40% exploration
        per_att = max(2, int(count * 0.6 / n_att))
        att_total = per_att * n_att
        explore_total = count - att_total

        idx = 0
        for a in range(n_att):
            for _ in range(per_att):
                if idx >= count:
                    break
                amplitudes[idx] = attractor_positions[a] + \
                    (np.random.rand(n_dims).astype(np.float32) - 0.5) * 2 * perturbation
                idx += 1

        # Exploration probes near centroid with wider spread
        centroid = attractor_positions.mean(axis=0)
        for _ in range(explore_total):
            if idx >= count:
                break
            amplitudes[idx] = centroid + \
                (np.random.rand(n_dims).astype(np.float32) - 0.5) * 2 * perturbation * 1.5
            idx += 1

        return np.clip(amplitudes, 0.05, 0.95), velocities

    def _cluster_probes(self, amplitudes: np.ndarray, n_dims: int,
                        cfg: dict) -> list:
        """Cluster probes using Sound Block resonance matching.

        Tiered by probe count:
        - P < 2000: Resonance clustering (O(P × K × D)) — attractor-seeded,
          emergent detection via orphan sub-clustering. The colony's own
          physics defines the clusters. No O(P²) distance matrix.
        - P >= 2000: GPU sb_clustering.wgsl (O(P²) but massively parallel)
        """
        P = amplitudes.shape[0]
        if P == 0:
            return []

        threshold = cfg.get("clustering_grid_threshold", 2000)
        if P >= threshold:
            return self._cluster_probes_grid(amplitudes, n_dims, cfg)

        return self._cluster_probes_resonance(amplitudes, n_dims, cfg)

    def _cluster_probes_resonance(self, amplitudes: np.ndarray, n_dims: int,
                                   cfg: dict) -> list:
        """Sound Block resonance clustering — O(P × K × D).

        The colony's physics already created the clusters. Probes near the same
        attractor basin have similar amplitudes. Instead of discovering clusters
        from scratch (O(P²)), use the attractor structure the colony already knows.

        Phase 1: Seed centers from attractor positions (FREE — already computed).
                 The colony pulled probes toward these. They ARE approximate centers.

        Phase 2: Resonance assignment — each probe gets a resonance score
                 against each center: score = 1 / (1 + distance).
                 Highest resonance = cluster assignment. O(P × A × D).

        Phase 3: Emergent detection — probes with low resonance to ALL attractors
                 (below threshold) form a new cluster. Captures strategies that
                 emerged between basins. O(P × D) per emergent cluster.

        Phase 4: Refine — recompute centroids from actual members. One pass.

        Total: O(P × K × D) where K = num_attractors + emergent.
        No O(P²). No distance matrix. The colony clusters itself.
        """
        P = amplitudes.shape[0]
        max_strategies = cfg.get("max_strategies", 32)
        radius = cfg["peak_merge_radius"] * 2.0 + 0.06 * np.log(n_dims)

        # ── Phase 1: Seed from attractor positions (already computed) ──
        # These are stored in self._attractor_cache (set during run()).
        # Fall back to probe-mean centroid if no attractors.
        if hasattr(self, '_attractor_cache') and self._attractor_cache is not None:
            initial_centers = self._attractor_cache.copy()
        else:
            # No attractor data — use global centroid as single seed
            initial_centers = amplitudes.mean(axis=0, keepdims=True)

        centers = initial_centers.astype(np.float32)
        K = centers.shape[0]

        # ── Phase 2: Resonance assignment — O(P × K × D) ──
        # score = 1 / (1 + distance) — Sound Block resonance matching
        probe_norms = np.sum(amplitudes ** 2, axis=1)        # P
        center_norms = np.sum(centers ** 2, axis=1)           # K
        dot_products = amplitudes @ centers.T                  # P × K
        dist_sq = probe_norms[:, None] + center_norms[None, :] - 2.0 * dot_products
        np.clip(dist_sq, 0.0, None, out=dist_sq)
        distances = np.sqrt(dist_sq)                           # P × K

        resonance = 1.0 / (1.0 + distances)                   # P × K
        assignments = np.argmax(resonance, axis=1)             # P
        max_resonance = np.max(resonance, axis=1)              # P

        # ── Phase 3: Emergent detection ──
        # Probes with low resonance to ALL attractors are in emergent basins.
        # Threshold: resonance < 1/(1+radius) means probe is further than radius
        # from its nearest attractor.
        emergent_threshold = 1.0 / (1.0 + radius)
        orphan_mask = max_resonance < emergent_threshold
        orphan_idx = np.where(orphan_mask)[0]

        emergent_centers = []
        if len(orphan_idx) > 0:
            # Cluster orphans among themselves using greedy density search
            # This is O(orphans²) but orphans << P (typically 5-20% of probes)
            orphan_amps = amplitudes[orphan_idx]
            orphan_assigned = np.zeros(len(orphan_idx), dtype=bool)

            for _ in range(max_strategies - K):
                remaining = np.where(~orphan_assigned)[0]
                if len(remaining) == 0:
                    break

                if len(remaining) <= 2:
                    # Just one cluster for the stragglers
                    centroid = orphan_amps[remaining].mean(axis=0)
                    emergent_centers.append((centroid, orphan_idx[remaining]))
                    break

                # Find densest orphan
                sub = orphan_amps[remaining]
                sub_norms = np.sum(sub ** 2, axis=1)
                sub_dist_sq = sub_norms[:, None] + sub_norms[None, :] - 2.0 * (sub @ sub.T)
                np.clip(sub_dist_sq, 0.0, None, out=sub_dist_sq)
                sub_dists = np.sqrt(sub_dist_sq)
                counts = np.sum(sub_dists < radius, axis=1)

                best = int(np.argmax(counts))
                if counts[best] < 1:
                    break

                neighbor_mask = sub_dists[best] < radius
                neighbor_local = remaining[neighbor_mask]
                centroid = orphan_amps[neighbor_local].mean(axis=0)
                emergent_centers.append((centroid, orphan_idx[neighbor_local]))
                orphan_assigned[neighbor_local] = True

        # ── Phase 4: Build final clusters ──
        clusters = []

        # Attractor-seeded clusters
        for k in range(K):
            member_mask = (assignments == k) & (~orphan_mask)
            member_indices = list(np.where(member_mask)[0])
            if not member_indices:
                continue
            centroid = amplitudes[member_indices].mean(axis=0)
            clusters.append({
                "centroid": centroid,
                "cell_count": len(member_indices),
                "member_indices": member_indices,
            })

        # Emergent clusters
        for centroid, members in emergent_centers:
            clusters.append({
                "centroid": centroid,
                "cell_count": len(members),
                "member_indices": list(members),
            })

        clusters.sort(key=lambda c: c["cell_count"], reverse=True)
        return clusters[:max_strategies]

    def _cluster_probes_exact(self, amplitudes: np.ndarray, n_dims: int,
                              cfg: dict) -> list:
        """O(N^2) exact pairwise distance clustering. Best for P < 2000.

        Optimized: matmul-based distance matrix + fully vectorized seed search.
        The seed search uses boolean matrix ops instead of per-probe Python loops,
        giving ~14x speedup on the cluster search phase (critical at D >= 30).
        """
        P = amplitudes.shape[0]
        radius = cfg["peak_merge_radius"] * 2.0 + 0.06 * np.log(n_dims)
        min_size = 1
        max_strategies = cfg.get("max_strategies", 32)
        assigned = np.zeros(P, dtype=bool)
        clusters = []

        # Matmul-based pairwise distances: ||a-b||^2 = ||a||^2 + ||b||^2 - 2*a.b
        norms = np.sum(amplitudes ** 2, axis=1)
        dist_sq = norms[:, None] + norms[None, :] - 2.0 * (amplitudes @ amplitudes.T)
        np.clip(dist_sq, 0.0, None, out=dist_sq)
        dists = np.sqrt(dist_sq, dtype=np.float32)

        # Pre-compute radius mask once (P x P bool matrix)
        within_radius = dists < radius

        while len(clusters) < max_strategies:
            # Vectorized seed search: count neighbors for ALL unassigned at once
            unassigned_mask = ~assigned
            neighbor_counts = np.sum(within_radius & unassigned_mask[None, :], axis=1)
            neighbor_counts[assigned] = 0  # exclude assigned probes as seeds

            best_idx = int(np.argmax(neighbor_counts))
            if neighbor_counts[best_idx] < min_size:
                break

            # Assign all unassigned within radius of best seed
            member_mask = unassigned_mask & within_radius[best_idx]
            assigned |= member_mask
            members = list(np.where(member_mask)[0])

            centroid = amplitudes[members].mean(axis=0)
            clusters.append({
                "centroid": centroid,
                "cell_count": len(members),
                "member_indices": members,
            })

        clusters.sort(key=lambda c: c["cell_count"], reverse=True)
        return clusters

    def _cluster_probes_grid(self, amplitudes: np.ndarray, n_dims: int,
                             cfg: dict) -> list:
        """O(N) amortized grid-based spatial hashing for large probe counts.

        Partitions [0,1]^D into grid cells of size = cluster_radius.
        Each probe hashes to a cell. Neighbors are found by checking
        adjacent cells only (3^D neighbors, but D is typically small
        relative to probe count).

        For D > 20, falls back to approximate centroid merging.
        """
        P = amplitudes.shape[0]
        radius = cfg["peak_merge_radius"] * 2.0 + 0.06 * np.log(n_dims)
        max_strategies = cfg.get("max_strategies", 32)

        if n_dims > 20:
            # High-D: grid cells explode (3^D neighbors). Use centroid merging.
            return self._cluster_probes_centroid_merge(amplitudes, n_dims, cfg)

        # Grid cell size = radius (so neighbors within one cell are within radius)
        cell_size = radius
        if cell_size <= 0:
            cell_size = 0.1

        # Hash each probe to a grid cell
        cell_indices = np.floor(amplitudes / cell_size).astype(np.int32)
        cell_keys = {}  # tuple(cell_index) → [probe_indices]
        for i in range(P):
            key = tuple(cell_indices[i])
            if key not in cell_keys:
                cell_keys[key] = []
            cell_keys[key].append(i)

        # Build clusters: largest cells first
        assigned = np.zeros(P, dtype=bool)
        clusters = []

        # Sort cells by population descending
        sorted_cells = sorted(cell_keys.items(), key=lambda x: len(x[1]), reverse=True)

        for cell_key, members in sorted_cells:
            if len(clusters) >= max_strategies:
                break

            # Filter out already-assigned probes
            live_members = [m for m in members if not assigned[m]]
            if not live_members:
                continue

            # Also collect from neighboring cells within radius
            all_neighbors = list(live_members)
            # Check adjacent cells in each dimension (±1)
            offsets = self._grid_neighbor_offsets(n_dims)
            for offset in offsets:
                neighbor_key = tuple(cell_key[d] + offset[d] for d in range(n_dims))
                if neighbor_key in cell_keys:
                    for j in cell_keys[neighbor_key]:
                        if not assigned[j] and j not in live_members:
                            # Distance check
                            dist = np.sqrt(np.sum(
                                (amplitudes[live_members[0]] - amplitudes[j]) ** 2))
                            if dist < radius:
                                all_neighbors.append(j)

            if not all_neighbors:
                continue

            for m in all_neighbors:
                assigned[m] = True

            centroid = amplitudes[all_neighbors].mean(axis=0)
            clusters.append({
                "centroid": centroid,
                "cell_count": len(all_neighbors),
                "member_indices": all_neighbors,
            })

        clusters.sort(key=lambda c: c["cell_count"], reverse=True)
        return clusters

    def _cluster_probes_centroid_merge(self, amplitudes: np.ndarray,
                                       n_dims: int, cfg: dict) -> list:
        """Agglomerative centroid merging for very high dimensions.

        O(K² × D) where K starts at P and merges down. Fast when P >> D.
        Uses iterative closest-pair merging until radius threshold met.
        """
        P = amplitudes.shape[0]
        radius = cfg["peak_merge_radius"] * 2.0 + 0.06 * np.log(n_dims)
        max_strategies = cfg.get("max_strategies", 32)

        # Start with each probe as its own cluster
        active = []
        for i in range(P):
            active.append({
                "centroid": amplitudes[i].copy(),
                "cell_count": 1,
                "member_indices": [i],
            })

        # Iteratively merge closest pair within radius
        merged = True
        while merged and len(active) > max_strategies:
            merged = False
            best_i, best_j, best_dist = -1, -1, float("inf")

            # Find closest pair
            for i in range(len(active)):
                for j in range(i + 1, len(active)):
                    d = np.sqrt(np.sum(
                        (active[i]["centroid"] - active[j]["centroid"]) ** 2))
                    if d < best_dist:
                        best_dist = d
                        best_i, best_j = i, j

            if best_dist < radius and best_i >= 0:
                # Merge j into i
                ci = active[best_i]
                cj = active[best_j]
                total = ci["cell_count"] + cj["cell_count"]
                ci["centroid"] = (ci["centroid"] * ci["cell_count"] +
                                  cj["centroid"] * cj["cell_count"]) / total
                ci["cell_count"] = total
                ci["member_indices"].extend(cj["member_indices"])
                active.pop(best_j)
                merged = True

        active.sort(key=lambda c: c["cell_count"], reverse=True)
        return active[:max_strategies]

    @staticmethod
    def _grid_neighbor_offsets(n_dims: int) -> list:
        """Generate all ±1 offset tuples for grid neighbor lookup.
        For D dims, produces 3^D - 1 neighbors (excludes (0,0,...,0)).
        Cached internally for reuse.
        """
        if n_dims > 10:
            # 3^10 = 59049 neighbors — too many, just use ±1 per axis
            offsets = []
            for d in range(n_dims):
                for delta in [-1, 1]:
                    offset = [0] * n_dims
                    offset[d] = delta
                    offsets.append(tuple(offset))
            return offsets

        from itertools import product
        all_offsets = list(product([-1, 0, 1], repeat=n_dims))
        return [o for o in all_offsets if any(v != 0 for v in o)]

    def _extract_strategies(self, clusters: list, factors: list,
                            factor_names: list, options: list,
                            attractor_positions: np.ndarray,
                            n_dims: int) -> list:
        """Convert clusters to strategies with real-unit values."""
        option_list = []
        for opt in options:
            if isinstance(opt, str):
                option_list.append({"name": opt})
            else:
                option_list.append(opt)

        strategies = []
        for idx, cl in enumerate(clusters):
            centroid = cl["centroid"]

            # Per-factor weights
            factor_weights = {}
            real_values = {}
            for d in range(n_dims):
                factor_weights[factor_names[d]] = round(float(centroid[d]), 3)
                f = factors[d]
                lo, hi = f.get("range", [0, 100])
                real_values[factor_names[d]] = round(lo + float(centroid[d]) * (hi - lo), 2)

            # Find nearest option
            nearest_option = None
            nearest_dist = float("inf")
            for oi, opt in enumerate(option_list):
                if "name" not in opt:
                    continue
                dist = 0
                matched = 0
                for d in range(n_dims):
                    fn = factor_names[d]
                    if fn in opt and fn != "name":
                        lo, hi = factors[d].get("range", [0, 100])
                        if hi != lo:
                            opt_norm = (opt[fn] - lo) / (hi - lo)
                            dist += (float(centroid[d]) - opt_norm) ** 2
                            matched += 1
                if matched > 0:
                    dist = np.sqrt(dist / matched)
                    if dist < nearest_dist and dist < 0.3:
                        nearest_dist = dist
                        nearest_option = opt["name"]

            # Mixed outcome check
            w_vals = list(factor_weights.values())
            w_max = max(w_vals)
            w_avg = sum(w_vals) / len(w_vals)
            is_mixed = w_avg > 0 and w_max < w_avg * 1.5

            if is_mixed:
                label = f"Mixed Outcome {idx + 1}"
            elif nearest_option:
                label = nearest_option
            else:
                label = f"Emergent Outcome {idx + 1}"

            strategies.append({
                "label": label,
                "cell_count": cl["cell_count"],
                "values": real_values,
                "concept_weights": factor_weights,
                "is_emergent": not nearest_option and not is_mixed,
                "is_mixed": is_mixed,
                "centroid_raw": centroid.tolist(),
            })

        return strategies

    # ─── GPU Sound Block: World Model + Preflight + Extract + Output WM ────────
    #
    # These replace ALL remaining Python in the hot path.
    # Python is the bootstrap. Everything else is Sound Blocks.

    def _ensure_wm_pipeline(self):
        """Lazy-init the world model GPU pipeline (sb_world_model.wgsl)."""
        if hasattr(self, '_wm_pipeline'):
            return

        d = self._device
        src = (_SHADER_DIR / "sb_world_model.wgsl").read_text()
        module = d.create_shader_module(code=src)

        self._wm_bgl = d.create_bind_group_layout(entries=[
            {"binding": 0, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.uniform}},
            {"binding": 1, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.read_only_storage}},
            {"binding": 2, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.storage}},
            {"binding": 3, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.read_only_storage}},
            {"binding": 4, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.storage}},
        ])
        pl = d.create_pipeline_layout(bind_group_layouts=[self._wm_bgl])
        self._wm_pipeline = d.create_compute_pipeline(
            layout=pl, compute={"module": module, "entry_point": "build_matrix"})
        self._preflight_pipeline = d.create_compute_pipeline(
            layout=pl, compute={"module": module, "entry_point": "preflight"})

        # Shared diagnostics buffer (8 floats = 32 bytes)
        self._buf_diagnostics = d.create_buffer(
            size=32,
            usage=wgpu.BufferUsage.STORAGE | wgpu.BufferUsage.COPY_SRC | wgpu.BufferUsage.COPY_DST)

        # Relationship buffer (max 200 relationships × 16 bytes = 3200 bytes)
        self._buf_relationships = d.create_buffer(
            size=200 * 16,
            usage=wgpu.BufferUsage.STORAGE | wgpu.BufferUsage.COPY_DST)

        # WM params buffer (32 bytes)
        self._buf_wm_params = d.create_buffer(
            size=32,
            usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST)

    def build_matrix_gpu(self, factors: list, relationships: list,
                         options: list) -> tuple:
        """Build influence matrix entirely on GPU.

        Returns (matrix_np, attractor_positions_np, preflight_dict).
        The matrix and attractors are already uploaded to GPU buffers.
        """
        self._ensure_wm_pipeline()
        d = self._device
        D = self.num_dims
        n_rels = len(relationships)

        factor_names = [f["name"] for f in factors]
        name_to_idx = {f["name"]: i for i, f in enumerate(factors)}

        # ── Pack relationships into GPU struct array ──
        # Each struct: from_idx, to_idx, strength, feedback_strength
        # Primary link: matrix[from][to] = strength
        # Feedback link: matrix[to][from] = feedback_strength (for leaf targets)
        outgoing = set(r.get("from") for r in relationships)
        rel_structs = []

        for rel in relationships:
            fi = name_to_idx.get(rel.get("from"))
            fj = name_to_idx.get(rel.get("to"))
            if fi is not None and fj is not None:
                strength = rel.get("strength", rel.get("weight", 0.5))
                # Check if target is a leaf (no outgoing) — add feedback
                target_name = rel.get("to")
                fb = strength * 0.4 if target_name not in outgoing else 0.0
                rel_structs.append(struct.pack("IIff", fi, fj, strength, fb))

        total_rels = len(rel_structs)

        # Build attractor positions (same logic as _build_attractors but inline)
        if not options:
            att_pos = np.full((1, D), 0.5, dtype=np.float32)
        else:
            att_pos = np.full((len(options), D), 0.5, dtype=np.float32)
            for oi, opt in enumerate(options):
                if isinstance(opt, str):
                    continue
                for di, f in enumerate(factors):
                    fn = f["name"]
                    if fn in opt and fn != "name":
                        lo, hi = f.get("range", [0, 100])
                        if hi != lo:
                            att_pos[oi, di] = np.clip((opt[fn] - lo) / (hi - lo), 0, 1)

        n_att = att_pos.shape[0]

        # ── Upload data ──
        # Zero the matrix buffer first
        d.queue.write_buffer(self._buf_influence_matrix, 0,
                             np.zeros(D * D, dtype=np.float32).tobytes())

        # Upload relationships
        rel_data = b"".join(rel_structs)
        # Pad to minimum buffer size
        while len(rel_data) < 16:
            rel_data += b"\x00" * 16
        d.queue.write_buffer(self._buf_relationships, 0, rel_data)

        # Upload attractor positions
        d.queue.write_buffer(self._buf_attractor_positions, 0,
                             att_pos.astype(np.float32).tobytes())
        self._num_attractors = n_att
        self._attractor_cache = att_pos

        # Upload WM params
        # struct WMParams: num_dims, num_attractors, num_relationships, num_factors,
        #   weak_sep, low_var, sparse_matrix, pad
        wm_params = struct.pack("IIIIffff",
            D, n_att, total_rels, D,
            0.2 * np.sqrt(D),  # weak_separation_threshold
            0.01,              # low_variance_threshold
            0.08,              # sparse_matrix_threshold
            0.0)
        d.queue.write_buffer(self._buf_wm_params, 0, wm_params)

        # Zero diagnostics
        d.queue.write_buffer(self._buf_diagnostics, 0, b"\x00" * 32)

        # ── Build bind group ──
        bg = d.create_bind_group(layout=self._wm_bgl, entries=[
            {"binding": 0, "resource": {"buffer": self._buf_wm_params}},
            {"binding": 1, "resource": {"buffer": self._buf_relationships,
                                        "size": max(16, total_rels * 16)}},
            {"binding": 2, "resource": {"buffer": self._buf_influence_matrix}},
            {"binding": 3, "resource": {"buffer": self._buf_attractor_positions,
                                        "size": n_att * D * 4}},
            {"binding": 4, "resource": {"buffer": self._buf_diagnostics}},
        ])

        # ── Dispatch: build_matrix + preflight in one encoder ──
        encoder = d.create_command_encoder()

        if total_rels > 0:
            cp1 = encoder.begin_compute_pass()
            cp1.set_pipeline(self._wm_pipeline)
            cp1.set_bind_group(0, bg)
            cp1.dispatch_workgroups((total_rels + 63) // 64)
            cp1.end()

        cp2 = encoder.begin_compute_pass()
        cp2.set_pipeline(self._preflight_pipeline)
        cp2.set_bind_group(0, bg)
        cp2.dispatch_workgroups(1)
        cp2.end()

        d.queue.submit([encoder.finish()])

        # ── Readback matrix + diagnostics ──
        d._poll_wait()
        mat_data = d.queue.read_buffer(self._buf_influence_matrix, size=D * D * 4)
        matrix = np.frombuffer(mat_data, dtype=np.float32).reshape(D, D).copy()

        diag_data = d.queue.read_buffer(self._buf_diagnostics, size=32)
        diag = np.frombuffer(diag_data, dtype=np.float32).copy()

        preflight = {
            "min_attractor_distance": float(diag[0]),
            "avg_attractor_distance": float(diag[1]),
            "low_variance_factor_count": int(diag[2]),
            "matrix_density": float(diag[3]),
            "negative_count": int(diag[4]),
            "positive_count": int(diag[5]),
            "mean_abs_value": float(diag[6]),
            "polarity_ratio": float(diag[7]),
            "warnings": [],
        }

        # Generate warnings from GPU diagnostics
        dim_factor = np.sqrt(D)
        if n_att >= 2 and preflight["min_attractor_distance"] < 0.2 * dim_factor:
            preflight["warnings"].append("WEAK_SEPARATION")
        if preflight["low_variance_factor_count"] > D * 0.3:
            preflight["warnings"].append("LOW_VARIANCE_FACTORS")
        if D >= 8 and preflight["matrix_density"] < 0.08:
            preflight["warnings"].append("SPARSE_MATRIX")
        if preflight["negative_count"] < 2:
            preflight["warnings"].append("FEW_TRADEOFFS")

        self._bind_group_dirty = True
        return matrix, att_pos, preflight

    def _ensure_extract_pipeline(self):
        """Lazy-init the strategy extraction GPU pipeline (sb_strategy_extract.wgsl)."""
        if hasattr(self, '_extract_pipeline'):
            return

        d = self._device
        src = (_SHADER_DIR / "sb_strategy_extract.wgsl").read_text()
        module = d.create_shader_module(code=src)

        self._extract_bgl = d.create_bind_group_layout(entries=[
            {"binding": 0, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.uniform}},
            {"binding": 1, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.read_only_storage}},
            {"binding": 2, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.read_only_storage}},
            {"binding": 3, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.read_only_storage}},
            {"binding": 4, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.storage}},
            {"binding": 5, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.storage}},
            {"binding": 6, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.read_only_storage}},
            {"binding": 7, "visibility": wgpu.ShaderStage.COMPUTE,
             "buffer": {"type": wgpu.BufferBindingType.storage}},
        ])
        pl = d.create_pipeline_layout(bind_group_layouts=[self._extract_bgl])

        self._denorm_pipeline = d.create_compute_pipeline(
            layout=pl, compute={"module": module, "entry_point": "denormalize"})
        self._match_pipeline = d.create_compute_pipeline(
            layout=pl, compute={"module": module, "entry_point": "label_match"})
        self._pearson_pipeline = d.create_compute_pipeline(
            layout=pl, compute={"module": module, "entry_point": "pearson_corr"})

        D = self.num_dims
        K_max = 64   # max clusters
        O_max = 32   # max options

        RW = (wgpu.BufferUsage.STORAGE |
              wgpu.BufferUsage.COPY_SRC |
              wgpu.BufferUsage.COPY_DST)
        RO = wgpu.BufferUsage.STORAGE | wgpu.BufferUsage.COPY_DST

        self._buf_centroids = d.create_buffer(size=K_max * D * 4, usage=RO)
        self._buf_factor_ranges = d.create_buffer(size=D * 2 * 4, usage=RO)
        self._buf_option_positions = d.create_buffer(size=O_max * D * 4, usage=RO)
        self._buf_real_values = d.create_buffer(size=K_max * D * 4, usage=RW)
        self._buf_match_results = d.create_buffer(size=K_max * 3 * 4, usage=RW)
        self._buf_cell_counts = d.create_buffer(size=K_max * 4, usage=RO)
        self._buf_corr_matrix = d.create_buffer(size=D * D * 4, usage=RW)
        self._buf_extract_params = d.create_buffer(
            size=16,
            usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST)

    def extract_strategies_gpu(self, clusters: list, factors: list,
                                factor_names: list, options: list,
                                attractor_positions: np.ndarray,
                                n_dims: int) -> tuple:
        """Extract strategies + output WM entirely on GPU.

        Returns (strategies_list, output_world_model_dict).
        Three GPU dispatches: denormalize → label_match → pearson_corr.
        """
        self._ensure_extract_pipeline()
        d = self._device
        D = n_dims
        K = len(clusters)
        if K == 0:
            return [], None

        # ── Pack centroids + cell counts ──
        centroids = np.array([c["centroid"] for c in clusters], dtype=np.float32)
        cell_counts = np.array([c["cell_count"] for c in clusters], dtype=np.uint32)

        # ── Pack factor ranges ──
        ranges = np.zeros((D, 2), dtype=np.float32)
        for di, f in enumerate(factors):
            lo, hi = f.get("range", [0, 100])
            ranges[di] = [lo, hi]

        # ── Pack option positions (normalized) ──
        option_list = []
        for opt in options:
            if isinstance(opt, str):
                option_list.append({"name": opt})
            else:
                option_list.append(opt)

        O = len(option_list)
        opt_pos = np.full((max(O, 1), D), 0.5, dtype=np.float32)
        for oi, opt in enumerate(option_list):
            for di, f in enumerate(factors):
                fn = f["name"]
                if fn in opt and fn != "name":
                    lo, hi = f.get("range", [0, 100])
                    if hi != lo:
                        opt_pos[oi, di] = np.clip((opt[fn] - lo) / (hi - lo), 0, 1)

        # ── Upload ──
        d.queue.write_buffer(self._buf_centroids, 0, centroids.tobytes())
        d.queue.write_buffer(self._buf_cell_counts, 0, cell_counts.tobytes())
        d.queue.write_buffer(self._buf_factor_ranges, 0, ranges.tobytes())
        d.queue.write_buffer(self._buf_option_positions, 0, opt_pos.tobytes())
        d.queue.write_buffer(self._buf_corr_matrix, 0,
                             np.zeros(D * D, dtype=np.float32).tobytes())

        # Extract params
        d.queue.write_buffer(self._buf_extract_params, 0,
                             struct.pack("IIIf", K, D, O, 0.3))

        # ── Build bind group ──
        bg = d.create_bind_group(layout=self._extract_bgl, entries=[
            {"binding": 0, "resource": {"buffer": self._buf_extract_params}},
            {"binding": 1, "resource": {"buffer": self._buf_centroids,
                                        "size": K * D * 4}},
            {"binding": 2, "resource": {"buffer": self._buf_factor_ranges}},
            {"binding": 3, "resource": {"buffer": self._buf_option_positions,
                                        "size": max(O, 1) * D * 4}},
            {"binding": 4, "resource": {"buffer": self._buf_real_values,
                                        "size": K * D * 4}},
            {"binding": 5, "resource": {"buffer": self._buf_match_results,
                                        "size": K * 3 * 4}},
            {"binding": 6, "resource": {"buffer": self._buf_cell_counts,
                                        "size": K * 4}},
            {"binding": 7, "resource": {"buffer": self._buf_corr_matrix}},
        ])

        # ── Dispatch: denorm + label_match + pearson in one encoder ──
        total_pairs = D * (D - 1) // 2
        encoder = d.create_command_encoder()

        cp1 = encoder.begin_compute_pass()
        cp1.set_pipeline(self._denorm_pipeline)
        cp1.set_bind_group(0, bg)
        cp1.dispatch_workgroups((K + 63) // 64)
        cp1.end()

        cp2 = encoder.begin_compute_pass()
        cp2.set_pipeline(self._match_pipeline)
        cp2.set_bind_group(0, bg)
        cp2.dispatch_workgroups((K + 63) // 64)
        cp2.end()

        if K >= 2:
            cp3 = encoder.begin_compute_pass()
            cp3.set_pipeline(self._pearson_pipeline)
            cp3.set_bind_group(0, bg)
            cp3.dispatch_workgroups((total_pairs + 63) // 64)
            cp3.end()

        d.queue.submit([encoder.finish()])

        # ── Readback ──
        d._poll_wait()
        rv_data = d.queue.read_buffer(self._buf_real_values, size=K * D * 4)
        real_vals = np.frombuffer(rv_data, dtype=np.float32).reshape(K, D).copy()

        mr_data = d.queue.read_buffer(self._buf_match_results, size=K * 3 * 4)
        match_res = np.frombuffer(mr_data, dtype=np.float32).reshape(K, 3).copy()

        corr_data = d.queue.read_buffer(self._buf_corr_matrix, size=D * D * 4)
        corr_mat = np.frombuffer(corr_data, dtype=np.float32).reshape(D, D).copy()

        # ── Build strategy dicts ──
        strategies = []
        for k in range(K):
            best_opt_idx = int(match_res[k, 0])
            best_dist = float(match_res[k, 1])
            is_mixed = match_res[k, 2] > 0.5

            factor_weights = {}
            real_values_dict = {}
            for di in range(D):
                factor_weights[factor_names[di]] = round(float(centroids[k, di]), 3)
                real_values_dict[factor_names[di]] = round(float(real_vals[k, di]), 2)

            nearest_option = None
            if best_opt_idx >= 0 and best_opt_idx < O:
                nearest_option = option_list[best_opt_idx].get("name")

            if is_mixed:
                label = f"Mixed Outcome {k + 1}"
            elif nearest_option:
                label = nearest_option
            else:
                label = f"Emergent Outcome {k + 1}"

            strategies.append({
                "label": label,
                "cell_count": int(cell_counts[k]),
                "values": real_values_dict,
                "concept_weights": factor_weights,
                "is_emergent": not nearest_option and not is_mixed,
                "is_mixed": is_mixed,
                "centroid_raw": centroids[k].tolist(),
            })

        # ── Build output world model from Pearson correlation matrix ──
        owm_factors = [
            {"name": fn, "range": [0, 100], "unit": "score",
             "displayName": fn.replace("_", " ").title()}
            for fn in factor_names
        ]
        owm_options = []
        for s in strategies:
            opt = {"name": s["label"]}
            for fn in factor_names:
                opt[fn] = round(s["concept_weights"].get(fn, 0.0) * 100, 2)
            owm_options.append(opt)

        owm_relationships = []
        for i in range(D):
            for j in range(i + 1, D):
                r = float(corr_mat[i, j])
                if abs(r) > 0.3:
                    owm_relationships.append({
                        "from": factor_names[i],
                        "to": factor_names[j],
                        "strength": round(r, 4),
                    })

        output_wm = {
            "factors": owm_factors,
            "options": owm_options,
            "relationships": owm_relationships,
            "_derived": True,
            "_strategy_count": K,
        } if K > 0 else None

        return strategies, output_wm

    def run_full_gpu(self, input_dict: dict, config: Optional[dict] = None) -> dict:
        """Run the COMPLETE pipeline on GPU — zero Python in the hot path.

        Python is the bootstrap. Everything else is Sound Blocks:
          1. build_matrix_gpu() — influence matrix + preflight (sb_world_model.wgsl)
          2. GPU homeostasis calibration (sb_homeostasis.wgsl) — if available
          3. Colony modulation loop (sb_colony_block.wgsl)
          4. GPU clustering (sb_clustering.wgsl)
          5. extract_strategies_gpu() — denorm + match + Pearson (sb_strategy_extract.wgsl)

        MongoDB I/O is sync/persistence, not in the hot path.
        """
        t0 = time.perf_counter()
        cfg = {**DEFAULT_CONFIG, **(config or {})}
        timings = {}

        factors = input_dict.get("factors", [])
        relationships = input_dict.get("relationships", [])
        options = input_dict.get("options", [])
        factor_names = [f["name"] for f in factors]
        n_dims = len(factors)
        assert n_dims > 0, "At least one factor required"

        if cfg["gravity_range"] is None:
            cfg["gravity_range"] = max(2.0, min(6.0, 1.5 * np.sqrt(n_dims)))

        # ── Phase 1+2: Build matrix + preflight (GPU) ──
        t1 = time.perf_counter()
        matrix, attractor_positions, preflight = self.build_matrix_gpu(
            factors, relationships, options)
        timings["build_matrix_gpu"] = time.perf_counter() - t1

        # ── Phase 2b: GPU Homeostasis Calibration ──
        t2 = time.perf_counter()
        try:
            from .sb_homeostasis_kernel import SBHomeostasisKernel
            hk = SBHomeostasisKernel(num_dims=n_dims,
                                     max_attractors=attractor_positions.shape[0])
            cal_result = hk.calibrate(matrix, attractor_positions, cfg)
            wc = cal_result.get("winning_config", {})
            for k, v in wc.items():
                if k in cfg:
                    cfg[k] = v
            cfg["_calibration"] = cal_result.get("calibration", {})
        except Exception:
            # Fallback to static calibration
            self._calibrate(cfg, n_dims, matrix)
        timings["calibrate_gpu"] = time.perf_counter() - t2

        # ── Phase 3: Upload matrix + attractors + probes ──
        t3 = time.perf_counter()
        self.upload_matrix(matrix)
        self.upload_attractors(attractor_positions)

        probe_count = cfg["probe_count"]
        if n_dims >= 8 and probe_count < n_dims * 4:
            probe_count = max(probe_count, n_dims * 4)

        amplitudes, velocities = self._init_probes(
            probe_count, n_dims, attractor_positions, cfg["probe_perturbation"])
        self.upload_probes(amplitudes, velocities)
        timings["initialize"] = time.perf_counter() - t3

        # ── Phase 4: GPU modulation loop ──
        t4 = time.perf_counter()
        total_passes = cfg["max_passes"]
        check_interval = cfg["convergence_check_interval"]
        fused_count = cfg["fused_passes"]
        convergence_history = []
        converged = False
        pass_count = 0

        d = self._device
        d.queue.write_buffer(self._buf_params, 0,
                             self._pack_static_params(total_passes, cfg))

        use_gpu_reduction = probe_count >= 64

        while pass_count < total_passes:
            batch_size = min(fused_count, total_passes - pass_count)
            self.dispatch_fused(pass_count, batch_size, total_passes, cfg)
            pass_count += batch_size

            effective_interval = check_interval if pass_count < 20 else check_interval * 4
            if pass_count >= cfg["min_passes"] and pass_count % effective_interval < batch_size:
                if use_gpu_reduction:
                    max_delta = self.dispatch_reduce_max_delta()
                else:
                    deltas = self.readback_deltas()
                    max_delta = float(np.max(deltas))
                convergence_history.append(max_delta)
                if max_delta < cfg["convergence_threshold"]:
                    converged = True
                    break

        timings["modulation"] = time.perf_counter() - t4

        # ── Phase 5: Clustering (GPU) ──
        t5 = time.perf_counter()
        gpu_cluster_threshold = cfg.get("clustering_grid_threshold", 2000)
        if probe_count >= gpu_cluster_threshold:
            clusters = self._cluster_probes_gpu(n_dims, cfg)
            final_amplitudes = self.readback_amplitudes()
        else:
            final_amplitudes = self.readback_amplitudes()
            clusters = self._cluster_probes(final_amplitudes, n_dims, cfg)
        timings["clustering"] = time.perf_counter() - t5

        # ── Phase 6: Strategy extraction + Output WM (GPU) ──
        t6 = time.perf_counter()
        strategies, output_wm = self.extract_strategies_gpu(
            clusters, factors, factor_names, options,
            attractor_positions, n_dims)
        timings["extraction_gpu"] = time.perf_counter() - t6

        total_ms = (time.perf_counter() - t0) * 1000

        emergent_count = sum(1 for s in strategies if s.get("is_emergent"))
        strongest = max(strategies, key=lambda s: s["cell_count"]) if strategies else None

        return {
            "strategies": strategies,
            "summary": {
                "total_strategies": len(strategies),
                "emergent_count": emergent_count,
                "strongest_outcome": strongest["label"] if strongest else None,
                "strongest_cells": strongest["cell_count"] if strongest else 0,
                "total_cells": probe_count,
                "generations_run": pass_count,
                "dimensions": n_dims,
                "converged": converged,
                "convergence_pass": pass_count if converged else None,
            },
            "total_ms": total_ms,
            "timings": timings,
            "pass_count": pass_count,
            "converged": converged,
            "convergence_history": convergence_history,
            "influence_matrix": matrix.tolist(),
            "cluster_count": len(clusters),
            "config": cfg,
            "preflight": preflight,
            "output_world_model": output_wm,
        }

    @property
    def adapter_info(self) -> dict:
        return dict(self._device.adapter.info)
