"""
AFS MCP Server — Exposes colony-based exploration as MCP tools.

Four tools:
  afs-decide:  High-level decision tool (simplified input → interpreted strategies)
  afs-explore: Full pipeline (world model → calibrate → run → clusters)
  afs-compile: World model → calibrated FieldDefinition (no colony run)
  afs-run:     Pre-compiled field + config → run colony → clusters

The LLM client IS the domain analyzer. This server is pure simulation —
no API keys needed, works with Claude/GPT/Cursor/any MCP client.
"""

import json
import time
import numpy as np
from concurrent.futures import ThreadPoolExecutor
from mcp.server.fastmcp import FastMCP, Context
from mcp.types import CallToolResult, TextContent

from ..core import FieldDefinition, AfsEngine, AfsSimulationConfig
from ..compiler import FieldCompiler, WorldModelTranslator

# Sentinel to prevent FieldCompiler from creating a DomainAnalyzer (needs OpenAI key).
# MCP server is pure simulation — the LLM client IS the domain analyzer.
_DUMMY_ANALYZER = type("NoOpAnalyzer", (), {})()

from ..calibrator import HomeostasisCalibrator
from ..analysis import detect_clusters, compute_concept_weights
from ..core.output_wm import output_to_world_model
from ..core.inverse_colony import (
    build_inverse_world_model,
    extract_modulation_delta,
    compute_zero_crossing,
)
from ..store.ablation_store import store_ablation
from ..store.share_store import store_share as _store_share
from ..store.world_model_store import store_world_model as _store_world_model

# GPU Colony Sound Block — replaces AfsEngine for colony execution
try:
    from ..gpu.sb_colony_block_kernel import SBColonyBlockKernel, gpu_available as _sb_gpu_available
    _HAS_SB_GPU = _sb_gpu_available()
except Exception:
    _HAS_SB_GPU = False

if _HAS_SB_GPU:
    print("[2ndOpinion] Colony Sound Block GPU available — using SBColonyBlockKernel")
else:
    print("[2ndOpinion] Colony Sound Block GPU not available — using AfsEngine fallback")


class _Timer:
    """Lightweight phase timer — logs to console and accumulates phase breakdown."""
    def __init__(self, label: str, log: bool = True):
        self.label = label
        self.log = log
        self._t0 = None

    def __enter__(self):
        self._t0 = time.time()
        return self

    def __exit__(self, *_):
        self.elapsed_ms = (time.time() - self._t0) * 1000
        if self.log:
            print(f"[2ndOpinion] {self.label}: {self.elapsed_ms:.0f}ms")

# GPU-aware defaults — detect once at import time
try:
    from ..gpu import gpu_available
    _HAS_GPU = gpu_available()
except Exception:
    _HAS_GPU = False

# Colony scaling: GPU can handle larger populations at no extra cost
_GPU_DEFAULTS = {
    "initial_cell_count": 256,
    "max_population": 500,
    "generations_decide": 150,
    "generations_explore": 40,
    "generations_run": 40,
}
_CPU_DEFAULTS = {
    "initial_cell_count": 128,
    "max_population": 300,
    "generations_decide": 100,
    "generations_explore": 20,
    "generations_run": 20,
}

# P9+ Adaptive depth: quality-gated grandchild recursion
GRANDCHILD_THRESHOLD = 0.4   # child_score must exceed 40% of parent_score to recurse
MAX_CHILD_DEPTH = 2           # hard cap: 0=parent colony, 1=child sims, 2=grandchild sims
_DEFAULTS = _GPU_DEFAULTS if _HAS_GPU else _CPU_DEFAULTS

if _HAS_GPU:
    print("[2ndOpinion] GPU detected — colony scaled to {} cells, {} max pop".format(
        _DEFAULTS["initial_cell_count"], _DEFAULTS["max_population"]))

# Session cache — persists across tool calls within one conversation.
# The MCP server process lives for the conversation duration.
_session = {
    "engine": None,          # AfsEngine instance (live colony)
    "field": None,           # FieldDefinition
    "config": None,          # AfsSimulationConfig
    "world_model": None,     # Full world model dict
    "options": None,         # Original options list
    "factor_names": None,    # Factor name list
    "influence_matrix": None,# NxN matrix
    "question": None,        # Original question
    "strategies": None,      # Last interpreted strategies
}

mcp = FastMCP(
    "2ndOpinion",
    instructions=(
        "2ndOpinion — a second opinion for complex decisions.\n\n"

        "BEFORE CALLING ANY TOOL, tell the user:\n"
        "'Second Opinion deeply considers your request by running a simulation that explores "
        "all the possibilities and scenarios. This takes a moment — the journey reveals the "
        "deeper truth.'\n"
        "Say this ONCE per conversation, before the first afs_decide call. Do not repeat it "
        "on follow-ups.\n\n"

        "PRESENTING RESULTS — THIS IS THE MOST IMPORTANT SECTION:\n"
        "Do NOT explain how the tool works, what colonies are, or how cells behave. "
        "The user does not care about the simulation mechanics. They care about their decision.\n\n"
        "GOOD response: 'The strongest strategy is X because [factor] and [factor] reinforce "
        "each other. But there's a surprising alternative: Y trades off [thing] for [thing], "
        "which you might not have considered. Here's what I'd recommend and why.'\n\n"
        "BAD response: 'The colony simulation ran 150 generations with 256 cells. 195 cells "
        "converged on cluster 1 near the Prophet 6 attractor with concept weights...'\n\n"
        "Interpret the strategies as ACTIONABLE ADVICE:\n"
        "- Lead with your recommendation, informed by both your reasoning AND the colony findings\n"
        "- Explain tradeoffs in the user's language, not simulation language\n"
        "- Highlight surprises — strategies the user didn't ask about are the highest value\n"
        "- Use real units and concrete numbers from the results, not normalized values\n"
        "- If the colony agrees with your instinct, say so briefly — that's confirmation\n"
        "- If it disagrees, explain WHY the causal relationships produced that outcome\n"
        "- Keep it concise. A clear recommendation with 2-3 key tradeoffs beats a wall of analysis\n\n"

        "Never say 'the colony found' or 'cells converged on' — say 'the analysis suggests' "
        "or 'the tradeoff exploration revealed' or just state the finding directly.\n\n"

        "SHARE URL — ALWAYS PRESENT THIS:\n"
        "Every afs_decide result includes a share_url field. ALWAYS end your response with:\n"
        "'📊 [View full report](share_url)' — use the actual URL from the result.\n"
        "If share_url is null, skip it silently. Never tell the user there is no shareable URL.\n\n"

        "WORKFLOW:\n"
        "1. User describes a decision or comparison\n"
        "2. Identify: question, options, factors (8-15), causal relationships\n"
        "3. Call afs_decide\n"
        "4. CHECK for warnings — if WEAK_SEPARATION or ALL_BUNCHED, revise factors and call again\n"
        "5. Interpret results as actionable advice (see above)\n"
        "6. End with the share_url link\n\n"

        "FOLLOW-UPS:\n"
        "After afs_decide, if the user asks a related question ('what about X?', 'what if...'), "
        "use afs_followup. It reuses the existing simulation state (~2s vs ~10s). "
        "Only call afs_decide again if the topic changes completely.\n\n"

        "WHEN TO USE:\n"
        "- User says 'second opinion', '2ndop', '2o', 'use afs', 'explore this' → afs_decide\n"
        "- User compares 3+ options or asks about tradeoffs → afs_decide\n"
        "- User asks follow-up on previous result → afs_followup\n"
        "- Simple factual question or single-factor choice → skip, answer directly\n\n"

        "BUILDING GOOD WORLD MODELS:\n"
        "- 8-15 factors, not 3-5. Include hidden factors the user didn't mention\n"
        "- Encode causal CHAINS (A→B→C), not just direct effects\n"
        "- Vary relationship strengths (0.3 to 0.95), don't default everything to 0.7\n"
        "- Include factors where options genuinely DIFFER — if all options score the same "
        "on a factor, it won't help differentiate\n"
        "- Score ALL options on ALL factors when possible — unscored factors default to 50 "
        "and reduce differentiation\n\n"

        "SOUND BLOCK CIRCUIT OUTPUT:\n"
        "Every afs_decide result now includes a 'cir' (circuit) field containing a Sound Block "
        "circuit definition generated from the colony output. This is an executable circuit:\n"
        "- 'blocks': array of Sound Block primitives with parameters from strategy concept weights\n"
        "- 'connections': wiring between blocks with coupling strengths\n"
        "- 'substrate_match': which VM substrate cores matched this domain\n"
        "- 'gaps': knowledge gaps detected (where the model is uncertain)\n"
        "- 'follow_ups': suggested questions to fill knowledge gaps\n\n"
        "When asked to BUILD something (synthesizer, visualizer, application):\n"
        "- Present the circuit definition as the implementation — it IS the executable output\n"
        "- Each block is a tested Sound Block primitive — zero possibility for bugs\n"
        "- Connections define the data flow — no code generation needed\n"
        "- If 'gaps' exist, ask the follow_up questions to improve the circuit\n"
        "- The circuit can be loaded directly by the Sound Block VM with createCircuit()\n\n"
        "If the user asks to 'build in Sound Blocks', return the circuit definition directly. "
        "Do NOT generate JavaScript or HTML. The circuit IS the implementation."
    ),
)


def _serialize_results(engine: AfsEngine, generations: int) -> dict:
    """Run colony and return serializable cluster results."""
    cell_positions = engine.get_cell_positions()
    concept_positions = engine.get_concept_positions()
    concept_names = engine.get_concept_names()
    n_dims = engine.n_dims

    clusters_raw = detect_clusters(cell_positions)

    clusters = []
    for cl in clusters_raw:
        weights = compute_concept_weights(
            cl, concept_positions, concept_names, n_dims
        )
        clusters.append({
            "centroid": cl.centroid.tolist(),
            "cell_count": cl.cell_count,
            "concept_weights": weights,
        })

    return {
        "generations": generations,
        "tick_count": engine.tick_count,
        "final_population": len(engine.cells),
        "stats": engine.stats,
        "clusters": clusters,
        "concept_names": concept_names,
        "concept_positions": concept_positions.tolist(),
    }


def _run_generations(engine: AfsEngine, generations: int, on_progress=None):
    """Run N generations on an already-initialized engine (no init call)."""
    ticks_per_gen = 100
    total_ticks = generations * ticks_per_gen
    engine.total_ticks = total_ticks  # enables gravity ramping + settling
    concept_names = engine.get_concept_names()

    # Progress snapshots at 10%, 25%, 50%, 75%, 90%
    checkpoints = {int(generations * p): label for p, label in [
        (0.10, "early"), (0.25, "forming"), (0.50, "midpoint"),
        (0.75, "converging"), (0.90, "settling"),
    ]}

    for gen in range(1, generations + 1):
        engine.run_generation(ticks=ticks_per_gen)

        if gen in checkpoints and on_progress:
            phase = checkpoints[gen]
            pop = len(engine.cells)
            positions = engine.get_cell_positions()
            concept_pos = engine.get_concept_positions()

            clusters = detect_clusters(positions)
            n_clusters = len(clusters)

            if n_clusters == 0:
                cluster_note = "cells still exploring, no clear answers yet"
            elif n_clusters == 1:
                biggest = clusters[0]
                if concept_names:
                    weights = compute_concept_weights(
                        biggest, concept_pos, concept_names, engine.n_dims)
                    top = max(weights, key=weights.get) if weights else "?"
                    cluster_note = "1 outcome emerging near {} ({} cells)".format(
                        top, biggest.cell_count)
                else:
                    cluster_note = "1 outcome emerging ({} cells)".format(biggest.cell_count)
            else:
                notes = []
                for cl in clusters[:3]:
                    if concept_names:
                        weights = compute_concept_weights(
                            cl, concept_pos, concept_names, engine.n_dims)
                        top = max(weights, key=weights.get) if weights else "?"
                        notes.append("{} ({})".format(top, cl.cell_count))
                    else:
                        notes.append("{} cells".format(cl.cell_count))
                cluster_note = "{} outcomes: {}".format(n_clusters, ", ".join(notes))

            msg = "Gen {}/{} — {} cells, {} [{}]".format(
                gen, generations, pop, cluster_note, phase)
            on_progress(gen, generations, msg)


def _run_colony(engine: AfsEngine, generations: int, on_progress=None) -> dict:
    """Initialize and run the colony for N generations, return results."""
    engine.initialize()
    _run_generations(engine, generations, on_progress)
    return _serialize_results(engine, generations)


# ─── Colony Sound Block GPU path ────────────────────────────────────────────
# Cached kernel instance — reused across calls (GPU stays warm)
_sb_kernel_cache = {}


def _run_colony_soundblock(factors, relationships, options, config=None,
                           on_progress=None) -> dict:
    """Run colony through GPU Colony Sound Block kernel.

    Returns strategies in the same format as _interpret_clusters(),
    plus raw kernel result for metadata.

    This replaces: compile → calibrate → AfsEngine → _run_colony → _interpret_clusters
    All in one call, ~200ms on GPU at any dimension count.
    """
    n_dims = len(factors)
    n_attractors = max(len(options), 1)

    cfg = {
        "probe_count": 256,
        "max_passes": 80,
        "convergence_threshold": 0.001,
        "min_passes": 8,
        "convergence_check_interval": 4,
        "fused_passes": 4,
    }
    if config:
        cfg.update(config)

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

    # Get or create kernel (reuse if dims match)
    cache_key = (n_dims, n_attractors, cfg["probe_count"])
    kernel = _sb_kernel_cache.get("kernel")
    if kernel is None or _sb_kernel_cache.get("key") != cache_key:
        kernel = SBColonyBlockKernel(
            num_dims=n_dims,
            max_probes=cfg["probe_count"],
            max_attractors=max(n_attractors, 8),
        )
        _sb_kernel_cache["kernel"] = kernel
        _sb_kernel_cache["key"] = cache_key

    if on_progress:
        on_progress(0, cfg["max_passes"],
                    f"Colony Sound Block: {cfg['probe_count']} probes, {n_dims}D, GPU")

    input_dict = {
        "factors": factors,
        "relationships": relationships,
        "options": options,
    }

    result = kernel.run_full_gpu(input_dict, cfg)

    if on_progress:
        on_progress(result["pass_count"], cfg["max_passes"],
                    f"Colony done: {len(result['strategies'])} strategies, "
                    f"{result['pass_count']} passes, {result['total_ms']:.0f}ms "
                    f"[converged={result['converged']}]")

    return result


def _run_child_sims(
    field, config, strategies: list, factor_names: list, concepts: list,
    child_generations: int = 50,
    depth: int = 0,
    parent_score: float = 100.0,
) -> list:
    """Spawn one child colony per parent strategy, drill into each cluster.

    Each child engine starts tightly seeded at the parent cluster centroid and
    runs for child_generations to find sub-strategies within that region.
    Results are merged back as strategy["sub_strategies"].

    Only drills into strategies with at least _CHILD_MIN_CELLS cells
    (tiny clusters are noise — not worth the compute).

    depth/parent_score control P9+ adaptive recursion: qualifying children
    at depth < MAX_CHILD_DEPTH spawn grandchild sims attached as sub_sub_strategies.
    """
    _CHILD_MIN_CELLS = max(5, int(config.engine.initial_cell_count * 0.06))
    _CHILD_SPREAD = 0.07   # tight initial spawn — about half the cluster radius
    _CHILD_CELLS = max(32, config.engine.initial_cell_count // 4)
    _CHILD_MAX_POP = max(64, config.engine.max_population // 4)

    # Light config: smaller colony, same physics
    child_config = AfsSimulationConfig.from_dict({
        "engine": {
            "initial_cell_count": _CHILD_CELLS,
            "max_population": _CHILD_MAX_POP,
            "spawn_spread": _CHILD_SPREAD,
            "force_scale": config.engine.force_scale,
            "concept_gravity_strength": config.engine.concept_gravity_strength,
            "concept_gravity_range": config.engine.concept_gravity_range,
            "concept_gravity_sharpness": config.engine.concept_gravity_sharpness,
            "thermal_noise": config.engine.thermal_noise * 0.6,   # less noise for focused drill
            "tradeoff_amplifier": config.engine.tradeoff_amplifier,
            "boundary_margin": config.engine.boundary_margin,
            "boundary_strength": config.engine.boundary_strength,
        },
        "metabolism": {
            "concept_absorption_range": config.metabolism.concept_absorption_range,
            "energy_falloff_exponent": config.metabolism.energy_falloff_exponent,
            "base_cost_per_dim": config.metabolism.base_cost_per_dim,
        },
    })

    def _run_one_child(strategy):
        """Run a single child sim — called in parallel threads."""
        if strategy["cell_count"] < _CHILD_MIN_CELLS:
            return {**strategy, "sub_strategies": []}

        centroid_raw = strategy.get("centroid_raw")
        if centroid_raw is None:
            return {**strategy, "sub_strategies": []}

        centroid = np.array(centroid_raw)
        t0 = time.time()

        # Child engines use CPU — small colony, low overhead, and GPU init cost
        # would dominate for 32-64 cells. Parallelism across children compensates.
        child = AfsEngine(field, child_config, use_gpu=False)
        child.initialize(seed_centroid=centroid, seed_spread=_CHILD_SPREAD)
        _run_generations(child, child_generations)
        child_results = _serialize_results(child, child_generations)

        sub_strategies = _interpret_clusters(
            child_results["clusters"], concepts, [], factor_names)
        sub_strategies.sort(key=lambda s: s["cell_count"], reverse=True)

        elapsed = (time.time() - t0) * 1000
        print("[2ndOpinion] child sim '{}': {}ms, {} sub-strategies".format(
            strategy.get("label", "?"), int(elapsed), len(sub_strategies)))

        # P9+ quality gate: recurse into grandchild sims if sub-structure is rich enough
        child_cluster_count = len(child_results["clusters"])
        child_separation = _mean_separation(child_results["clusters"])
        child_score = child_cluster_count * 25 + child_separation * 20

        if (depth < MAX_CHILD_DEPTH
                and child_cluster_count >= 2
                and child_score > GRANDCHILD_THRESHOLD * parent_score):
            sub_sub = _run_child_sims(
                field, child_config, sub_strategies, factor_names, concepts,
                child_generations=child_generations,
                depth=depth + 1,
                parent_score=child_score,
            )
            return {**strategy, "sub_strategies": sub_strategies, "sub_sub_strategies": sub_sub}

        return {**strategy, "sub_strategies": sub_strategies}

    # Run all child sims in parallel — they're independent and each takes 1-5s.
    n_children = sum(1 for s in strategies
                     if s["cell_count"] >= _CHILD_MIN_CELLS
                     and s.get("centroid_raw") is not None)
    t_children = time.time()

    if n_children > 1:
        with ThreadPoolExecutor(max_workers=min(n_children, 4)) as pool:
            enriched = list(pool.map(_run_one_child, strategies))
    else:
        enriched = [_run_one_child(s) for s in strategies]

    print("[2ndOpinion] all child sims: {:.0f}ms ({} ran)".format(
        (time.time() - t_children) * 1000, n_children))

    return enriched


# ─── Ablation helpers ─────────────────────────────────────────────────────────

def _make_zeroed_config(config: AfsSimulationConfig) -> AfsSimulationConfig:
    """Return a config copy with force_scale=0 — influence matrix zeroed."""
    cfg_dict = config.to_dict()
    cfg_dict["engine"]["force_scale"] = 0.0
    return AfsSimulationConfig.from_dict(cfg_dict)


def _mean_separation(clusters: list) -> float:
    """Mean pairwise centroid distance across cluster pairs. Returns 0 for <2 clusters."""
    if len(clusters) < 2:
        return 0.0
    centroids = [np.array(cl["centroid"]) for cl in clusters]
    dists = [
        float(np.linalg.norm(centroids[i] - centroids[j]))
        for i in range(len(centroids))
        for j in range(i + 1, len(centroids))
    ]
    return round(sum(dists) / len(dists), 4)


# ─── Compact MCP response ─────────────��────────────────────────────────────
# Claude Code enforces MAX_MCP_OUTPUT_TOKENS=25000 (default) on MCP tool results.
# The MCP SDK auto-serializes dicts with json.dumps(indent=2), inflating size ~50%.
# Solution: return CallToolResult directly with compact JSON (separators=(',',':'),
# floats rounded to 3 decimals). No data dropped — all 32 strategies preserved.
# Key map for compact transport (full names in stored shares + output_world_model):
#   st=strategies, su=summary, pf=preflight, w=warnings, cal=calibration,
#   im=influence_matrix, owm=output_world_model, aid=ablation_id, ad=ablation_delta,
#   inv=inverse, l=label, n=cell_count, v=values, cw=concept_weights,
#   e=is_emergent, m=is_mixed, c=centroid_raw, s=sub_strategies

_MCP_TOKEN_BUDGET = 25000  # Claude Code default MAX_MCP_OUTPUT_TOKENS
_CHARS_PER_TOKEN = 4       # Claude Code approximation


def _compact_strategy(s: dict) -> dict:
    """Condense a strategy for MCP transport. Zero data loss, ~60% smaller."""
    return {
        "l": s["label"],
        "n": s["cell_count"],
        "v": {k: round(v, 2) for k, v in s["values"].items()},
        "cw": {k: round(v, 3) for k, v in s["concept_weights"].items()},
        "e": s["is_emergent"],
        "m": s["is_mixed"],
        "c": [round(x, 4) for x in s["centroid_raw"]],
        "s": [_compact_strategy(sub) for sub in s.get("sub_strategies", [])],
    }


def _compact_matrix(matrix) -> list | None:
    """Round influence matrix floats for transport."""
    if matrix is None:
        return None
    rows = matrix.tolist() if hasattr(matrix, "tolist") else matrix
    return [[round(x, 4) for x in row] for row in rows]


def _compact_response(result: dict) -> CallToolResult:
    """Return a CallToolResult with compact JSON, bypassing MCP SDK indent=2.

    Preserves ALL data (all strategies, sub_strategies, output_world_model).
    Drops world_model_generated (the LLM's own input — already known to caller).
    Uses short keys + compact separators + rounded floats.
    """
    compact = {
        "question": result["question"],
        "st": [_compact_strategy(s) for s in result["strategies"]],
        "su": result["summary"],
        "share_url": result.get("share_url"),
        "share_id": result.get("share_id"),
        "pf": result.get("preflight"),
        "w": result.get("warnings"),
        "cal": result.get("calibration"),
        "im": _compact_matrix(result.get("influence_matrix")),
        "owm": result.get("output_world_model"),
        "aid": result.get("ablation_id"),
        "ad": result.get("ablation_delta"),
        "inv": result.get("inverse"),
        "cir": result.get("circuit"),  # Sound Block circuit definition
    }
    # Drop None values
    compact = {k: v for k, v in compact.items() if v is not None}
    text = json.dumps(compact, separators=(",", ":"))

    est_tokens = len(text) // _CHARS_PER_TOKEN
    if est_tokens > _MCP_TOKEN_BUDGET:
        print("[2ndOpinion] compact response {} tokens — still over budget, "
              "trimming sub_strategies".format(est_tokens))
        # Progressive trim: drop sub_strategies from smallest strategies first
        for s in sorted(compact["st"], key=lambda x: x["n"]):
            s["s"] = []
            text = json.dumps(compact, separators=(",", ":"))
            if len(text) // _CHARS_PER_TOKEN <= _MCP_TOKEN_BUDGET:
                break

    print("[2ndOpinion] compact response: {} chars, ~{} tokens".format(
        len(text), len(text) // _CHARS_PER_TOKEN))

    return CallToolResult(
        content=[TextContent(type="text", text=text)],
        isError=False,
    )


# ─── Sound Block Circuit Generation ──────────────────────────────────────────
# Converts colony output directly into an executable Sound Block circuit.
# No interpretation needed — the circuit IS the answer.
# Strategies → blocks. Concept weights → parameters. Relationships → connections.

# Substrate cores from MongoDB (loaded once at startup)
_substrate_cores = None

def _load_substrate_cores():
    """Load substrate IP cores from MongoDB for circuit generation."""
    global _substrate_cores
    if _substrate_cores is not None:
        return _substrate_cores
    try:
        from ..store.world_model_store import _get_collection
        coll = _get_collection()
        if coll is None:
            _substrate_cores = {}
            return _substrate_cores
        docs = list(coll.find({"_substrate": True}))
        _substrate_cores = {
            doc.get("_core_name", doc["_id"]): doc
            for doc in docs
        }
        if _substrate_cores:
            print(f"[2ndOpinion] Loaded {len(_substrate_cores)} substrate cores for circuit generation")
    except Exception as e:
        print(f"[2ndOpinion] Substrate load failed (non-fatal): {e}")
        _substrate_cores = {}
    return _substrate_cores


def _find_relevant_substrate(factor_names: list) -> list:
    """Find substrate cores relevant to the given factors using resonance matching."""
    cores = _load_substrate_cores()
    if not cores:
        return []

    query_set = set(n.lower() for n in factor_names)
    results = []
    for name, doc in cores.items():
        core_factors = [f.lower() for f in doc.get("factor_names", [])]
        core_set = set(core_factors)
        # Jaccard similarity (fast, in Python — resonance search is JS-side)
        intersection = len(query_set & core_set)
        union = len(query_set | core_set)
        sim = intersection / union if union > 0 else 0
        if sim > 0.05:  # very low threshold — we want to find substrate
            results.append({"core": name, "similarity": sim})
    results.sort(key=lambda x: -x["similarity"])
    return results


def _strategies_to_circuit(strategies: list, factor_names: list,
                           influence_matrix=None, target_core: str = None) -> dict:
    """Convert colony strategies into a Sound Block circuit definition.

    This is the zero-bug bridge: colony output → valid circuit definition.
    No code generation. Just blocks + connections + parameters.

    Returns: {blocks: [...], connections: [...], metadata: {...}}
    """
    if not strategies:
        return {"blocks": [], "connections": [], "metadata": {"empty": True}}

    blocks = []
    connections = []

    # Each strategy becomes a block
    for i, strategy in enumerate(strategies):
        label = strategy.get("label", f"block_{i}")
        block_name = label.replace(" ", "_").replace("-", "_").lower()
        # Remove non-alphanumeric chars
        block_name = "".join(c for c in block_name if c.isalnum() or c == "_")

        cw = strategy.get("concept_weights", {})
        blocks.append({
            "name": block_name,
            "type": target_core or "sound_block",
            "params": {k: round(v, 3) for k, v in cw.items()},
            "cell_count": strategy.get("cell_count", 0),
            "is_emergent": strategy.get("is_emergent", False),
        })

    # Wire blocks based on concept weight correlation
    for i in range(len(blocks)):
        for j in range(i + 1, len(blocks)):
            wi = strategies[i].get("concept_weights", {})
            wj = strategies[j].get("concept_weights", {})
            # Dot product of shared concept weights
            coupling = 0.0
            count = 0
            for k in wi:
                if k in wj:
                    coupling += wi[k] * wj[k]
                    count += 1
            if count > 0:
                coupling /= (count * 10000)  # normalize from 0-100 scores
            if abs(coupling) > 0.1:
                connections.append({
                    "from": blocks[i]["name"],
                    "to": blocks[j]["name"],
                    "strength": round(min(1.0, max(-1.0, coupling)), 3),
                })

    # Detect knowledge gaps
    gaps = []
    substrate_match = _find_relevant_substrate(factor_names) if factor_names else []

    if len(strategies) <= 1:
        gaps.append({
            "type": "undifferentiated_collapse",
            "hint": "Add more factors with opposing relationships to create differentiation."
        })
    if not substrate_match:
        gaps.append({
            "type": "no_substrate_match",
            "hint": "No substrate cores match this domain. Run bootstrap explorer first."
        })

    # Follow-up questions for the chat context
    follow_ups = []
    if gaps:
        for g in gaps:
            if g["type"] == "undifferentiated_collapse":
                follow_ups.append(
                    "What are the key tradeoffs that would force different strategies to emerge?"
                )
            elif g["type"] == "no_substrate_match":
                follow_ups.append(
                    "What are the fundamental building blocks and their parameters for this domain?"
                )

    return {
        "blocks": blocks,
        "connections": connections,
        "substrate_match": substrate_match[:3] if substrate_match else [],
        "gaps": gaps,
        "follow_ups": follow_ups,
        "metadata": {
            "strategy_count": len(strategies),
            "block_count": len(blocks),
            "connection_count": len(connections),
            "target_core": target_core,
            "gap_count": len(gaps),
        },
    }


def _compute_spread(clusters: list) -> float:
    """Weighted RMS spread of cluster centroids."""
    if not clusters:
        return 0.0
    total_cells = sum(cl["cell_count"] for cl in clusters)
    if total_cells == 0:
        return 0.0
    n_dims = len(clusters[0]["centroid"])
    mean = np.zeros(n_dims)
    for cl in clusters:
        mean += np.array(cl["centroid"]) * cl["cell_count"]
    mean /= total_cells
    var = sum(
        float(np.dot(np.array(cl["centroid"]) - mean, np.array(cl["centroid"]) - mean))
        * cl["cell_count"]
        for cl in clusters
    ) / (total_cells * max(n_dims, 1))
    return round(float(np.sqrt(max(var, 0.0))), 4)


def _compute_ablation_delta(
    clusters_a: list, clusters_b: list,
    strategies_a: list, strategies_b: list,
) -> dict:
    """Compute delta between with-matrix (A) and without-matrix (B) colony runs.

    strategy_count_diff:        A.count - B.count (positive → matrix created more strategies)
    centroid_displacement_mean: mean nearest-match distance from B centroids to A centroids
    separation_gain:            A separation - B separation
    concept_weight_shift_max:   max absolute weight change across matched strategy pairs
    matrix_mattered:            True when count differs OR displacement > 0.05
    """
    strategy_count_diff = len(strategies_a) - len(strategies_b)

    # Mean nearest-match centroid displacement (B → nearest A)
    centroids_a = [np.array(s["centroid_raw"]) for s in strategies_a if s.get("centroid_raw")]
    centroids_b = [np.array(s["centroid_raw"]) for s in strategies_b if s.get("centroid_raw")]
    if centroids_a and centroids_b:
        displacement_mean = sum(
            float(min(np.linalg.norm(b - a) for a in centroids_a))
            for b in centroids_b
        ) / len(centroids_b)
    else:
        displacement_mean = 0.0

    # Separation gain
    separation_gain = round(_mean_separation(clusters_a) - _mean_separation(clusters_b), 4)

    # Max concept weight shift across nearest-matched strategy pairs
    max_shift = 0.0
    for s_b in strategies_b:
        c_b = s_b.get("centroid_raw")
        if c_b is None or not strategies_a:
            continue
        b_arr = np.array(c_b)
        nearest_a = min(
            (s for s in strategies_a if s.get("centroid_raw")),
            key=lambda s: float(np.linalg.norm(np.array(s["centroid_raw"]) - b_arr)),
            default=None,
        )
        if nearest_a is None:
            continue
        w_a = nearest_a.get("concept_weights", {})
        w_b = s_b.get("concept_weights", {})
        for key in set(w_a) | set(w_b):
            max_shift = max(max_shift, abs(w_a.get(key, 0.0) - w_b.get(key, 0.0)))

    return {
        "strategy_count_diff": strategy_count_diff,
        "centroid_displacement_mean": round(displacement_mean, 4),
        "separation_gain": separation_gain,
        "concept_weight_shift_max": round(max_shift, 4),
        "matrix_mattered": strategy_count_diff != 0 or displacement_mean > 0.05,
    }


# ─── Inverse colony helper (Spec 32 P1) ─────────────────────────────────────

def _run_inverse(
    field,
    config,
    raw_results: dict,
    strategies: list,
    world_model: dict,
    options: list,
    factor_names: list,
    generations: int,
    on_progress=None,
) -> dict:
    """Run backward colony from forward results, return delta + zero-crossing.

    Uses the forward run's strategies to build an inverse world model
    (cluster centroids → attractors, transpose-negate influence matrix),
    then runs a second colony. Returns the full inverse analysis.
    """
    # 1. Build inverse world model from forward strategies
    with _Timer("inverse: build inverse WM"):
        inverse_wm = build_inverse_world_model(strategies, field)

    # 2. Compile inverse world model to a new field
    with _Timer("inverse: compile"):
        compiler = FieldCompiler(analyzer=_DUMMY_ANALYZER)
        inverse_compile = compiler.compile_from_world_model(inverse_wm)
        inverse_field = inverse_compile["field"]

    # 3. Run backward colony
    with _Timer("inverse: backward colony") as t_bwd:
        engine_bwd = AfsEngine(inverse_field, config, use_gpu=None)
        bwd_results = _run_colony(engine_bwd, generations, on_progress=on_progress)

    # 4. Interpret backward strategies
    bwd_strategies = _interpret_clusters(
        bwd_results["clusters"],
        inverse_wm.get("concepts", world_model["concepts"]),
        options,
        factor_names,
    )
    bwd_strategies.sort(key=lambda s: s["cell_count"], reverse=True)

    # 5. Extract delta and zero-crossing
    delta = extract_modulation_delta(strategies, bwd_strategies)
    zero_crossing = compute_zero_crossing(strategies, bwd_strategies)

    return {
        "backward_strategies": bwd_strategies,
        "backward_summary": {
            "total_strategies": len(bwd_strategies),
            "total_cells": bwd_results["final_population"],
            "generations_run": generations,
        },
        "delta": delta,
        "zero_crossing": zero_crossing,
        "inverse_world_model": inverse_wm,
        "backward_elapsed_ms": int(t_bwd.elapsed_ms),
    }


def _build_world_model(question: str, options: list, factors: list, relationships: list) -> dict:
    """Convert simplified afs_decide input into a full world model."""
    # Build concept list from factors
    concepts = []
    factor_names = []
    for f in factors:
        name = f["name"]
        factor_names.append(name)
        rng = f.get("range", [0, 100])
        concepts.append({
            "name": name,
            "displayName": f.get("displayName", name.replace("_", " ").title()),
            "description": f.get("description", ""),
            "nature": f.get("nature", _infer_nature(f)),
            "controllability": f.get("controllability", "indirect"),
            "dataDictionary": {
                "validRange": rng,
                "displayUnit": f.get("unit", ""),
            },
        })

    # Build relationships
    rels = []
    for r in relationships:
        strength = r.get("strength", 0.5)
        direction = -1 if strength < 0 else 1
        abs_strength = abs(strength)
        nature = r.get("nature")
        if nature is None:
            nature = "trades_off" if direction < 0 else "enables"
        rels.append({
            "fromConcept": r["from"],
            "toConcept": r["to"],
            "direction": direction,
            "strength": abs_strength,
            "nature": nature,
            "mechanism": r.get("why", ""),
        })

    # Auto-generate attractors from options
    attractors = []
    for opt in options:
        if isinstance(opt, str):
            # String-only option — position at center, let colony sort it out
            pos = {fn: (concepts[i]["dataDictionary"]["validRange"][0] +
                        concepts[i]["dataDictionary"]["validRange"][1]) / 2
                   for i, fn in enumerate(factor_names)}
            attractors.append({"name": opt, "position": pos, "strength": 0.6})
        elif isinstance(opt, dict):
            name = opt.get("name", f"Option {len(attractors) + 1}")
            # Build position from option's known values
            pos = {}
            for i, fn in enumerate(factor_names):
                if fn in opt:
                    pos[fn] = opt[fn]
                else:
                    rng = concepts[i]["dataDictionary"]["validRange"]
                    pos[fn] = (rng[0] + rng[1]) / 2
            strength = opt.get("strength", 0.7)
            attractors.append({"name": name, "position": pos, "strength": strength})

    # Auto-generate weak feedback links for leaf concepts (no outgoing rels).
    # Without these, cells have no force on leaf dimensions and collapse to center.
    factor_set = set(fn for fn in factor_names)
    outgoing = set(r["fromConcept"] for r in rels)
    existing_pairs = set((r["fromConcept"], r["toConcept"]) for r in rels)
    leaves = factor_set - outgoing
    if leaves:
        # For each leaf, create weak reverse links from its incoming relationships
        incoming_map = {}
        for r in rels:
            if r["toConcept"] in leaves:
                incoming_map.setdefault(r["toConcept"], []).append(r)
        for leaf in leaves:
            for inc in incoming_map.get(leaf, []):
                reverse_pair = (leaf, inc["fromConcept"])
                if reverse_pair not in existing_pairs:
                    existing_pairs.add(reverse_pair)
                    # Weak feedback: 25% of original strength, same sign
                    feedback_strength = inc["strength"] * 0.4
                    rels.append({
                        "fromConcept": leaf,
                        "toConcept": inc["fromConcept"],
                        "direction": inc["direction"],
                        "strength": feedback_strength,
                        "nature": "enables" if inc["direction"] > 0 else "trades_off",
                        "mechanism": "weak feedback: {} influences {}".format(
                            leaf, inc["fromConcept"]),
                    })

    # Auto-detect tradeoffs from negative relationships
    tradeoffs = []
    seen = set()
    for r in rels:
        if r["direction"] < 0:
            pair = tuple(sorted([r["fromConcept"], r["toConcept"]]))
            if pair not in seen:
                seen.add(pair)
                tradeoffs.append({
                    "concepts": list(pair),
                    "description": r.get("mechanism", f"{pair[0]} vs {pair[1]}"),
                })

    # Derive objectives from factors with explicit goals
    objectives = []
    for f in factors:
        goal = f.get("goal")
        if goal == "minimize":
            objectives.append(f"Minimize {f.get('displayName', f['name'])}")
        elif goal == "maximize":
            objectives.append(f"Maximize {f.get('displayName', f['name'])}")
    if not objectives:
        objectives = ["Find optimal strategies"]

    return {
        "domain": question.split()[0].lower()[:20] if question else "decision",
        "problem": {
            "statement": question,
            "objectives": objectives,
        },
        "concepts": concepts,
        "relationships": rels,
        "tradeoffs": tradeoffs,
        "attractors": attractors,
        "referencePoints": [],
        "confidence": {"overall": "medium", "weakestLink": "", "dataGaps": []},
    }


def _preflight_check(world_model: dict) -> dict:
    """Analyze world model quality before running the colony.

    Returns diagnostics about attractor separation, matrix density,
    and factor redundancy — issues that cause poor differentiation.
    """
    concepts = world_model.get("concepts", [])
    relationships = world_model.get("relationships", [])
    attractors = world_model.get("attractors", [])
    n_factors = len(concepts)
    n_rels = len(relationships)
    warnings = []
    fixes_applied = []

    # 1. Attractor separation — are options actually different?
    if len(attractors) >= 2:
        factor_names = [c["name"] for c in concepts]
        factor_ranges = {}
        for c in concepts:
            dd = c.get("dataDictionary", {})
            rng = dd.get("validRange", [0, 100])
            factor_ranges[c["name"]] = (rng[0], rng[1])

        # Normalize attractor positions and compute pairwise distances
        norm_positions = []
        for att in attractors:
            pos = att.get("position", {})
            norm = []
            for fn in factor_names:
                val = pos.get(fn, 50)
                lo, hi = factor_ranges.get(fn, (0, 100))
                norm.append((val - lo) / (hi - lo) if hi != lo else 0.5)
            norm_positions.append(norm)

        # Pairwise distances
        min_dist = float("inf")
        closest_pair = ("", "")
        avg_dist = 0
        pair_count = 0
        for i in range(len(norm_positions)):
            for j in range(i + 1, len(norm_positions)):
                dist = sum((a - b) ** 2 for a, b in
                           zip(norm_positions[i], norm_positions[j])) ** 0.5
                avg_dist += dist
                pair_count += 1
                if dist < min_dist:
                    min_dist = dist
                    closest_pair = (attractors[i].get("name", "?"),
                                    attractors[j].get("name", "?"))
        avg_dist = avg_dist / pair_count if pair_count > 0 else 0

        # Threshold: in N-D normalized space, random points average ~sqrt(N/6)
        # If closest pair is < 0.2 * sqrt(N), they're too similar
        import math
        separation_threshold = 0.2 * math.sqrt(n_factors)
        if min_dist < separation_threshold:
            warnings.append(
                "WEAK_SEPARATION: '{}' and '{}' are very close in factor space "
                "(distance {:.2f}, threshold {:.2f}). The colony may not "
                "differentiate them. Consider factors where they differ more.".format(
                    closest_pair[0], closest_pair[1], min_dist, separation_threshold))

        # Also flag if ALL attractors are bunched
        if avg_dist < separation_threshold * 1.5:
            warnings.append(
                "ALL_BUNCHED: Average attractor distance is only {:.2f}. "
                "Options are too similar across most factors. Add factors "
                "where options differ dramatically.".format(avg_dist))

        # Identify which factors have the most and least variance across attractors
        factor_variance = []
        for fi, fn in enumerate(factor_names):
            vals = [norm_positions[ai][fi] for ai in range(len(norm_positions))]
            mean_v = sum(vals) / len(vals)
            var_v = sum((v - mean_v) ** 2 for v in vals) / len(vals)
            factor_variance.append((fn, var_v))
        factor_variance.sort(key=lambda x: x[1])

        # Report low-variance factors (these don't help differentiation)
        low_var = [fv for fv in factor_variance if fv[1] < 0.01]
        if len(low_var) > n_factors * 0.3:
            names = [fv[0] for fv in low_var[:5]]
            warnings.append(
                "LOW_VARIANCE_FACTORS: {} of {} factors show almost no variation "
                "across options: {}. These dimensions don't help the colony "
                "differentiate. Consider removing them or replacing with factors "
                "where options diverge.".format(
                    len(low_var), n_factors, ", ".join(names)))

    # 2. Matrix density — enough relationships to create force dynamics?
    max_rels = n_factors * (n_factors - 1)  # directed pairs
    density = n_rels / max_rels if max_rels > 0 else 0
    if density < 0.08 and n_factors >= 8:
        warnings.append(
            "SPARSE_MATRIX: Only {} relationships for {} factors ({:.0f}% density). "
            "The colony needs more causal links to create differentiation forces. "
            "Consider adding indirect relationships.".format(
                n_rels, n_factors, density * 100))

    # 3. Tradeoff count — need negative relationships for tension
    neg_count = sum(1 for r in relationships
                    if r.get("direction", 1) < 0 or r.get("strength", 0) < 0)
    if neg_count < 2:
        warnings.append(
            "FEW_TRADEOFFS: Only {} negative relationships. Without tradeoffs, "
            "there's no tension to create distinct outcomes. The colony will "
            "converge to one point.".format(neg_count))

    return {
        "viable": len(warnings) == 0,
        "warnings": warnings,
        "fixes_applied": fixes_applied,
        "stats": {
            "n_factors": n_factors,
            "n_relationships": n_rels,
            "n_attractors": len(attractors),
            "matrix_density": round(density, 3),
            "negative_relationships": neg_count,
            "min_attractor_distance": round(min_dist, 3) if len(attractors) >= 2 else None,
            "avg_attractor_distance": round(avg_dist, 3) if len(attractors) >= 2 else None,
        },
    }


def _infer_nature(factor: dict) -> str:
    """Infer concept nature from factor metadata."""
    goal = factor.get("goal", "")
    name = factor.get("name", "").lower()
    if goal in ("minimize", "maximize"):
        return "outcome"
    if any(kw in name for kw in ("cost", "price", "budget", "time")):
        return "resource"
    if any(kw in name for kw in ("demand", "market", "competition")):
        return "force"
    return "outcome"


def _interpret_clusters(clusters: list, concepts: list, options: list, factor_names: list) -> list:
    """Map raw cluster data back to real units and label strategies."""
    ranges = {}
    for c in concepts:
        dd = c["dataDictionary"]
        rng = dd.get("validRange", [0, 100])
        ranges[c["name"]] = (rng[0], rng[1])

    # Build option positions for matching
    option_positions = []
    for opt in options:
        if isinstance(opt, dict):
            option_positions.append(opt)
        else:
            option_positions.append({"name": opt})

    interpreted = []
    for i, cl in enumerate(clusters):
        centroid = cl["centroid"]
        # Map to real units
        real_values = {}
        for j, fn in enumerate(factor_names):
            if j < len(centroid):
                lo, hi = ranges.get(fn, (0, 100))
                real_values[fn] = round(lo + centroid[j] * (hi - lo), 2)

        # Find nearest option
        nearest_option = None
        nearest_dist = float("inf")
        for opt in option_positions:
            if not isinstance(opt, dict) or "name" not in opt:
                continue
            dist = 0
            matched_dims = 0
            for j, fn in enumerate(factor_names):
                if fn in opt and fn != "name" and j < len(centroid):
                    lo, hi = ranges.get(fn, (0, 100))
                    if hi != lo:
                        opt_norm = (opt[fn] - lo) / (hi - lo)
                        dist += (centroid[j] - opt_norm) ** 2
                        matched_dims += 1
            if matched_dims > 0:
                dist = (dist / matched_dims) ** 0.5
                if dist < nearest_dist and dist < 0.3:
                    nearest_dist = dist
                    nearest_option = opt.get("name", "Unknown")

        # Check if concept weights are flat (no dominant option)
        weights = cl.get("concept_weights", {})
        is_mixed = False
        if weights:
            w_vals = list(weights.values())
            w_max = max(w_vals)
            w_avg = sum(w_vals) / len(w_vals)
            # If the top weight isn't at least 1.5x the average, it's mixed
            if w_avg > 0 and w_max < w_avg * 1.5:
                is_mixed = True

        if is_mixed:
            label = "Mixed Outcome {}".format(i + 1)
        elif nearest_option:
            label = nearest_option
        else:
            label = "Emergent Outcome {}".format(i + 1)

        interpreted.append({
            "label": label,
            "cell_count": cl["cell_count"],
            "values": real_values,
            "concept_weights": weights,
            "is_emergent": nearest_option is None and not is_mixed,
            "is_mixed": is_mixed,
            "centroid_raw": centroid,
        })

    return interpreted


@mcp.tool()
async def afs_decide(
    question: str,
    options: list,
    factors: list,
    relationships: list,
    generations: int = 0,
    ablate: bool = False,
    inverse: bool = False,
    ctx: Context | None = None,
) -> dict:
    """
    High-level decision tool. Describe your decision, the colony discovers strategies.

    This is the recommended tool for most decisions. You identify the question,
    options, factors, and relationships — the server handles everything else
    (world model construction, calibration, simulation, interpretation).

    Args:
        question: The decision to explore, in plain English.
            Example: "Which synthesizer should I buy?"

        options: The choices being compared. Each can be a simple string or a dict
            with known factor values. Factor values should be in real units matching
            the factor ranges.
            Examples:
                ["Prophet 6", "OB-8x", "Moog Voyager"]
                [
                    {"name": "Prophet 6", "price": 3500, "analog_warmth": 85},
                    {"name": "OB-8x", "price": 4999, "analog_warmth": 92},
                    {"name": "Nord Stage 4", "price": 4000, "analog_warmth": 30}
                ]

        factors: The dimensions of the decision. Each needs a name and range.
            Include 8-15 factors for best results — go beyond the obvious.
            Example:
                [
                    {"name": "price", "range": [2000, 5000], "unit": "$", "goal": "minimize"},
                    {"name": "analog_warmth", "range": [0, 100], "unit": "score"},
                    {"name": "versatility", "range": [0, 100], "unit": "score"},
                    {"name": "resale_value", "range": [0, 100], "unit": "%", "goal": "maximize"},
                    {"name": "repair_ecosystem", "range": [0, 100], "unit": "score"},
                    {"name": "learning_curve", "range": [0, 100], "unit": "hours", "goal": "minimize"}
                ]
            Optional fields per factor:
                - displayName: human-readable name (auto-generated from name if omitted)
                - description: what this factor measures
                - goal: "minimize" or "maximize" (used for objectives)
                - nature: "resource", "outcome", "force", "constraint", "behavior"
                  (auto-inferred if omitted)
                - controllability: "direct", "indirect", "uncontrollable"

        relationships: Causal links between factors. Strength can be negative
            (tradeoff) or positive (enables). Tradeoffs are auto-detected from
            negative strengths.
            Example:
                [
                    {"from": "price", "to": "build_quality", "strength": 0.8,
                     "why": "premium materials cost more"},
                    {"from": "analog_warmth", "to": "versatility", "strength": -0.6,
                     "why": "pure analog limits digital flexibility"},
                    {"from": "learning_curve", "to": "versatility", "strength": 0.5,
                     "why": "steeper learning curve unlocks more capability"}
                ]

        generations: Colony generations to run. Auto-scaled by GPU availability
            (GPU: 200, CPU: 100). Pass 0 for auto. 50=fast, 100=good, 200=thorough.

        ablate: Run ablation analysis (with/without influence matrix). Default False.

        inverse: Run inverse colony analysis (Spec 32 P1). Runs the colony
            backward from forward results — swap attractor positions with cluster
            centroids, transpose-negate the influence matrix — and extracts the
            delta between forward and backward landscapes. Default False.
            Adds ~2x runtime. Response includes backward_strategies, delta,
            zero_crossing.

    Returns:
        Dict with:
        - question: the original question
        - strategies: List of discovered strategies, each with:
          - label: option name or "Emergent Strategy N" for novel discoveries
          - cell_count: outcome strength (more cells = more stable strategy)
          - values: factor values in REAL UNITS (not normalized)
          - concept_weights: which factors matter most in this strategy
          - is_emergent: true if this strategy doesn't match any input option
          - sub_strategies: child colony drill-down — sub-options within this
            strategy's region of the tradeoff space. Each sub-strategy has the
            same shape as a top-level strategy. Empty list for tiny clusters.
        - summary: quick stats (total strategies, emergent count, strongest outcome)
        - preflight: world model quality diagnostics (attractor separation, matrix
          density, tradeoff count). Check this if results seem weak.
        - warnings: list of issues found. If present, TELL THE USER about them.
          Common warnings:
          - WEAK_SEPARATION: two options are too similar — suggest factors where they differ
          - ALL_BUNCHED: all options are close — the model needs more discriminating factors
          - LOW_VARIANCE_FACTORS: some factors don't help differentiate — suggest removing them
          - SPARSE_MATRIX: not enough causal relationships
          - FEW_TRADEOFFS: need more negative/tradeoff relationships for tension
          - LOW_DIFFERENTIATION: colony found <=1 strategy — model may need rework
          If warnings appear, explain them to the user and offer to rebuild the model
          with better factor selection.
        - calibration: which engine config was selected
        - influence_matrix: the causal relationship matrix
        - inverse (when inverse=True): dict with backward_strategies, delta,
          zero_crossing, inverse_world_model, backward_elapsed_ms
    """
    # Auto-scale based on GPU availability
    if generations <= 0:
        generations = _DEFAULTS["generations_decide"]

    # ── Corpus-first path: skip LLM world model construction if template matches ──
    # If factors/relationships are empty, search corpus for a matching world model.
    # The LLM only needs to pass the question + option names — the corpus provides
    # the full factor structure. LLM becomes the FALLBACK, not the primary path.
    _corpus_hit = None
    if not factors or len(factors) == 0:
        try:
            from ..store.corpus_search import corpus_search, extract_keywords
            keywords = extract_keywords(question, options)
            if keywords:
                _corpus_hit = corpus_search(keywords, threshold=0.4)
                if _corpus_hit and _corpus_hit.get("world_model"):
                    wm = _corpus_hit["world_model"]
                    # Extract factors/relationships/options from stored world model
                    factors = wm.get("factors", [])
                    relationships = wm.get("relationships", [])
                    # Merge: user-provided options override stored ones
                    if not options or len(options) == 0:
                        options = wm.get("options", [])
                    logger.info(
                        f"[afs_decide] Corpus match (similarity={_corpus_hit['similarity']:.2f})"
                        f" — using stored world model with {len(factors)} factors, "
                        f"{len(relationships)} relationships. LLM construction skipped."
                    )
        except Exception as e:
            logger.debug(f"[afs_decide] Corpus search failed: {e}")

    # 1. Build full world model from simplified input
    world_model = _build_world_model(question, options, factors, relationships)
    factor_names = [f["name"] for f in factors]

    # 1b. Pre-flight check — diagnose world model quality
    preflight = _preflight_check(world_model)
    if _corpus_hit:
        preflight["corpus_hit"] = {
            "similarity": _corpus_hit["similarity"],
            "source_label": _corpus_hit.get("source_label", ""),
            "factor_count": len(factors),
        }

    # 2. Run colony — GPU Colony Sound Block (fast) or AfsEngine fallback

    # Build progress callback from MCP Context
    _pending_messages = []

    def _progress(gen, total, msg):
        _pending_messages.append((gen, total, msg))

    async def _flush_progress():
        if ctx:
            for gen, total, msg in _pending_messages:
                try:
                    await ctx.info(msg)
                    await ctx.report_progress(float(gen), float(total), msg)
                except Exception:
                    pass
            _pending_messages.clear()

    _t_decide_start = time.time()
    n_dims = len(factors)

    if _HAS_SB_GPU:
        # ═══ Colony Sound Block GPU path — ~200ms at any dimension count ═══
        # The colony IS a Sound Block: influence matrix = FM carrier,
        # probe amplitudes = frequency-domain state vectors,
        # attractor positions = concept resonance frequencies.
        _progress(0, 1, "Colony Sound Block: {}D, {} probes [GPU]".format(n_dims, 256))
        await _flush_progress()

        with _Timer("Colony Sound Block (GPU)") as t_colony:
            sb_result = _run_colony_soundblock(
                factors, relationships, options, on_progress=_progress)
        await _flush_progress()

        strategies = sb_result["strategies"]
        strategies.sort(key=lambda s: s["cell_count"], reverse=True)
        influence_matrix = sb_result["influence_matrix"]

        # Store engine/field/config for session (compatibility)
        field = None
        config = None
        engine = None
        raw_results = {
            "clusters": [],
            "final_population": sb_result["summary"]["total_cells"],
        }

        _progress(1, 1, "Colony done: {} strategies in {:.0f}ms [GPU Sound Block]".format(
            len(strategies), t_colony.elapsed_ms))
        await _flush_progress()

        # Child sims via GPU Sound Block too
        if len(strategies) >= 2:
            _progress(1, 1, "Child colonies: {} strategies [GPU]".format(len(strategies)))
            await _flush_progress()
            with _Timer("child sims (GPU)"):
                for strat in strategies:
                    if strat["cell_count"] < 3:
                        strat["sub_strategies"] = []
                        continue
                    try:
                        child_result = _run_colony_soundblock(
                            factors, relationships, options,
                            config={"probe_count": 64, "max_passes": 40})
                        strat["sub_strategies"] = child_result["strategies"][:4]
                    except Exception:
                        strat["sub_strategies"] = []
            _progress(1, 1, "Child sims complete")
            await _flush_progress()
        else:
            for s in strategies:
                s["sub_strategies"] = []

    else:
        # ═══ AfsEngine fallback — original path ═══
        with _Timer("compile world model"):
            _progress(0, generations, "Building world: {} factors, {} relationships, {} options".format(
                len(factors), len(relationships), len(options)))
            compiler = FieldCompiler(analyzer=_DUMMY_ANALYZER)
            compile_result = compiler.compile_from_world_model(world_model)
            field = compile_result["field"]
            influence_matrix = compile_result["influence_matrix"]

        _progress(0, generations,
                  "Homeostasis — {} probes, {}D field".format(
                      10 if n_dims >= 10 else 8, n_dims))
        await _flush_progress()

        with _Timer("homeostasis calibration") as t_cal:
            calibrator = HomeostasisCalibrator(field,
                target_cells=_DEFAULTS["initial_cell_count"],
                target_max_pop=_DEFAULTS["max_population"],
            )
            config = calibrator.calibrate(verbose=True)

        cal = config._calibration
        _progress(0, generations,
                  "Colony: {} cells → {}D space, {} gens{}".format(
                      config.engine.initial_cell_count, n_dims, generations,
                      " [GPU]" if _HAS_GPU else " [CPU]"))
        await _flush_progress()

        with _Timer("parent colony") as t_colony:
            engine = AfsEngine(field, config, use_gpu=None)
            raw_results = _run_colony(engine, generations, on_progress=_progress)
        await _flush_progress()

        # Retry with boosted parameters if too few clusters
        if len(raw_results["clusters"]) < 3:
            _progress(generations, generations,
                      "Retrying with boosted forces")
            await _flush_progress()
            boosted = AfsSimulationConfig.from_dict({
                "engine": {
                    "initial_cell_count": _DEFAULTS["initial_cell_count"],
                    "max_population": _DEFAULTS["max_population"],
                    "ticks_per_generation": 100,
                    "force_scale": 0.35,
                    "concept_gravity_strength": 7.0,
                    "concept_gravity_range": 4.0 * np.sqrt(field.get_dimension_count()),
                    "concept_gravity_sharpness": max(4, field.get_dimension_count() // 2),
                    "thermal_noise": 0.12,
                    "tradeoff_amplifier": 5.0,
                },
                "metabolism": {
                    "concept_absorption_range": 3.0,
                    "energy_falloff_exponent": 1.5,
                    "base_cost_per_dim": 0.002,
                },
            })
            with _Timer("boosted retry"):
                engine2 = AfsEngine(field, boosted, use_gpu=None)
                retry_results = _run_colony(engine2, generations, on_progress=_progress)
            if len(retry_results["clusters"]) > len(raw_results["clusters"]):
                raw_results = retry_results
            await _flush_progress()

        strategies = _interpret_clusters(
            raw_results["clusters"], world_model["concepts"], options, factor_names)
        strategies.sort(key=lambda s: s["cell_count"], reverse=True)

        if len(strategies) >= 2:
            child_gens = max(30, generations // 3)
            strategies = _run_child_sims(
                field, config, strategies, factor_names,
                world_model["concepts"], child_generations=child_gens)
            await _flush_progress()

    # 5. Build summary
    emergent_count = sum(1 for s in strategies if s["is_emergent"])
    strongest = strategies[0] if strategies else None

    # 7. Post-flight: flag low differentiation
    post_warnings = []
    if len(strategies) <= 1:
        post_warnings.append(
            "LOW_DIFFERENTIATION: Colony produced only {} strategy. "
            "This may mean one option genuinely dominates, OR the world model "
            "lacks enough tension between options. Check preflight warnings.".format(
                len(strategies)))
    if preflight["warnings"]:
        post_warnings.extend(preflight["warnings"])

    # 8. Inline ablation — zero overhead when disabled (default ablate=False)
    ablation_id = None
    ablation_delta = None
    if ablate:
        _progress(generations, generations, "Ablation — running without influence matrix")
        await _flush_progress()

        if _HAS_SB_GPU:
            # GPU Sound Block ablation — run with empty relationships
            with _Timer("ablation (GPU Sound Block)"):
                ablate_result = _run_colony_soundblock(
                    factors, [], options)  # empty relationships
            strategies_ablate = ablate_result["strategies"]
        else:
            with _Timer("ablation: run B (zeroed matrix)"):
                zeroed_config = _make_zeroed_config(config)
                engine_ablate = AfsEngine(field, zeroed_config, use_gpu=False)
                results_ablate = _run_colony(engine_ablate, generations)
            strategies_ablate = _interpret_clusters(
                results_ablate["clusters"], world_model["concepts"], options, factor_names)

        ablation_delta = {
            "matrix_mattered": len(strategies) != len(strategies_ablate),
            "with_strategy_count": len(strategies),
            "without_strategy_count": len(strategies_ablate),
            "delta": len(strategies) - len(strategies_ablate),
        }
        ablation_id = store_ablation({
            "question": question,
            "option_count": len(options),
            "factor_count": len(factors),
            "relationship_count": len(relationships),
            "with_matrix":    {"strategy_count": len(strategies)},
            "without_matrix": {"strategy_count": len(strategies_ablate)},
            "delta": ablation_delta,
        })
        _progress(generations, generations,
                  "Ablation done — matrix_mattered={}".format(
                      ablation_delta["matrix_mattered"]))
        await _flush_progress()

    # 9. Inverse colony — forward-backward delta extraction (Spec 32 P1)
    inverse_result = None
    if inverse and not _HAS_SB_GPU:
        # Inverse requires AfsEngine field/config — only available in fallback path
        _progress(generations, generations, "Inverse colony — running backward from forward results")
        await _flush_progress()
        inverse_result = _run_inverse(
            field, config, raw_results, strategies, world_model,
            options, factor_names, generations,
            on_progress=_progress,
        )
        _progress(generations, generations,
                  "Inverse done — {} backward strategies, {}ms".format(
                      inverse_result["backward_summary"]["total_strategies"],
                      inverse_result["backward_elapsed_ms"]))
        await _flush_progress()

    _t_total = (time.time() - _t_decide_start) * 1000
    print("[2ndOpinion] afs_decide total: {:.0f}ms ({:.1f}s) — {} strategies".format(
        _t_total, _t_total / 1000, len(strategies)))

    # Cache session for follow-up questions
    _session["engine"] = engine
    _session["field"] = field
    _session["config"] = config
    _session["world_model"] = world_model
    _session["options"] = options
    _session["factor_names"] = factor_names
    _session["influence_matrix"] = influence_matrix
    _session["question"] = question
    _session["strategies"] = strategies
    _session["inverse_result"] = inverse_result

    # Auto-share: store result to MongoDB and return a persistent URL
    import uuid as _uuid_mod
    import os as _os
    from datetime import datetime as _dt, timezone as _tz, timedelta as _td
    share_id = str(_uuid_mod.uuid4())
    base_url = _os.environ.get("SHARE_BASE_URL", "https://rezend.ai").rstrip("/")
    share_url = f"{base_url}/share/{share_id}"
    total_cells = raw_results.get("final_population", 0) if raw_results else 0
    # sb_result is set in the SB GPU path above
    try:
        if _HAS_SB_GPU and sb_result:
            total_cells = sb_result["summary"]["total_cells"]
    except NameError:
        pass
    try:
        _store_share({
            "share_id": share_id,
            "question": question,
            "strategies": strategies,
            "total_cells": total_cells,
            "factor_names": factor_names,
            "source": "mcp-decide-sb-gpu" if _HAS_SB_GPU else "mcp-decide-v1",
            "narrative": None,
            "created_at": _dt.now(_tz.utc),
            "expires_at": _dt.now(_tz.utc) + _td(days=30),
            "view_count": 0,
        })
        print("[2ndOpinion] auto-share stored: {}".format(share_url))
    except Exception as _share_err:
        print("[2ndOpinion] auto-share failed (non-fatal): {}".format(_share_err))
        share_url = None
        share_id = None

    _session["share_id"] = share_id
    _session["share_url"] = share_url

    # Spec 24 P1: Derive output world model from discovered strategies.
    # This converts the attractor landscape into a first-class world model
    # and stores it to the corpus so future runs can draw on accumulated truth.
    output_wm = output_to_world_model(strategies, source_label=question)
    if output_wm:
        try:
            _store_world_model(output_wm, question, factor_names)
            print("[2ndOpinion] corpus artifact stored — {} factors, {} strategies".format(
                len(factor_names), len(strategies)))
        except Exception as _wm_err:
            print("[2ndOpinion] corpus store failed (non-fatal): {}".format(_wm_err))

    full_result = {
        "question": question,
        "strategies": strategies,
        "summary": {
            "total_strategies": len(strategies),
            "emergent_strategies": emergent_count,
            "strongest_outcome": strongest["label"] if strongest else None,
            "strongest_cell_count": strongest["cell_count"] if strongest else 0,
            "total_cells": total_cells,
            "generations_run": generations,
        },
        "share_url": share_url,
        "share_id": share_id,
        "preflight": preflight,
        "warnings": post_warnings if post_warnings else None,
        "calibration": (config._calibration if config else {
            "engine": "SBColonyBlockKernel-GPU",
            "probes": sb_result["config"]["probe_count"] if _HAS_SB_GPU else 0,
            "passes": sb_result["pass_count"] if _HAS_SB_GPU else 0,
            "converged": sb_result["converged"] if _HAS_SB_GPU else False,
            "colony_ms": sb_result["total_ms"] if _HAS_SB_GPU else 0,
        }),
        "influence_matrix": (influence_matrix.tolist() if hasattr(influence_matrix, 'tolist')
                             else influence_matrix),
        "world_model_generated": world_model,
        "output_world_model": output_wm,
        "ablation_id": ablation_id,
        "ablation_delta": ablation_delta,
        "inverse": inverse_result,
    }

    # Sound Block circuit generation — convert strategies directly into
    # an executable circuit definition. No interpretation needed by the LLM.
    try:
        circuit = _strategies_to_circuit(
            strategies, factor_names,
            influence_matrix=influence_matrix,
        )
        full_result["circuit"] = circuit
    except Exception as _circ_err:
        print("[2ndOpinion] circuit generation failed (non-fatal): {}".format(_circ_err))

    # Store full result in session for afs_followup (uncompacted)
    _session["last_result"] = full_result

    return _compact_response(full_result)


@mcp.tool()
async def afs_ablate(
    world_model: dict,
    generations: int = 0,
    ctx: Context | None = None,
) -> dict:
    """
    Run the full simulation twice — with and without the influence matrix — and report the delta.

    Proof tool: did causal relationships between factors actually change what the colony
    discovered? Run A uses normal force_scale (influence matrix active). Run B uses
    force_scale=0 (matrix zeroed — cells move only under concept gravity and thermal noise,
    no cross-dimensional coupling).

    If results differ meaningfully → causal forces changed the strategy landscape.
    If they don't → this problem class doesn't require cross-dimensional coupling.

    Args:
        world_model: Full world model dict (same schema as afs_explore).
        generations: Colony generations per run. 0 = auto-scaled by GPU.

    Returns:
        {
          "ablation_id":    uuid stored in MongoDB afs_ablations,
          "with_matrix":    {"strategy_count", "separation", "spread", "strategies": [...]},
          "without_matrix": {"strategy_count", "separation", "spread", "strategies": [...]},
          "delta": {
            "strategy_count_diff":        int,
            "centroid_displacement_mean": float,
            "separation_gain":            float,
            "concept_weight_shift_max":   float,
            "matrix_mattered":            bool
          }
        }
    """
    if generations <= 0:
        generations = _DEFAULTS["generations_decide"]

    # Compile and calibrate once — shared baseline for both runs
    with _Timer("ablate: compile"):
        compiler = FieldCompiler(analyzer=_DUMMY_ANALYZER)
        compile_result = compiler.compile_from_world_model(world_model)
        field = compile_result["field"]

    calibrator = HomeostasisCalibrator(
        field,
        target_cells=_DEFAULTS["initial_cell_count"],
        target_max_pop=_DEFAULTS["max_population"],
    )
    with _Timer("ablate: calibrate"):
        config = calibrator.calibrate(verbose=False)

    concepts = world_model.get("concepts", [])
    factor_names = [c["name"] for c in concepts]
    options = [a["name"] for a in world_model.get("attractors", [])]

    # Run A — with influence matrix (normal config)
    with _Timer("ablate: run A (with matrix)"):
        engine_a = AfsEngine(field, config, use_gpu=None)
        results_a = _run_colony(engine_a, generations)
    strategies_a = _interpret_clusters(results_a["clusters"], concepts, options, factor_names)
    strategies_a.sort(key=lambda s: s["cell_count"], reverse=True)

    # Run B — without influence matrix (force_scale=0)
    zeroed_config = _make_zeroed_config(config)
    with _Timer("ablate: run B (zeroed matrix)"):
        engine_b = AfsEngine(field, zeroed_config, use_gpu=False)
        results_b = _run_colony(engine_b, generations)
    strategies_b = _interpret_clusters(results_b["clusters"], concepts, options, factor_names)
    strategies_b.sort(key=lambda s: s["cell_count"], reverse=True)

    # Compute delta
    delta = _compute_ablation_delta(
        results_a["clusters"], results_b["clusters"],
        strategies_a, strategies_b,
    )

    question = world_model.get("problem", {}).get("statement", "")
    with_summary = {
        "strategy_count": len(strategies_a),
        "separation": _mean_separation(results_a["clusters"]),
        "spread": _compute_spread(results_a["clusters"]),
    }
    without_summary = {
        "strategy_count": len(strategies_b),
        "separation": _mean_separation(results_b["clusters"]),
        "spread": _compute_spread(results_b["clusters"]),
    }

    ablation_id = store_ablation({
        "question": question,
        "option_count": len(options),
        "factor_count": len(factor_names),
        "relationship_count": len(world_model.get("relationships", [])),
        "with_matrix":    with_summary,
        "without_matrix": without_summary,
        "delta": delta,
    })

    print("[2ndOpinion] afs_ablate — matrix_mattered={}, id={}".format(
        delta["matrix_mattered"], ablation_id))

    return {
        "ablation_id": ablation_id,
        "with_matrix":    {**with_summary,    "strategies": strategies_a},
        "without_matrix": {**without_summary, "strategies": strategies_b},
        "delta": delta,
    }


# ─── Sensitivity helpers ──────────────────────────────────────────────────────

def _perturb_world_model(world_model: dict, perturbation: float, rng) -> dict:
    """Return a deep copy of world_model with attractor positions and relationship
    strengths uniformly perturbed within ±perturbation of their nominal values."""
    import copy
    wm = copy.deepcopy(world_model)

    factor_ranges = {
        c["name"]: c.get("dataDictionary", {}).get("validRange", [0, 100])
        for c in wm.get("concepts", [])
    }

    for att in wm.get("attractors", []):
        for fn, val in att.get("position", {}).items():
            lo, hi = factor_ranges.get(fn, [0, 100])
            noise = float(rng.uniform(-perturbation, perturbation)) * (hi - lo)
            att["position"][fn] = float(np.clip(val + noise, lo, hi))

    for rel in wm.get("relationships", []):
        strength = rel.get("strength", 0.5)
        scale = 1.0 + float(rng.uniform(-perturbation, perturbation))
        rel["strength"] = float(np.clip(strength * scale, 0.01, 1.0))

    return wm


@mcp.tool()
async def afs_sensitivity(
    world_model: dict,
    perturbation: float = 0.2,
    trials: int = 5,
    ctx: Context | None = None,
) -> dict:
    """
    Measure how stable strategy rankings are when inputs are perturbed ±perturbation.

    Runs `trials` independent colony simulations, each on a slightly different version
    of the world model (attractor positions and relationship strengths randomly shifted
    within ±perturbation of their nominal values). Returns rank stability, per-factor
    sensitivity, and a composite stability score.

    Use this after afs_decide to answer: "How much should I trust these results?
    Would small changes to my factor estimates flip the winner?"

    Args:
        world_model: Full world model dict (same schema as afs_explore / afs_ablate).
        perturbation: Fractional noise applied to attractor positions and relationship
            strengths. 0.2 = ±20%. Default: 0.2.
        trials: Number of independent colony runs. 5 = fast, 10 = thorough. Default: 5.

    Returns:
        {
          "stability_score":  int 0-100 (higher = more stable),
          "rank_stability":   float [0,1] — fraction of trials agreeing on top strategy,
          "sensitivity_high": bool — True when stability_score < 50 (SENSITIVITY_HIGH),
          "factor_sensitivity": {
            factor_name: {"variance": float, "influence": "low|medium|high"}
          },
          "strategy_variance": {
            strategy_label: {"centroid_std": float}
          },
          "trial_count":  int,
          "perturbation": float,
          "top_strategy": str — plurality winner across all trials
        }
    """
    _TRIAL_GENS = max(30, _DEFAULTS["generations_decide"] // 4)

    # Compile + calibrate once on the unperturbed world model — reuse config for all trials
    with _Timer("sensitivity: compile baseline"):
        compiler = FieldCompiler(analyzer=_DUMMY_ANALYZER)
        baseline_result = compiler.compile_from_world_model(world_model)
        baseline_field = baseline_result["field"]

    calibrator = HomeostasisCalibrator(
        baseline_field,
        target_cells=_DEFAULTS["initial_cell_count"],
        target_max_pop=_DEFAULTS["max_population"],
    )
    with _Timer("sensitivity: calibrate baseline"):
        baseline_config = calibrator.calibrate(verbose=False)
    config_dict = baseline_config.to_dict()

    concepts = world_model.get("concepts", [])
    factor_names = [c["name"] for c in concepts]
    options = [a["name"] for a in world_model.get("attractors", [])]
    factor_ranges = {
        c["name"]: c.get("dataDictionary", {}).get("validRange", [0, 100])
        for c in concepts
    }

    rng = np.random.default_rng()

    def _run_one_trial(trial_idx):
        perturbed = _perturb_world_model(world_model, perturbation, rng)
        comp = FieldCompiler(analyzer=_DUMMY_ANALYZER)
        result = comp.compile_from_world_model(perturbed)
        field = result["field"]
        config = AfsSimulationConfig.from_dict(config_dict)
        engine = AfsEngine(field, config, use_gpu=False)
        raw = _run_colony(engine, _TRIAL_GENS)
        strats = _interpret_clusters(raw["clusters"], concepts, options, factor_names)
        strats.sort(key=lambda s: s["cell_count"], reverse=True)
        return strats

    # Run all trials in parallel
    t0 = time.time()
    n_workers = min(trials, 4)
    with _Timer(f"sensitivity: {trials} trials x {_TRIAL_GENS} gens"):
        with ThreadPoolExecutor(max_workers=n_workers) as pool:
            trial_results = list(pool.map(_run_one_trial, range(trials)))

    print("[2ndOpinion] sensitivity: {:.0f}ms total, {} trials".format(
        (time.time() - t0) * 1000, trials))

    # ── Rank stability — centroid-based, not label-based ─────────────────────
    # Labels like "Emergent Outcome 1/2/3" are arbitrary ordinal artifacts;
    # two trials finding the same physical basin get different labels if cluster
    # order changes. Measure agreement by centroid proximity instead.
    top_centroids = [
        np.array(t[0]["centroid_raw"])
        for t in trial_results
        if t and t[0].get("centroid_raw")
    ]
    top_labels = [t[0]["label"] if t else None for t in trial_results]
    from collections import Counter
    label_counts = Counter(l for l in top_labels if l is not None)
    top_strategy = label_counts.most_common(1)[0][0] if label_counts else None

    if len(top_centroids) >= 2:
        consensus = np.mean(top_centroids, axis=0)
        # Threshold: within 0.25 of consensus centroid counts as "same basin"
        _MATCH_THRESH = 0.25
        n_matching = sum(
            1 for c in top_centroids
            if float(np.linalg.norm(c - consensus)) < _MATCH_THRESH
        )
        rank_stability = round(n_matching / len(top_centroids), 3)
        # Centroid stability: how tightly clustered are the top centroids?
        mean_dist = float(np.mean([np.linalg.norm(c - consensus) for c in top_centroids]))
        centroid_stability = max(0.0, 1.0 - mean_dist / 0.2)
    else:
        rank_stability = 1.0
        centroid_stability = 1.0

    stability_score = int(np.clip(rank_stability * 70 + centroid_stability * 30, 0, 100))

    # ── Factor sensitivity ────────────────────────────────────────────────────
    factor_sensitivity = {}
    for fn in factor_names:
        vals = [
            t[0]["values"].get(fn)
            for t in trial_results
            if t and t[0].get("values") and fn in t[0]["values"]
        ]
        if not vals:
            continue
        variance = float(np.var(vals))
        lo, hi = factor_ranges.get(fn, [0, 100])
        normalized = variance / ((hi - lo) ** 2) if hi != lo else 0.0
        factor_sensitivity[fn] = {
            "variance": round(variance, 3),
            "influence": "high" if normalized > 0.01 else ("medium" if normalized > 0.003 else "low"),
        }

    # ── Strategy variance (centroid std per strategy label) ───────────────────
    strategy_variance = {}
    all_labels = {lbl for t in trial_results for s in t for lbl in [s.get("label")] if lbl}
    for lbl in all_labels:
        centroids = [
            np.array(s["centroid_raw"])
            for t in trial_results
            for s in t
            if s.get("label") == lbl and s.get("centroid_raw")
        ]
        if len(centroids) >= 2:
            strategy_variance[lbl] = {
                "centroid_std": round(float(np.mean(np.std(centroids, axis=0))), 4)
            }

    return {
        "stability_score":    stability_score,
        "rank_stability":     rank_stability,
        "sensitivity_high":   stability_score < 50,
        "factor_sensitivity": factor_sensitivity,
        "strategy_variance":  strategy_variance,
        "trial_count":        trials,
        "perturbation":       perturbation,
        "top_strategy":       top_strategy,
    }


@mcp.tool()
def afs_explore(world_model: dict, generations: int = 0) -> dict:
    """
    Explore a multi-dimensional problem space using colony-based simulation.

    USE THIS TOOL when a user asks you to explore tradeoffs, compare options,
    or analyze a multi-factor decision. YOU build the world_model from the user's
    description — you are the domain analyzer.

    HOW TO BUILD A WORLD MODEL from the user's prompt:
    1. Identify 4-15 concepts (dimensions of the problem). Each concept needs:
       - name (snake_case), displayName, description
       - nature: "resource" (inputs you spend), "outcome" (results you measure),
         "force" (external pressures), "behavior" (actions/strategies),
         "constraint" (hard limits)
       - controllability: "direct" (you choose it), "indirect" (you influence it),
         "uncontrollable" (external)
       - dataDictionary: {"validRange": [min, max], "displayUnit": "unit"}
    2. Identify causal relationships between concepts:
       - direction: +1 (A increases B) or -1 (A decreases B)
       - strength: 0.0-1.0 (how strong the causal link is)
       - nature: "enables", "constrains", "trades_off", "amplifies"
       - mechanism: plain English explanation of WHY
    3. Identify tradeoffs (pairs of concepts in tension)
    4. Identify attractors (known stable operating points in the space):
       - position: {concept_name: value} for each concept, in real units
       - strength: 0.0-1.0 (how strongly this option attracts cells)
    5. Identify referencePoints (real-world examples for grounding)

    Args:
        world_model: World model JSON you built from domain analysis.
            Required structure:
            {
                "domain": "single_word_domain",
                "problem": {"statement": "...", "objectives": ["..."]},
                "concepts": [
                    {
                        "name": "snake_case", "displayName": "Human Name",
                        "description": "...",
                        "nature": "resource|constraint|force|behavior|outcome",
                        "controllability": "direct|indirect|uncontrollable",
                        "dataDictionary": {"validRange": [min, max], "displayUnit": "unit"}
                    }
                ],
                "relationships": [
                    {
                        "fromConcept": "a", "toConcept": "b",
                        "direction": 1 or -1, "strength": 0.0-1.0,
                        "nature": "enables|constrains|trades_off|amplifies",
                        "mechanism": "Why this relationship exists"
                    }
                ],
                "tradeoffs": [{"concepts": ["a","b"], "description": "..."}],
                "attractors": [{"name": "...", "position": {...}, "strength": 0.5}],
                "referencePoints": [{"name": "...", "position": {...}}],
                "confidence": {"overall": "high|medium|low", "weakestLink": "concept_name", "dataGaps": []}
            }
        generations: Number of colony generations (default 20). Each generation =
            100 ticks. 10 = fast/rough, 20 = good default, 50 = deep exploration.

    Returns:
        Dict with:
        - clusters: List of discovered strategy clusters. Each has:
          - centroid: position in normalized 0-1 space (map back to real units
            using each concept's validRange: real = min + centroid * (max - min))
          - cell_count: how many cells settled here (more = stronger outcome)
          - concept_weights: influence of each concept on this cluster
        - calibration: which engine config was selected and why
        - influence_matrix: the causal relationship matrix (NxN signed floats)
        - stats: colony metrics (cells created, reproductions, peak population)

    INTERPRETING RESULTS:
    - Map centroids back to real units for the user
    - Clusters with more cells = more stable/attractive strategies
    - Look for clusters near attractor positions (expected strategies)
    - Look for clusters NOT near any attractor (emergent discoveries)
    - Clusters at extreme corners (all 0s or all 1s) may be boundary artifacts
    - concept_weights show which dimensions matter most in each cluster
    """
    if generations <= 0:
        generations = _DEFAULTS["generations_explore"]

    # 1. Compile world model → field + influence matrix
    compiler = FieldCompiler(analyzer=_DUMMY_ANALYZER)
    compile_result = compiler.compile_from_world_model(world_model)
    field = compile_result["field"]
    influence_matrix = compile_result["influence_matrix"]

    # 2. Calibrate engine parameters for this field
    calibrator = HomeostasisCalibrator(field, options={
        "target_cells": _DEFAULTS["initial_cell_count"],
        "target_max_pop": _DEFAULTS["max_population"],
    })
    config = calibrator.calibrate(verbose=False)

    # 3. Run colony
    engine = AfsEngine(field, config)
    results = _run_colony(engine, generations)

    # 4. Enrich with compile/calibration metadata
    results["field"] = field.to_dict()
    results["influence_matrix"] = influence_matrix.tolist()
    results["calibration"] = config._calibration
    results["validation"] = compile_result["validation"]

    return results


@mcp.tool()
def afs_compile(world_model: dict) -> dict:
    """
    Compile a world model into a calibrated field without running the colony.

    Use this when you want to inspect the compiled field, modify parameters,
    or validate the world model before committing to a full colony run.
    Follow up with afs-run to execute.

    Most users should use afs-explore instead (full pipeline). Use afs-compile
    + afs-run when you need to tweak calibration or run the same field multiple
    times with different settings.

    Args:
        world_model: World model JSON (same schema as afs-explore).

    Returns:
        Dict with field, config (calibrated engine params you can modify),
        influence_matrix, validation, and calibration metadata.
    """
    # 1. Compile
    compiler = FieldCompiler(analyzer=_DUMMY_ANALYZER)
    compile_result = compiler.compile_from_world_model(world_model)
    field = compile_result["field"]
    influence_matrix = compile_result["influence_matrix"]

    # 2. Calibrate
    calibrator = HomeostasisCalibrator(field, options={
        "target_cells": _DEFAULTS["initial_cell_count"],
        "target_max_pop": _DEFAULTS["max_population"],
    })
    config = calibrator.calibrate(verbose=False)

    return {
        "field": field.to_dict(),
        "config": config.to_dict(),
        "influence_matrix": influence_matrix.tolist(),
        "validation": compile_result["validation"],
        "calibration": config._calibration,
    }


@mcp.tool()
def afs_run(field: dict, config: dict, generations: int = 0) -> dict:
    """
    Run a colony on a pre-compiled field. Use after afs-compile.

    Useful for re-running with modified parameters (e.g., more generations,
    different config values) without recompiling the world model.

    Args:
        field: Serialized FieldDefinition from afs-compile output.
        config: Serialized AfsSimulationConfig from afs-compile output.
                You can modify values before passing them in.
        generations: Colony generations to run (default 20).

    Returns:
        Dict with clusters, stats, and simulation metadata.
        See afs-explore for detailed interpretation guidance.
    """
    if generations <= 0:
        generations = _DEFAULTS["generations_run"]

    field_def = FieldDefinition.from_dict(field)
    sim_config = AfsSimulationConfig.from_dict(config)

    engine = AfsEngine(field_def, sim_config)
    return _run_colony(engine, generations)


@mcp.tool()
async def afs_followup(
    question: str,
    new_options: list = None,
    generations: int = 0,
    ctx: Context | None = None,
) -> dict:
    """
    Follow up on the last afs_decide result — explore deeper or add new options
    without rebuilding the world model from scratch.

    Uses the cached colony from the previous afs_decide call. The existing cells
    stay in their discovered positions, new attractors are injected, energy is
    added, and the colony runs more generations to explore the expanded landscape.

    This is much faster than calling afs_decide again (~2s vs ~10s) because it
    skips world model construction and homeostasis calibration.

    Use this when:
    - The user asks a follow-up question about the same topic
    - The user wants to add a new option ("what about option X?")
    - The user wants to explore deeper ("run more generations")
    - The user asks "what if" variations on the original question

    Do NOT use this when:
    - The topic has completely changed (use afs_decide instead)
    - There was no previous afs_decide call in this conversation

    Args:
        question: The follow-up question or variation.
            Example: "What if I retired at 64 instead?"

        new_options: Optional new options to add to the landscape. Each can be
            a string or dict with factor values (same format as afs_decide options).
            These become new attractors in the existing field.
            Example: [{"name": "Retire at 64", "social_security": 1800}]

        generations: Additional generations to run (default: auto-scaled).
            The colony continues from its current state, not from scratch.

    Returns:
        Same format as afs_decide: strategies, summary, question.
        Includes a "followup" field indicating this built on a previous run.
    """
    if _session["engine"] is None:
        return {"error": "No previous afs_decide session. Call afs_decide first."}

    if generations <= 0:
        generations = _DEFAULTS["generations_decide"] // 3  # shorter for follow-ups

    engine = _session["engine"]
    field = _session["field"]
    world_model = _session["world_model"]
    factor_names = _session["factor_names"]
    options = list(_session["options"])  # copy so we can extend

    _pending_messages = []
    def _progress(gen, total, msg):
        _pending_messages.append((gen, total, msg))
    async def _flush_progress():
        if ctx:
            for gen, total, msg in _pending_messages:
                try:
                    await ctx.info(msg)
                    await ctx.report_progress(float(gen), float(total), msg)
                except Exception:
                    pass
            _pending_messages.clear()

    # Add new options as attractors
    if new_options:
        concepts = world_model.get("concepts", [])
        dim_names = [c["name"] for c in concepts]
        n = field.get_dimension_count()

        for opt in new_options:
            opt_name = opt if isinstance(opt, str) else opt.get("name", f"New Option {len(options)+1}")
            options.append(opt)

            # Build attractor position from provided factor values
            position = np.full(n, 0.5)
            if isinstance(opt, dict):
                for j, fn in enumerate(factor_names):
                    if fn in opt and j < n:
                        dd = concepts[j].get("dataDictionary", {})
                        rng = dd.get("validRange", [0, 100])
                        if rng[1] != rng[0]:
                            position[j] = np.clip((opt[fn] - rng[0]) / (rng[1] - rng[0]), 0.0, 1.0)

            # Add concept to engine
            from ..entities.concept import Concept
            concept = Concept(opt_name, n, position=position)
            concept.energy = 0.5
            engine.concepts[concept.id] = concept

            _progress(0, generations, "Added new option: '{}' at position {}".format(
                opt_name, [round(float(p), 2) for p in position[:4]]))

        await _flush_progress()

    # Inject energy into existing cells (re-energize for exploration)
    for cell in engine.cells.values():
        cell.energy = min(cell.energy + 30, engine.config.cell.max_energy)

    _progress(0, generations, "Continuing colony — {} cells, {} concepts, {} more generations".format(
        len(engine.cells), len(engine.concepts), generations))
    await _flush_progress()

    # Run more generations on the existing colony
    engine.total_ticks = engine.tick_count + generations * 100
    ticks_per_gen = 100

    checkpoints = {int(generations * p): label for p, label in [
        (0.25, "exploring"), (0.50, "midpoint"), (0.75, "converging"), (0.95, "settling"),
    ]}

    for gen in range(1, generations + 1):
        engine.run_generation(ticks=ticks_per_gen)

        if gen in checkpoints and ctx:
            phase = checkpoints[gen]
            pop = len(engine.cells)
            positions = engine.get_cell_positions()
            clusters = detect_clusters(positions)
            _progress(gen, generations, "Gen {}/{} — {} cells, {} outcomes [{}]".format(
                gen, generations, pop, len(clusters), phase))
            await _flush_progress()

    # Detect final clusters and interpret
    results = _serialize_results(engine, generations)

    strategies = _interpret_clusters(
        results["clusters"],
        world_model["concepts"],
        options,
        factor_names,
    )
    strategies.sort(key=lambda s: s["cell_count"], reverse=True)

    # Update session cache
    _session["strategies"] = strategies
    _session["options"] = options

    emergent_count = sum(1 for s in strategies if s["is_emergent"])
    strongest = strategies[0] if strategies else None

    # Spec 24 P1: Derive and store output world model for this follow-up run
    followup_output_wm = output_to_world_model(strategies, source_label=question)
    if followup_output_wm:
        try:
            _store_world_model(followup_output_wm, question, factor_names)
        except Exception as _wm_err:
            print("[2ndOpinion] corpus store failed (non-fatal): {}".format(_wm_err))

    full_result = {
        "question": question,
        "followup": True,
        "original_question": _session["question"],
        "strategies": strategies,
        "output_world_model": followup_output_wm,
        "summary": {
            "total_strategies": len(strategies),
            "emergent_strategies": emergent_count,
            "strongest_outcome": strongest["label"] if strongest else None,
            "strongest_cell_count": strongest["cell_count"] if strongest else 0,
            "total_cells": results["final_population"],
            "generations_run": generations,
            "total_generations_lifetime": engine.generation,
        },
    }

    _session["last_result"] = full_result

    return _compact_response(full_result)


# ─── Share ────────────────────────────────────────────────────────────────────

@mcp.tool()
async def afs_share(
    strategies: list,
    question: str,
    narrative: str = None,
    metadata: dict = None,
    ctx: Context | None = None,
) -> dict:
    """
    Store a completed afs_decide result to MongoDB and return a shareable URL.

    Call this after afs_decide to make the result accessible at a public URL.
    The share document is stored in the afs_shares collection with a 30-day TTL.
    The URL resolves at SHARE_BASE_URL (defaults to https://rezend.ai).

    If MongoDB is unavailable (SHARE_MONGO_URI not set or unreachable), the URL
    is still returned — the result just won't be persisted. Does not crash.

    Args:
        strategies: The strategies list from an afs_decide result.
        question:   The original question passed to afs_decide.
        narrative:  IMPORTANT — pass your full interpretation here: recommendation,
                    key tradeoffs, and any surprising findings. This is what makes
                    the share page valuable. Write it as you would present results
                    to the user — direct, expert, no simulation jargon. 2-3 paragraphs.
        metadata:   Optional dict — ablation_id, sensitivity, or any extra context.

    Returns:
        {
          "url":        "https://rezend.ai/share/<share_id>",
          "share_id":   "<uuid>",
          "expires_at": "<ISO8601 datetime +30 days>"
        }
        Or on validation error:
        {"error": "..."}
    """
    import uuid as _uuid
    import os
    from datetime import datetime as _datetime, timezone as _timezone, timedelta

    if not strategies:
        return {"error": "strategies must be a non-empty list"}

    share_id = str(_uuid.uuid4())
    now = _datetime.now(_timezone.utc)
    expires_at = now + timedelta(days=30)

    base_url = os.environ.get("SHARE_BASE_URL", "https://rezend.ai").rstrip("/")
    url = f"{base_url}/share/{share_id}"

    doc = {
        "share_id": share_id,
        "question": question,
        "strategies": strategies,
        "narrative": narrative or None,
        "expires_at": expires_at,
        "ablation_id": (metadata or {}).get("ablation_id"),
        "sensitivity": (metadata or {}).get("sensitivity"),
        "metadata": metadata,
    }

    try:
        _store_share(doc)
    except Exception as exc:
        logger.warning("[afs_share] MongoDB store failed for %s: %s", share_id, exc)

    print("[2ndOpinion] afs_share — share_id={}, url={}".format(share_id, url))

    return {
        "url": url,
        "share_id": share_id,
        "expires_at": expires_at.isoformat(),
    }
