"""
Homeostasis Calibrator — Cold-start auto-tuning for AFS fields.

Port of src/afs/core/FieldCalibrator.js to Python.

Problem: Engine parameters that work for a 5D field with strong trade-offs
fail for a 12D field with weak similarities. Every compiled field has
different dimensionality, matrix polarity, and similarity spread.

Solution: Run a series of short probe colonies with different parameter
configurations, score each on differentiation quality, pick the best.

Usage:
    calibrator = HomeostasisCalibrator(field_definition)
    config = calibrator.calibrate()
    engine = AfsEngine(field_definition, config)
"""

import math
import time
import numpy as np
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from typing import Optional, Callable

from ..core.field_definition import FieldDefinition
from ..core.afs_engine import AfsEngine
from ..core.config import AfsSimulationConfig
from ..analysis.cluster_detection import detect_clusters


@dataclass
class FieldAnalysis:
    """Statistics about a field's similarity matrix."""
    dimensions: int = 0
    total_pairs: int = 0
    positive_count: int = 0
    negative_count: int = 0
    zero_count: int = 0
    positive_sum: float = 0.0
    negative_sum: float = 0.0
    min_value: float = float('inf')
    max_value: float = float('-inf')
    mean_abs_value: float = 0.0
    median_abs_value: float = 0.0
    polarity_ratio: float = 0.0
    sparsity: float = 0.0
    spread: float = 0.0


@dataclass
class ProbeScore:
    """Scoring result from a single probe colony."""
    total: float = 0.0
    cluster_count: int = 0
    separation: float = 0.0
    spread: float = 0.0
    energy_health: float = 0.0
    growth_score: float = 0.0
    pair_dist_variance: float = 0.0
    population: int = 0
    avg_energy: float = 0.0
    unstable: bool = False


@dataclass
class ProbeResult:
    """Result from running one probe configuration."""
    label: str
    config: dict
    score: ProbeScore
    elapsed_ms: float


class HomeostasisCalibrator:
    """
    Run short probe colonies with different parameter configurations,
    score each on differentiation quality, pick the best.

    Called once per project session initialization.
    """

    def __init__(self, field_definition: FieldDefinition, **options):
        self.field = field_definition
        # Probe length: goal is to RANK configs, not achieve convergence.
        # Cluster separation signal emerges within 10-15 gens. Keep probes short.
        # Benchmarks show homeostasis dominates total time (84%+) — minimize it.
        n_dims = field_definition.get_dimension_count()
        default_probe_gens = 20 if n_dims >= 10 else (15 if n_dims >= 7 else 10)
        default_probe_cells = 48 if n_dims >= 10 else 32
        self.probe_generations = options.get("probe_generations", default_probe_gens)
        self.probe_cells = options.get("probe_cells", default_probe_cells)
        self.probe_count = options.get("probe_count", 8)
        self.target_cells = options.get("target_cells", 128)
        self.target_max_pop = options.get("target_max_pop", 300)
        # ThreadPoolExecutor doesn't help here — Python's GIL blocks NumPy thread
        # parallelism, and thread overhead exceeds the gain for short probes.
        # ProcessPoolExecutor would bypass the GIL but requires pickling FieldDefinition.
        # Default off. The real speedup comes from reduced probe_generations above.
        self.parallel = options.get("parallel", False)
        self.on_progress: Optional[Callable] = options.get("on_progress")

    # ─── Field Analysis ────────────────────────────────────────

    def analyze_field(self) -> FieldAnalysis:
        """Analyze the field's matrix properties to drive the parameter search space."""
        n = self.field.get_dimension_count()
        matrix = self.field.similarity_matrix
        analysis = FieldAnalysis(dimensions=n)

        if matrix is None or matrix.size != n * n:
            return analysis

        abs_values = []

        for i in range(n):
            for j in range(i + 1, n):
                v = float(matrix[i, j])
                analysis.total_pairs += 1

                if abs(v) < 0.001:
                    analysis.zero_count += 1
                elif v > 0:
                    analysis.positive_count += 1
                    analysis.positive_sum += v
                else:
                    analysis.negative_count += 1
                    analysis.negative_sum += v

                abs_values.append(abs(v))
                if v < analysis.min_value:
                    analysis.min_value = v
                if v > analysis.max_value:
                    analysis.max_value = v

        non_zero = analysis.positive_count + analysis.negative_count
        analysis.polarity_ratio = analysis.negative_count / non_zero if non_zero > 0 else 0.0
        analysis.sparsity = analysis.zero_count / analysis.total_pairs if analysis.total_pairs > 0 else 1.0
        analysis.spread = analysis.max_value - analysis.min_value

        if abs_values:
            abs_values.sort()
            analysis.mean_abs_value = sum(abs_values) / len(abs_values)
            analysis.median_abs_value = abs_values[len(abs_values) // 2]

        return analysis

    # ─── Probe Config Generation ───────────────────────────────

    def generate_probe_configs(self, analysis: FieldAnalysis) -> list[dict]:
        """Generate parameter configurations to probe based on field analysis."""
        n = analysis.dimensions
        dim_factor = math.sqrt(n)
        base_absorption_range = max(0.8, min(3.0, 1.2 * dim_factor / 2))
        base_gravity_range = max(2.0, min(6.0, 1.5 * dim_factor))

        needs_tension = analysis.polarity_ratio < 0.2
        has_strong_tension = analysis.polarity_ratio > 0.4
        weak_matrix = analysis.mean_abs_value < 0.3
        strong_matrix = analysis.mean_abs_value > 0.6

        configs = []

        # Config 1: Conservative baseline
        configs.append({
            "label": "conservative",
            "engine": {
                "force_scale": 0.05,
                "concept_gravity_strength": 3.0,
                "concept_gravity_range": base_gravity_range,
                "concept_gravity_sharpness": 3,
                "thermal_noise": 0.02,
                "tradeoff_amplifier": 2.5 if needs_tension else 1.0,
            },
            "metabolism": {
                "concept_absorption_range": base_absorption_range,
                "energy_falloff_exponent": 2.0,
                "base_cost_per_dim": 0.005,
            },
        })

        # Config 2: Strong gravity, tight basins
        configs.append({
            "label": "strong-gravity",
            "engine": {
                "force_scale": 0.1,
                "concept_gravity_strength": 5.0,
                "concept_gravity_range": base_gravity_range * 0.8,
                "concept_gravity_sharpness": 5,
                "thermal_noise": 0.03,
                "tradeoff_amplifier": 2.0 if needs_tension else 1.2,
            },
            "metabolism": {
                "concept_absorption_range": base_absorption_range * 0.7,
                "energy_falloff_exponent": 3.0,
                "base_cost_per_dim": 0.008,
            },
        })

        # Config 3: High thermal noise — break out of local minima
        configs.append({
            "label": "high-noise",
            "engine": {
                "force_scale": 0.15,
                "concept_gravity_strength": 3.5,
                "concept_gravity_range": base_gravity_range,
                "concept_gravity_sharpness": 3,
                "thermal_noise": 0.06,
                "tradeoff_amplifier": 2.0 if needs_tension else 1.5,
            },
            "metabolism": {
                "concept_absorption_range": base_absorption_range,
                "energy_falloff_exponent": 2.5,
                "base_cost_per_dim": 0.006,
            },
        })

        # Config 4: Strong trade-off amplification — force separation
        configs.append({
            "label": "amplified-tradeoffs",
            "engine": {
                "force_scale": 0.2,
                "concept_gravity_strength": 2.5,
                "concept_gravity_range": base_gravity_range,
                "concept_gravity_sharpness": 4,
                "thermal_noise": 0.04,
                "tradeoff_amplifier": 1.5 if has_strong_tension else 3.0,
            },
            "metabolism": {
                "concept_absorption_range": base_absorption_range * 0.8,
                "energy_falloff_exponent": 2.5,
                "base_cost_per_dim": 0.007,
            },
        })

        # Config 5: Wide absorption, gentle forces — let cells explore
        configs.append({
            "label": "wide-exploration",
            "engine": {
                "force_scale": 0.08,
                "concept_gravity_strength": 2.0,
                "concept_gravity_range": base_gravity_range * 1.3,
                "concept_gravity_sharpness": 2,
                "thermal_noise": 0.05,
                "tradeoff_amplifier": 2.5 if needs_tension else 1.0,
            },
            "metabolism": {
                "concept_absorption_range": base_absorption_range * 1.5,
                "energy_falloff_exponent": 1.8,
                "base_cost_per_dim": 0.004,
            },
        })

        # Config 6: Sharp basins, high cost — survival pressure
        configs.append({
            "label": "survival-pressure",
            "engine": {
                "force_scale": 0.12,
                "concept_gravity_strength": 4.0,
                "concept_gravity_range": base_gravity_range * 0.7,
                "concept_gravity_sharpness": 6,
                "thermal_noise": 0.03,
                "tradeoff_amplifier": 2.0 if needs_tension else 1.3,
            },
            "metabolism": {
                "concept_absorption_range": base_absorption_range * 0.6,
                "energy_falloff_exponent": 3.5,
                "base_cost_per_dim": 0.01,
                "movement_cost_factor": 0.005,
            },
        })

        # Config 7: Balanced — dimension-adapted middle ground
        configs.append({
            "label": "balanced",
            "engine": {
                "force_scale": 0.15 if weak_matrix else 0.1,
                "concept_gravity_strength": 2.5 if strong_matrix else 4.0,
                "concept_gravity_range": base_gravity_range,
                "concept_gravity_sharpness": 4,
                "thermal_noise": 0.04,
                "tradeoff_amplifier": 2.5 if needs_tension else (1.0 if has_strong_tension else 1.5),
            },
            "metabolism": {
                "concept_absorption_range": base_absorption_range * 0.9,
                "energy_falloff_exponent": 2.5,
                "base_cost_per_dim": 0.006,
                "movement_cost_factor": 0.003,
            },
        })

        # Config 8: Matrix-reactive — directly computed from field analysis
        configs.append({
            "label": "matrix-reactive",
            "engine": {
                "force_scale": max(0.05, min(0.3, analysis.spread * 0.3)),
                "concept_gravity_strength": max(1.5, min(6.0, 3.0 / (analysis.mean_abs_value + 0.1))),
                "concept_gravity_range": base_gravity_range,
                "concept_gravity_sharpness": max(2, min(6, round(n / 2))),
                "thermal_noise": max(0.02, min(0.07, analysis.spread * 0.05)),
                "tradeoff_amplifier": max(1.0, min(4.0, 1.0 / (analysis.polarity_ratio + 0.1))),
            },
            "metabolism": {
                "concept_absorption_range": base_absorption_range,
                "energy_falloff_exponent": max(1.5, min(4.0, 1.5 + n * 0.15)),
                "base_cost_per_dim": max(0.003, min(0.012, 0.03 / n)),
            },
        })

        # Config 9: High-dimensional sparse — aggressive forces for D >= 7
        if n >= 7:
            configs.append({
                "label": "high-dim-sparse",
                "engine": {
                    "force_scale": 0.3,
                    "concept_gravity_strength": 6.0,
                    "concept_gravity_range": base_gravity_range * 1.5,
                    "concept_gravity_sharpness": max(3, n // 2),
                    "thermal_noise": 0.08,
                    "tradeoff_amplifier": 4.0,
                },
                "metabolism": {
                    "concept_absorption_range": base_absorption_range * 1.3,
                    "energy_falloff_exponent": 2.0,
                    "base_cost_per_dim": 0.003,
                },
            })

            # Config 10: Ultra-exploration for sparse high-D
            configs.append({
                "label": "ultra-exploration",
                "engine": {
                    "force_scale": 0.25,
                    "concept_gravity_strength": 4.5,
                    "concept_gravity_range": base_gravity_range * 2.0,
                    "concept_gravity_sharpness": 3,
                    "thermal_noise": 0.1,
                    "tradeoff_amplifier": 3.5,
                },
                "metabolism": {
                    "concept_absorption_range": base_absorption_range * 2.0,
                    "energy_falloff_exponent": 1.5,
                    "base_cost_per_dim": 0.002,
                },
            })

        return configs

    # ─── Probe Execution ───────────────────────────────────────

    def _timed_probe(self, config_overrides: dict) -> "ProbeResult":
        """Run a probe and return a ProbeResult with timing."""
        t0 = time.time()
        score = self.run_probe(config_overrides)
        elapsed = (time.time() - t0) * 1000
        return ProbeResult(
            label=config_overrides["label"],
            config=config_overrides,
            score=score,
            elapsed_ms=elapsed,
        )

    def run_probe(self, config_overrides: dict) -> ProbeScore:
        """Run a single probe colony and score the result."""
        config = AfsSimulationConfig.from_dict({
            "engine": {
                "initial_cell_count": self.probe_cells,
                "max_population": self.probe_cells * 3,
                "ticks_per_generation": 100,
                **config_overrides.get("engine", {}),
            },
            "metabolism": config_overrides.get("metabolism", {}),
        })

        # Probes use CPU — GPU init overhead exceeds benefit at 48 cells
        engine = AfsEngine(self.field, config, use_gpu=False)
        engine.initialize()

        n = self.field.get_dimension_count()
        dt = 1000.0 / 60.0
        total_ticks = self.probe_generations * 100
        engine.total_ticks = total_ticks
        unstable = False

        for gen in range(self.probe_generations):
            engine.run_generation(ticks=100, dt=dt)

            # Stability check every generation
            if gen > 0:
                cells = list(engine.cells.values())
                if len(cells) == 0:
                    unstable = True
                    break
                avg_e = sum(c.energy for c in cells) / len(cells)
                if math.isnan(avg_e) or math.isinf(avg_e):
                    unstable = True
                    break
                sample = cells[0]
                if np.any(np.isnan(sample.position)) or np.any(np.isinf(sample.position)):
                    unstable = True
                    break

        score = self._score_colony(engine, n)
        if unstable:
            score.total = 0.0
            score.unstable = True
        return score

    # ─── Colony Scoring ────────────────────────────────────────

    def _score_colony(self, engine: AfsEngine, dim_count: int) -> ProbeScore:
        """Score a colony on differentiation quality."""
        cells = list(engine.cells.values())
        if len(cells) < 4:
            return ProbeScore(population=len(cells))

        # NaN check
        avg_e = sum(c.energy for c in cells) / len(cells)
        if math.isnan(avg_e) or any(np.any(np.isnan(c.position)) for c in cells):
            return ProbeScore(population=len(cells), unstable=True)

        n = dim_count
        positions = np.array([c.position for c in cells])

        # 1. Cluster count
        clusters = detect_clusters(positions)
        cluster_count = len(clusters)

        # 2. Cluster separation — mean pairwise centroid distance
        separation = 0.0
        if len(clusters) >= 2:
            pair_count = 0
            for i in range(len(clusters)):
                for j in range(i + 1, len(clusters)):
                    separation += float(np.linalg.norm(clusters[i].centroid - clusters[j].centroid))
                    pair_count += 1
            separation = separation / pair_count if pair_count > 0 else 0.0

        # 3. Cell spread — RMS variance across dimensions
        variance = np.var(positions, axis=0)
        spread = float(np.sqrt(np.mean(variance)))

        # 4. Energy health — sweet spot at 0.3-0.7 ratio
        max_energy = engine.config.cell.max_energy
        energy_ratio = avg_e / max_energy
        energy_health = 1.0 - 2.0 * abs(energy_ratio - 0.5)

        # 5. Population growth
        pop_ratio = len(cells) / self.probe_cells
        growth_score = min(1.0, pop_ratio / 2.0)

        # 6. Concept pair distance variance
        concepts = list(engine.concepts.values())
        pair_dist_variance = 0.0
        if len(concepts) >= 2:
            concept_positions = np.array([c.position for c in concepts])
            dists = []
            for i in range(len(concepts)):
                for j in range(i + 1, len(concepts)):
                    dists.append(float(np.linalg.norm(concept_positions[i] - concept_positions[j])))
            if dists:
                mean_dist = sum(dists) / len(dists)
                pair_dist_variance = sum((d - mean_dist) ** 2 for d in dists) / len(dists)

        # Composite score (weighted — same as JS)
        total = (
            cluster_count * 25
            + separation * 20
            + spread * 30
            + energy_health * 10
            + growth_score * 10
            + pair_dist_variance * 15
        )

        return ProbeScore(
            total=round(total, 2),
            cluster_count=cluster_count,
            separation=round(separation, 4),
            spread=round(spread, 4),
            energy_health=round(energy_health, 3),
            growth_score=round(growth_score, 3),
            pair_dist_variance=round(pair_dist_variance, 4),
            population=len(cells),
            avg_energy=round(avg_e, 1),
        )

    # ─── Parameter Space (for adaptive bisection) ─────────────

    def _param_space(self, analysis: FieldAnalysis) -> dict:
        """Define the searchable parameter space as {key: (min, max)} ranges.

        Values are chosen to bracket the hand-crafted probe configs — bisection
        searches within this space rather than sampling fixed presets.
        """
        n = analysis.dimensions
        dim_factor = math.sqrt(n)
        return {
            "force_scale":              (0.03, 0.35),
            "concept_gravity_strength": (1.5,  8.0),
            "concept_gravity_range":    (max(1.5, dim_factor * 0.8),
                                         min(8.0, dim_factor * 2.5)),
            "concept_gravity_sharpness":(2.0,  8.0),
            "thermal_noise":            (0.01, 0.12),
            "tradeoff_amplifier":       (1.0 if analysis.polarity_ratio > 0.4 else 2.0,
                                         5.0),
            "concept_absorption_range": (max(0.6, dim_factor * 0.3),
                                         min(4.0, dim_factor * 1.0)),
            "energy_falloff_exponent":  (1.5, 4.0),
            "base_cost_per_dim":        (0.002, 0.012),
        }

    def _sample_configs(self, space: dict, analysis: FieldAnalysis,
                        centers: list[dict] | None, width: float,
                        n_configs: int) -> list[dict]:
        """Sample n_configs within the current search window.

        Round 1 (centers=None): spread configs evenly across the full space.
        Round 2+: sample around the winning centers, within ±width of each parameter.

        Width starts at 1.0 (full range) and halves each round — binary search.
        """
        engine_keys = [
            "force_scale", "concept_gravity_strength", "concept_gravity_range",
            "concept_gravity_sharpness", "thermal_noise", "tradeoff_amplifier",
        ]
        meta_keys = [
            "concept_absorption_range", "energy_falloff_exponent", "base_cost_per_dim",
        ]

        configs = []

        if centers is None:
            # Wide scan: place configs at evenly spaced positions across each key range
            for i in range(n_configs):
                t = i / max(1, n_configs - 1)   # 0.0 → 1.0 across configs
                engine = {}
                for k in engine_keys:
                    lo, hi = space[k]
                    # Stagger: even configs lean low, odd configs lean high
                    if k in ("tradeoff_amplifier", "concept_gravity_strength"):
                        val = lo + (hi - lo) * (0.2 + 0.6 * ((i * 0.618) % 1.0))
                    else:
                        val = lo + (hi - lo) * t
                    engine[k] = round(val, 4)

                meta = {}
                for k in meta_keys:
                    lo, hi = space[k]
                    meta[k] = round(lo + (hi - lo) * (0.3 + 0.4 * t), 4)

                configs.append({
                    "label": f"wide-{i+1}",
                    "engine": engine,
                    "metabolism": meta,
                })
        else:
            # Narrow: interpolate between top winners, then sample their neighborhood
            # First: midpoint between the top two winners (the binary search midpoint)
            if len(centers) >= 2:
                mid_engine = {}
                mid_meta = {}
                for k in engine_keys:
                    lo, hi = space[k]
                    a = centers[0].get("engine", {}).get(k, (lo + hi) / 2)
                    b = centers[1].get("engine", {}).get(k, (lo + hi) / 2)
                    # Geometric midpoint (better for log-scale params like gravity)
                    if a > 0 and b > 0:
                        mid_val = math.sqrt(a * b)
                    else:
                        mid_val = (a + b) / 2
                    mid_engine[k] = round(float(np.clip(mid_val, lo, hi)), 4)
                for k in meta_keys:
                    lo, hi = space[k]
                    a = centers[0].get("metabolism", {}).get(k, (lo + hi) / 2)
                    b = centers[1].get("metabolism", {}).get(k, (lo + hi) / 2)
                    mid_meta[k] = round(float(np.clip((a + b) / 2, lo, hi)), 4)
                configs.append({"label": "bisect-mid", "engine": mid_engine, "metabolism": mid_meta})

            # Neighborhood: small perturbations around the top winner
            best = centers[0]
            for i in range(n_configs - len(configs)):
                perturb_engine = {}
                perturb_meta = {}
                rng_offset = (i + 1) * 0.618  # golden ratio spread
                for k in engine_keys:
                    lo, hi = space[k]
                    base = best.get("engine", {}).get(k, (lo + hi) / 2)
                    delta = (hi - lo) * width * (0.3 + 0.2 * ((rng_offset * (hash(k) % 7 + 1)) % 1.0))
                    sign = 1 if i % 2 == 0 else -1
                    val = float(np.clip(base + sign * delta, lo, hi))
                    perturb_engine[k] = round(val, 4)
                for k in meta_keys:
                    lo, hi = space[k]
                    base = best.get("metabolism", {}).get(k, (lo + hi) / 2)
                    delta = (hi - lo) * width * 0.2
                    sign = 1 if i % 2 == 0 else -1
                    val = float(np.clip(base + sign * delta, lo, hi))
                    perturb_meta[k] = round(val, 4)
                configs.append({
                    "label": f"narrow-{i+1}",
                    "engine": perturb_engine,
                    "metabolism": perturb_meta,
                })

        return configs[:n_configs]

    # ─── Main Calibration ─────────────────────────────────────

    def calibrate(self, verbose: bool = True) -> AfsSimulationConfig:
        """
        Adaptive bisection calibration — binary search over the parameter space.

        Stage 1 (wide): 4 configs spread across the full parameter range × probe_gens
        Stage 2 (narrow): midpoint between top-2 + 3 neighborhood probes × probe_gens
        Winner: best config from stage 2 (or stage 1 if stage 2 doesn't improve)

        Total probes: ~7 (vs 10 in grid search), covering a larger adaptive space.
        Speedup: ~30% fewer gen-units, finds configs grid search never samples.

        From the stability test: the scoring signal only emerges at 25-30 gens.
        We keep probe_gens fixed and halve the number of configs instead.
        """
        analysis = self.analyze_field()
        n = analysis.dimensions
        space = self._param_space(analysis)

        if verbose:
            print("[Homeostasis] Analyzing field structure...")
            print(f"  dimensions: {n}")
            print(f"  matrix pairs: {analysis.total_pairs} "
                  f"(pos={analysis.positive_count}, neg={analysis.negative_count}, "
                  f"zero={analysis.zero_count})")
            polarity_label = (
                "NEEDS TENSION" if analysis.polarity_ratio < 0.2
                else ("STRONG TENSION" if analysis.polarity_ratio > 0.4 else "moderate"))
            print(f"  polarity ratio: {analysis.polarity_ratio:.3f} ({polarity_label})")
            print(f"  value range: [{analysis.min_value:.3f}, {analysis.max_value:.3f}], "
                  f"spread={analysis.spread:.3f}")
            print()

        all_results: list[ProbeResult] = []
        t_all = time.time()

        # ── Stage 1: Wide scan ────────────────────────────────
        stage1_configs = self._sample_configs(space, analysis, None, 1.0, n_configs=4)

        if verbose:
            print(f"[Homeostasis] Stage 1 — wide scan "
                  f"({len(stage1_configs)} configs × {self.probe_generations} gens)...")

        stage1_results = []
        for i, pc in enumerate(stage1_configs):
            result = self._timed_probe(pc)
            stage1_results.append(result)
            all_results.append(result)
            if verbose:
                s = result.score
                tag = " ⚠ UNSTABLE" if s.unstable else ""
                print(f"  [{i+1}/{len(stage1_configs)}] {result.label:<16} "
                      f"score={s.total:>6.1f}  spread={s.spread:.3f}  "
                      f"clusters={s.cluster_count}  ({result.elapsed_ms:.0f}ms){tag}")

        stage1_results.sort(key=lambda r: r.score.total, reverse=True)
        top_two_configs = [r.config for r in stage1_results[:2]]

        if verbose:
            print(f"  → Stage 1 leaders: "
                  f"'{stage1_results[0].label}' ({stage1_results[0].score.total:.1f}), "
                  f"'{stage1_results[1].label}' ({stage1_results[1].score.total:.1f})")
            print()

        # ── Stage 2: Narrow — bisect toward the winners ───────
        stage2_configs = self._sample_configs(
            space, analysis, top_two_configs, width=0.5, n_configs=3)

        if verbose:
            print(f"[Homeostasis] Stage 2 — bisecting toward winners "
                  f"({len(stage2_configs)} configs × {self.probe_generations} gens)...")

        stage2_results = []
        for i, pc in enumerate(stage2_configs):
            result = self._timed_probe(pc)
            stage2_results.append(result)
            all_results.append(result)
            if verbose:
                s = result.score
                tag = " ⚠ UNSTABLE" if s.unstable else ""
                print(f"  [{i+1}/{len(stage2_configs)}] {result.label:<16} "
                      f"score={s.total:>6.1f}  spread={s.spread:.3f}  "
                      f"clusters={s.cluster_count}  ({result.elapsed_ms:.0f}ms){tag}")

        # Pick the best across both stages
        all_results.sort(key=lambda r: r.score.total, reverse=True)
        best = all_results[0]

        t_total = (time.time() - t_all) * 1000
        if verbose:
            stage = "stage-2" if best.label.startswith(("bisect", "narrow")) else "stage-1"
            print()
            print(f"[Homeostasis] Equilibrium: '{best.label}' ({stage}) "
                  f"score={best.score.total:.1f} in {t_total:.0f}ms "
                  f"({len(all_results)} probes total)")

        # Build production config
        prod_config = AfsSimulationConfig.from_dict({
            "engine": {
                "initial_cell_count": self.target_cells,
                "max_population": self.target_max_pop,
                "ticks_per_generation": 100,
                **best.config.get("engine", {}),
            },
            "metabolism": best.config.get("metabolism", {}),
        })

        prod_config._calibration = {
            "label": best.label,
            "score": {
                "total": best.score.total,
                "cluster_count": best.score.cluster_count,
                "separation": best.score.separation,
                "spread": best.score.spread,
            },
            "field_analysis": {
                "dimensions": analysis.dimensions,
                "polarity_ratio": analysis.polarity_ratio,
                "mean_abs_value": analysis.mean_abs_value,
                "spread": analysis.spread,
            },
            "probe_results": [
                {"label": r.label, "score": r.score.total, "clusters": r.score.cluster_count}
                for r in all_results
            ],
            "method": "adaptive-bisection",
            "timestamp": time.time(),
        }

        return prod_config

    def calibrate_grid(self, verbose: bool = True) -> AfsSimulationConfig:
        """
        Run calibration — returns the best AfsSimulationConfig for this field.

        Probes 8 configurations, scores each on differentiation quality,
        returns production config using the winning probe's parameters.
        """
        analysis = self.analyze_field()
        n = analysis.dimensions

        if verbose:
            print("[Homeostasis] Analyzing field structure...")
            print(f"  dimensions: {n}")
            print(f"  matrix pairs: {analysis.total_pairs} (pos={analysis.positive_count}, neg={analysis.negative_count}, zero={analysis.zero_count})")
            polarity_label = "NEEDS TENSION" if analysis.polarity_ratio < 0.2 else ("STRONG TENSION" if analysis.polarity_ratio > 0.4 else "moderate")
            print(f"  polarity ratio: {analysis.polarity_ratio:.3f} ({polarity_label})")
            print(f"  value range: [{analysis.min_value:.3f}, {analysis.max_value:.3f}], spread={analysis.spread:.3f}")
            print(f"  mean|v|: {analysis.mean_abs_value:.3f}, median|v|: {analysis.median_abs_value:.3f}")
            print(f"  sparsity: {analysis.sparsity:.3f}")
            print()

        probe_configs = self.generate_probe_configs(analysis)
        results: list[ProbeResult] = []
        n_probes = len(probe_configs)

        if verbose:
            mode = "parallel" if self.parallel else "sequential"
            print(f"[Homeostasis] Probing {n_probes} configs ({self.probe_generations} gen × {self.probe_cells} cells, {mode})...")
            print()

        t_all = time.time()

        if self.parallel:
            # All probes are independent — run them in parallel threads.
            # NumPy releases the GIL during array ops, so threads overlap on multi-core.
            max_workers = min(n_probes, 6)  # cap at 6 to avoid memory pressure
            futures = {}
            with ThreadPoolExecutor(max_workers=max_workers) as pool:
                for pc in probe_configs:
                    futures[pool.submit(self._timed_probe, pc)] = pc["label"]

                completed = 0
                for future in as_completed(futures):
                    label = futures[future]
                    result = future.result()
                    results.append(result)
                    completed += 1

                    if verbose:
                        s = result.score
                        tag = " ⚠ UNSTABLE" if s.unstable else ""
                        print(
                            f"  [{completed}/{n_probes}] {result.label:<22} "
                            f"score={s.total:>6} "
                            f"clusters={s.cluster_count} "
                            f"sep={s.separation:.3f} "
                            f"spread={s.spread:.3f} "
                            f"pop={s.population} "
                            f"energy={s.avg_energy:.0f} "
                            f"({result.elapsed_ms:.0f}ms){tag}"
                        )

                    if self.on_progress:
                        self.on_progress(completed, n_probes, result.label, result.score)
        else:
            # Sequential fallback (for debugging or single-core environments)
            for i, pc in enumerate(probe_configs):
                result = self._timed_probe(pc)
                results.append(result)

                if verbose:
                    s = result.score
                    tag = " ⚠ UNSTABLE" if s.unstable else ""
                    print(
                        f"  [{i+1}/{n_probes}] {result.label:<22} "
                        f"score={s.total:>6} "
                        f"clusters={s.cluster_count} "
                        f"sep={s.separation:.3f} "
                        f"spread={s.spread:.3f} "
                        f"pop={s.population} "
                        f"energy={s.avg_energy:.0f} "
                        f"({result.elapsed_ms:.0f}ms){tag}"
                    )

                if self.on_progress:
                    self.on_progress(i + 1, n_probes, result.label, result.score)

        t_total = (time.time() - t_all) * 1000
        if verbose:
            print(f"\n[Homeostasis] All probes done in {t_total:.0f}ms")

        # Sort by total score
        results.sort(key=lambda r: r.score.total, reverse=True)
        best = results[0]

        if verbose:
            print()
            print(f'[Homeostasis] Equilibrium found: "{best.label}" (score={best.score.total})')
            print(f"  clusters={best.score.cluster_count}, separation={best.score.separation:.3f}, spread={best.score.spread:.3f}")

        # Build production config with winning parameters but production cell counts
        prod_config = AfsSimulationConfig.from_dict({
            "engine": {
                "initial_cell_count": self.target_cells,
                "max_population": self.target_max_pop,
                "ticks_per_generation": 100,
                **best.config.get("engine", {}),
            },
            "metabolism": best.config.get("metabolism", {}),
        })

        # Attach calibration metadata
        prod_config._calibration = {
            "label": best.label,
            "score": {
                "total": best.score.total,
                "cluster_count": best.score.cluster_count,
                "separation": best.score.separation,
                "spread": best.score.spread,
            },
            "field_analysis": {
                "dimensions": analysis.dimensions,
                "polarity_ratio": analysis.polarity_ratio,
                "mean_abs_value": analysis.mean_abs_value,
                "spread": analysis.spread,
            },
            "probe_results": [
                {"label": r.label, "score": r.score.total, "clusters": r.score.cluster_count}
                for r in results
            ],
            "timestamp": time.time(),
        }

        return prod_config
