"""Agent Swarm -- 19 Agents with I-Logic Routing

Each agent occupies a position in Morton space. Its behavior is
determined by its I-Logic state. Tasks are routed via algebraic
composition -- multiply(task_state, agent_state) predicts the outcome
BEFORE execution.

The 19 agents map to the 12-layer architecture:
    L1-L9: 2 agents per internal layer (sensor + actor)
    L10:   1 agent (Identifier -- self-awareness)
    = 19 total

ROUTING PRINCIPLE:
    Task arrives with an I-Logic state (condition).
    The swarm finds the agent whose state RESOLVES the condition:
        condition x agent_state = DIRECT (resolution)
    If no single agent resolves it, compose a multi-agent path.

USAGE:
    from cubesquare.swarm import Swarm

    swarm = Swarm()
    result = swarm.dispatch("authentication module needs refactoring")
    print(result.agent.name, result.i_logic_outcome)

    # Multi-agent workflow
    workflow = swarm.compose_workflow(["navigate", "generate", "validate"])
    print(workflow.path, workflow.final_state)

NO PYTHON LOOPS in routing hot path.
All I-Logic operations are O(1) complex multiplication.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from functools import reduce
from itertools import product as _product
from typing import Dict, List, Optional, Tuple
import cmath
import math

from ._core.constants import PRIME_131, LAYER_BASINS, LAYER_NAMES, K5, K11
from ._core import morton_encode, morton_decode, compute_basin
from .i_logic.states import ILogicState
from .i_logic.algebra import multiply, compose

# FML token type (imported lazily to avoid circular imports)
# dispatch() accepts Optional[FMLToken] for token-guided routing
try:
    from .shells.fml import FMLToken
except ImportError:
    FMLToken = None  # type: ignore


# =========================================================================
# AGENT DEFINITIONS
# =========================================================================

@dataclass
class AgentRole:
    """Definition of an agent's role in the swarm."""
    agent_id: int
    name: str
    layer: int
    layer_name: str
    role: str               # sensor or actor
    specialization: str     # what this agent does
    i_logic_state: complex  # behavioral state
    home_basin: int         # center of this agent's basin range
    basin_range: Tuple[int, int]  # (start, end) basin range
    keywords: List[str] = field(default_factory=list)

    def to_dict(self) -> dict:
        return {
            "id": self.agent_id,
            "name": self.name,
            "layer": self.layer,
            "layer_name": self.layer_name,
            "role": self.role,
            "specialization": self.specialization,
            "i_logic_state": ILogicState.name(self.i_logic_state),
            "home_basin": self.home_basin,
            "basin_range": self.basin_range,
            "keywords": self.keywords,
        }


# The 19 agents: 2 per internal layer (sensor + actor) + 1 external
AGENT_DEFINITIONS: List[dict] = [
    # L1 GEOMETRIC (basins 0-14) -- Structure
    {"id": 1,  "name": "Navigator",     "layer": 1, "role": "sensor",
     "spec": "Finds structures, tree traversal, file discovery",
     "state": ILogicState.DIRECT,
     "keywords": ["find", "search", "locate", "navigate", "explore", "tree", "path"]},
    {"id": 2,  "name": "Architect",     "layer": 1, "role": "actor",
     "spec": "Creates structures, scaffolding, organization",
     "state": ILogicState.DIRECT,
     "keywords": ["create", "structure", "scaffold", "organize", "build", "init"]},

    # L2 SEMANTIC (basins 15-29) -- Meaning
    {"id": 3,  "name": "Semanticist",   "layer": 2, "role": "sensor",
     "spec": "Interprets meaning, clusters concepts, understands context",
     "state": ILogicState.DIRECT,
     "keywords": ["meaning", "concept", "understand", "interpret", "classify"]},
    {"id": 4,  "name": "Translator",    "layer": 2, "role": "actor",
     "spec": "Converts between representations, adapts formats",
     "state": ILogicState.DIRECT,
     "keywords": ["convert", "translate", "transform", "adapt", "format"]},

    # L3 SPATIAL (basins 30-43) -- Location
    {"id": 5,  "name": "Locator",       "layer": 3, "role": "sensor",
     "spec": "Finds positions, spatial queries, proximity detection",
     "state": ILogicState.SPIRAL,
     "keywords": ["where", "position", "location", "nearby", "proximity", "distance"]},
    {"id": 6,  "name": "Coordinator",   "layer": 3, "role": "actor",
     "spec": "Manages spatial relations, coordinates agents",
     "state": ILogicState.SPIRAL,
     "keywords": ["coordinate", "arrange", "place", "distribute", "map"]},

    # L4 FUNCTIONAL (basins 44-58) -- Operation
    {"id": 7,  "name": "Generator",     "layer": 4, "role": "actor",
     "spec": "Creates operations, code generation, implementation",
     "state": ILogicState.DIRECT,
     "keywords": ["generate", "implement", "code", "write", "produce", "make"]},
    {"id": 8,  "name": "Refactor",      "layer": 4, "role": "actor",
     "spec": "Transforms, abstracts, summarizes, simplifies",
     "state": ILogicState.DIRECT,
     "keywords": ["refactor", "simplify", "abstract", "extract", "optimize"]},

    # L5 PROBABILISTIC (basins 59-72) -- Uncertainty
    {"id": 9,  "name": "Validator",     "layer": 5, "role": "sensor",
     "spec": "Tests, verifies, samples, measures confidence",
     "state": ILogicState.ANTI_SPIRAL,
     "keywords": ["test", "verify", "validate", "check", "assert", "prove"]},
    {"id": 10, "name": "Balancer",      "layer": 5, "role": "actor",
     "spec": "Load balancing, Pareto optimization, resource allocation",
     "state": ILogicState.ANTI_SPIRAL,
     "keywords": ["balance", "distribute", "allocate", "optimize", "scale"]},

    # L6 EMOTIONAL (basins 73-87) -- State
    {"id": 11, "name": "Healer",        "layer": 6, "role": "actor",
     "spec": "Repairs states, resolves errors, restores coherence",
     "state": ILogicState.INVERSE,
     "keywords": ["fix", "repair", "heal", "resolve", "restore", "debug"]},
    {"id": 12, "name": "Monitor",       "layer": 6, "role": "sensor",
     "spec": "Observes state, alerts on anomalies, tracks health",
     "state": ILogicState.INVERSE,
     "keywords": ["monitor", "watch", "observe", "track", "alert", "status"]},

    # L7 FREQUENCY (basins 88-101) -- Timing
    {"id": 13, "name": "Scheduler",     "layer": 7, "role": "actor",
     "spec": "Temporal management, sequencing, scheduling",
     "state": ILogicState.SPIRAL,
     "keywords": ["schedule", "sequence", "order", "plan", "timeline", "when"]},
    {"id": 14, "name": "Synchronizer",  "layer": 7, "role": "actor",
     "spec": "Aligns frequencies, beat sync, phase coordination",
     "state": ILogicState.SPIRAL,
     "keywords": ["sync", "align", "phase", "frequency", "rhythm", "timing"]},

    # L8 ELECTROMAGNETIC (basins 102-116) -- Force
    {"id": 15, "name": "Stabilizer",    "layer": 8, "role": "actor",
     "spec": "Maintains coherence, dampens oscillation, error correction",
     "state": ILogicState.ANTI_SPIRAL,
     "keywords": ["stabilize", "dampen", "correct", "steady", "anchor"]},
    {"id": 16, "name": "Amplifier",     "layer": 8, "role": "actor",
     "spec": "Boosts signals, amplifies patterns, strengthens weak signals",
     "state": ILogicState.ANTI_SPIRAL,
     "keywords": ["amplify", "boost", "strengthen", "enhance", "magnify"]},

    # L9 MEMBRANE (basins 117-130) -- Boundary
    {"id": 17, "name": "Guardian",      "layer": 9, "role": "sensor",
     "spec": "Membrane control, access gating, boundary enforcement",
     "state": ILogicState.INVERSE,
     "keywords": ["guard", "protect", "gate", "filter", "boundary", "limit"]},
    {"id": 18, "name": "Transmitter",   "layer": 9, "role": "actor",
     "spec": "Cross-boundary messaging, external interface, bridging",
     "state": ILogicState.INVERSE,
     "keywords": ["transmit", "send", "bridge", "connect", "external", "relay"]},

    # L10 AGENT IDENTITY -- External layer
    {"id": 19, "name": "Identifier",    "layer": 10, "role": "sensor",
     "spec": "Self-awareness, identity verification, swarm introspection",
     "state": ILogicState.DIRECT,
     "keywords": ["identity", "self", "who", "introspect", "meta", "swarm"]},
]


# =========================================================================
# ROUTING — derived from I-Logic algebra + layer domain strings
# =========================================================================
#
# Two routing mechanisms, each with its own source of truth:
#
#   DATA routing:  flip(content) → basin → layer → meaning
#                  The coordinate IS the answer. No words needed.
#
#   TASK routing:  verb(task) → SRL state → agent group  (one word: the verb)
#                  noun(task) → layer domain → specialist (layer description)
#
# SRL_VERBS: 4 sets derived from what each I-Logic state DOES algebraically.
#   DIRECT (1×x=x):       constructive — build, create, generate
#   INVERSE (-1×-1=1):    corrective  — fix, heal, guard, restore
#   SPIRAL (+i rotation):  exploratory — locate, schedule, sync, explore
#   ANTI_SPIRAL (-i contraction): convergent — validate, stabilize, balance
#
# LAYER_TERRITORY: parsed from LAYER_DOMAINS in population.py.
#   L9 domain: "K11 QRS, transmission/reflection, coherence, membrane"
#   → {"k11", "qrs", "transmission", "reflection", "coherence", "membrane"}
#   Nobody wrote those nouns — they're already in the layer definition.
#
# Agent keywords in AGENT_DEFINITIONS are the agent's action identity:
#   the specific verbs that ARE this agent. They route to the I-Logic
#   group via the agent's state, and discriminate WITHIN the group
#   at Pass 2 (specialist selection).
# =========================================================================

# ------ SRL_VERBS: 4 sets from the algebra ---------------------------
#
# Each I-Logic state has a mathematical character:
#   1  (DIRECT)      → identity, passes through → constructive operations
#  -1  (INVERSE)     → reflection, cancels negation → corrective operations
#  +i  (SPIRAL)      → rotation, expands phase space → exploratory operations
#  -i  (ANTI_SPIRAL) → counter-rotation, contracts → convergent operations
#   0  (ABSORBED)    → annihilator, energy transform → termination operations
#
# The verbs below are the universal verbs for each algebraic character.
# They are NOT arbitrary — "fix" is INVERSE because fixing CANCELS a fault
# (×-1 = reflection). "validate" is ANTI_SPIRAL because validation
# CONTRACTS possibility to certainty (-i collapses phase space).

SRL_VERBS: Dict[complex, frozenset] = {
    # 1×x = x : identity pass-through → constructive
    ILogicState.DIRECT: frozenset({
        "create", "build", "generate", "design", "scaffold", "organize",
        "implement", "produce", "write", "code", "emit", "make",
        "interpret", "classify", "cluster", "define", "translate",
        "convert", "encode", "decode", "transform", "adapt", "format",
        "refactor", "optimize", "restructure", "simplify", "extract",
        "rewrite", "improve", "identify", "introspect",
        # structural navigation (L1 DIRECT)
        "navigate", "find", "explore", "traverse", "search", "discover",
    }),
    # -1×-1 = 1 : reflection cancels negation → corrective
    ILogicState.INVERSE: frozenset({
        "fix", "heal", "repair", "restore", "debug", "resolve", "correct",
        "monitor", "watch", "observe", "track", "alert",
        "guard", "protect", "secure", "gate", "filter",
        "transmit", "send", "relay", "broadcast", "bridge",
        # general fault indicators
        "fail", "crash", "wrong",
    }),
    # +i rotation expands phase space → exploratory
    ILogicState.SPIRAL: frozenset({
        "locate", "position", "coordinate", "arrange", "orchestrate",
        "schedule", "sequence", "plan", "order",
        "sync", "align", "synchronize", "tune",
        # spatial/temporal questions
        "where", "map", "discover",
    }),
    # -i contraction collapses phase space → convergent
    ILogicState.ANTI_SPIRAL: frozenset({
        "validate", "verify", "test", "check", "assert", "prove", "confirm",
        "balance", "distribute", "allocate", "scale",
        "stabilize", "dampen", "steady", "anchor",
        "amplify", "boost", "strengthen", "enhance", "magnify",
        # contraction actions
        "remove", "delete", "reduce", "shrink", "compress",
        "trim", "clean", "prune", "minimize", "strip",
    }),
    # 0 annihilates → energy transformation at C=0 Fernandez sphere
    ILogicState.ABSORBED: frozenset({
        "stop", "halt", "pause", "reset", "collapse",
        "transform", "absorb", "origin",
    }),
}

# ALWAYS_DIRECT: verbs from the DIRECT set that are NEVER reclassified.
# These are the constructive/navigational verbs from L1, L2, L4, L10.
# Even if "fix" also appears in the task, a DIRECT verb anchors the state.
ALWAYS_DIRECT: frozenset = SRL_VERBS[ILogicState.DIRECT]


# ------ LAYER_TERRITORY: parsed from LAYER_DOMAINS in population.py ----

def _parse_layer_territory() -> Dict[int, frozenset]:
    """Extract domain nouns from the layer's own description string.

    LAYER_DOMAINS already defines each layer's territory:
      1: ("Structure", "Hierarchy, tree traversal, file navigation, geometry")
      9: ("Boundary",  "K11 QRS, transmission/reflection, coherence, membrane")

    Parse those strings → frozenset of territory nouns per layer.
    No manual word lists. The layer description IS the vocabulary.
    """
    from .population import LAYER_DOMAINS

    _stop = frozenset({
        "and", "the", "a", "of", "in", "to", "for", "from",
        "by", "with", "at", "on", "or", "is",
    })

    return dict(map(
        lambda kv: (
            kv[0],
            frozenset(
                kv[1][1].lower().replace(",", "").replace(
                    "(", "").replace(")", "").replace("/", " ").split()
            ) - _stop | {kv[1][0].lower()},  # include the domain name itself
        ),
        LAYER_DOMAINS.items(),
    ))


LAYER_TERRITORY: Dict[int, frozenset] = _parse_layer_territory()


# ------ SPECIALIST_KEYWORDS: agent keywords + layer territory ----------

def _build_specialist_keywords() -> Dict[str, frozenset]:
    """Each agent's full keyword set = its action verbs + its layer territory.

    Action verbs: from AGENT_DEFINITIONS.keywords (the agent's identity).
    Territory nouns: from LAYER_TERRITORY[agent.layer] (the layer's domain).

    Two agents on the same layer share territory but differ by action.
    """
    return dict(map(
        lambda d: (
            d["name"],
            frozenset(d["keywords"]) | LAYER_TERRITORY.get(min(d["layer"], 9), frozenset()),
        ),
        AGENT_DEFINITIONS,
    ))


SPECIALIST_KEYWORDS: Dict[str, frozenset] = _build_specialist_keywords()


# =========================================================================
# DATA STRUCTURES
# =========================================================================

@dataclass
class DispatchResult:
    """Result of dispatching a task to the swarm."""
    agent: AgentRole
    task_state: complex        # I-Logic state of the task
    outcome: complex           # Single-agent outcome (task x agent)
    outcome_name: str          # Human-readable single-agent outcome
    resolved: bool             # True if DIRECT (single or chained)
    confidence: float          # Keyword match score (0-1)
    reasoning: str             # Why this agent was chosen
    chain: Optional[WorkflowResult] = None  # Multi-agent resolution chain
    physics: Optional[dict] = None  # Content physics (mass, orbit, membrane)

    def to_dict(self) -> dict:
        d = {
            "agent": self.agent.to_dict(),
            "task_state": ILogicState.name(self.task_state),
            "outcome": self.outcome_name,
            "resolved": self.resolved,
            "confidence": round(self.confidence, 3),
            "reasoning": self.reasoning,
        }
        if self.chain:
            d["chain"] = self.chain.to_dict()
        if self.physics:
            d["physics"] = self.physics
        return d


@dataclass
class WorkflowResult:
    """Result of composing a multi-agent workflow."""
    agents: List[AgentRole]
    states: List[complex]
    final_state: complex
    final_state_name: str
    steps: int
    resolved: bool
    path_description: str

    def to_dict(self) -> dict:
        return {
            "agents": list(map(lambda a: a.name, self.agents)),
            "states": list(map(lambda s: ILogicState.name(s), self.states)),
            "final_state": self.final_state_name,
            "steps": self.steps,
            "resolved": self.resolved,
            "path": self.path_description,
        }


# =========================================================================
# SWARM
# =========================================================================

class Swarm:
    """19-agent swarm with I-Logic routing.

    Each agent has:
    - A layer assignment (L1-L10)
    - An I-Logic state (DIRECT, INVERSE, SPIRAL, ANTI_SPIRAL)
    - A home basin (center of its basin range)
    - Keywords for task matching

    Routing uses I-Logic algebraic composition:
        task_state x agent_state -> outcome
        DIRECT outcome = task resolved by this agent

    Usage:
        swarm = Swarm()

        # Dispatch a task
        result = swarm.dispatch("fix the authentication bug")
        print(result.agent.name)  # "Healer" (L6, INVERSE resolves errors)

        # Compose a workflow
        wf = swarm.compose_workflow(["Navigator", "Generator", "Validator"])
        print(wf.final_state_name)  # Composed I-Logic outcome
    """

    def __init__(self):
        self._agents: Dict[int, AgentRole] = {}
        self._name_index: Dict[str, int] = {}
        self._keyword_index: Dict[str, List[int]] = {}
        self._layer_index: Dict[int, List[int]] = {}
        self._registry = None  # ToolRegistry — set via set_registry()
        self._initialize()

    def _initialize(self):
        """Build the agent registry from definitions. No for-loops."""

        def _make_agent(defn):
            layer = defn["layer"]
            if layer <= 9:
                basins = LAYER_BASINS.get(layer, range(0, 15))
                start, end = basins.start, basins.stop - 1
                home = (start + end) // 2
            else:
                start, end, home = 0, 130, 65
            return AgentRole(
                agent_id=defn["id"], name=defn["name"], layer=layer,
                layer_name=LAYER_NAMES.get(layer, f"L{layer}"),
                role=defn["role"], specialization=defn["spec"],
                i_logic_state=defn["state"], home_basin=home,
                basin_range=(start, end), keywords=defn["keywords"],
            )

        def _register(agent):
            self._agents[agent.agent_id] = agent
            self._name_index[agent.name.lower()] = agent.agent_id
            # Keywords → index  (setdefault returns the list, append mutates it)
            tuple(map(
                lambda kw: self._keyword_index.setdefault(kw, []).append(agent.agent_id),
                agent.keywords,
            ))
            # Layer → index
            self._layer_index.setdefault(agent.layer, []).append(agent.agent_id)

        tuple(map(_register, map(_make_agent, AGENT_DEFINITIONS)))

    # -----------------------------------------------------------------
    # CORE API
    # -----------------------------------------------------------------

    def dispatch(self, task_description: str, token=None) -> DispatchResult:
        """Route a task to the best agent via FML encoding + I-Logic algebra.

        THREE MODES — cascading from most to least structured:

        (A) Token-guided routing (token= FMLToken):
            The FML encoder already computed 9 layers of semantic analysis.
            dispatch() READS the token instead of recomputing:
              task_state = token.L4_k5k11.state  (I-Logic from K5×K11)
              task_basin = token.L1_basin         (from Zipf frequency)
            Then finds agents that ALGEBRAICALLY RESOLVE the state:
              task_state × agent_state = DIRECT
            The token did the hard work. dispatch() does the algebra.

        (B) FML + Phi4 routing (no token, default path):
            1. FML-encode the task → 9-layer token with basin/layer/state
            2. Route by basin proximity → agents in same layer domain
            3. Algebraic resolution: task_state × agent_state = ?
            4. If ambiguous (tied agents), Phi4 refines the semantic layer
            Keywords are NOT used. The coordinate IS the routing.

        (C) Verb-based fallback (FML unavailable):
            Pass 1: verb → SRL_VERBS → I-Logic group
            Pass 2: noun → LAYER_TERRITORY → specialist
            Falls back to keyword scoring + basin proximity.

        DISPATCH POOL: L1-L9 agents only.
            Identifier (L10) is self-awareness — it observes the swarm,
            it doesn't process tasks. Excluding it prevents overflow.
        """
        task_terms = set(task_description.lower().split())

        # Dispatch pool: L1-L9 only. Identifier (L10) observes, doesn't act.
        dispatch_pool = list(filter(
            lambda a: a.layer <= 9,
            self._agents.values(),
        ))

        # ---------------------------------------------------------------
        # MODE A: Token-guided routing (FML token provided by caller)
        # ---------------------------------------------------------------
        if token is not None and hasattr(token, 'l4_state') and hasattr(token, 'l1_basin'):
            # Read the token — don't recompute
            task_basin = token.l1_basin
            fml_state_name = token.l4_state  # e.g. 'SPIRAL', 'DIRECT', ...
            task_state = ILogicState.from_name(fml_state_name)
            if task_state is None:
                task_state = ILogicState.DIRECT  # safe fallback

            # Algebraic resolution: find agents where task × agent = DIRECT
            resolvers = list(filter(
                lambda a: multiply(task_state, a.i_logic_state) == ILogicState.DIRECT,
                dispatch_pool,
            ))

            if resolvers:
                # Score resolvers by basin proximity (coordinate math, not keywords)
                best_agent = self._select_by_proximity(resolvers, task_basin)
                best_confidence = self._proximity_score(best_agent, task_basin)
                best_outcome = multiply(task_state, best_agent.i_logic_state)
            else:
                # No direct resolver — pick closest agent by basin, then chain
                best_agent = self._select_by_proximity(dispatch_pool, task_basin)
                best_confidence = self._proximity_score(best_agent, task_basin)
                best_outcome = multiply(task_state, best_agent.i_logic_state)

        # ---------------------------------------------------------------
        # MODE B: FML + Phi4 routing (default — no hardcoded keywords)
        # ---------------------------------------------------------------
        elif (fml_token := self._try_fml_encode(task_description)) is not None:
            task_basin = fml_token.l1_basin
            fml_state_name = fml_token.l4_state
            task_state = ILogicState.from_name(fml_state_name)
            if task_state is None:
                task_state = ILogicState.DIRECT

            # Find agents by algebraic resolution first
            resolvers = list(filter(
                lambda a: multiply(task_state, a.i_logic_state) == ILogicState.DIRECT,
                dispatch_pool,
            ))

            if resolvers:
                # Multiple resolvers: basin proximity decides (math only, no LLM)
                # Ties broken by agent_id for full determinism
                best_agent = self._select_by_proximity(resolvers, task_basin)
                best_confidence = self._proximity_score(best_agent, task_basin) + 1.0  # FML bonus
                best_outcome = multiply(task_state, best_agent.i_logic_state)
            else:
                # No algebraic resolver — route by basin proximity
                best_agent = self._select_by_proximity(dispatch_pool, task_basin)
                best_confidence = self._proximity_score(best_agent, task_basin)
                best_outcome = multiply(task_state, best_agent.i_logic_state)

            # Attach FML token for downstream use
            token = fml_token

        # ---------------------------------------------------------------
        # MODE C: Verb-based fallback (FML unavailable)
        # ---------------------------------------------------------------
        else:
            # Task → structural basin position (simple hash — O(1))
            task_basin = sum(task_description.lower().encode()) % PRIME_131

            # Pass 1: Classify task I-Logic state from keywords
            task_state = self._infer_task_state(task_terms)

            # Pass 1 filter: same I-Logic group, L1-L9 only
            candidates = list(filter(
                lambda a: a.i_logic_state == task_state,
                dispatch_pool,
            ))

            if candidates:
                best_agent = self._select_specialist(task_terms, candidates, task_basin)
                best_confidence = self._specialist_score(task_terms, best_agent, task_basin)
                best_outcome = multiply(task_state, best_agent.i_logic_state)
            else:
                all_scores = sorted(
                    map(lambda a: (self._specialist_score(task_terms, a, task_basin), a),
                        dispatch_pool),
                    key=lambda x: x[0], reverse=True,
                )
                best_agent = all_scores[0][1]
                best_confidence = all_scores[0][0]
                best_outcome = multiply(task_state, best_agent.i_logic_state)

        resolved = best_outcome == ILogicState.DIRECT

        # --- -8th CHANNEL RECOVERY FOR ABSORBED ---
        # When L4 = ABSORBED, the task collapsed to C=0. But L8 (polarity)
        # survives the collapse. The -8th channel is NOT(L8), which provides
        # the exit direction from ABSORBED via double negation: -1 × -1 = +1.
        #
        # This distinguishes:
        #   - Intentional ABSORBED: verb itself encodes ABSORBED → birth event
        #   - Incidental ABSORBED: specificity overwhelmed verb → use -8th exit
        #
        # The -8th channel is the "shadow of all 9 layers" — the inverse of
        # the electromagnetic polarity that stabilizes recovery from C=0.
        minus_8th_agent = None
        if not resolved and task_state == ILogicState.ABSORBED:
            # Check if we have L8 polarity from FML token
            l8_polarity = None
            if token is not None and hasattr(token, 'l8_polarity'):
                l8_polarity = token.l8_polarity

            if l8_polarity is not None:
                # Compute -8th channel: NOT(L8)
                minus_8th_map = {
                    'DIRECT': ILogicState.INVERSE,
                    'INVERSE': ILogicState.DIRECT,
                    'SPIRAL': ILogicState.ANTI_SPIRAL,
                    'ANTI_SPIRAL': ILogicState.SPIRAL,
                }
                minus_8th_state = minus_8th_map.get(l8_polarity, ILogicState.DIRECT)

                # Find agent that resolves -8th state: -8th × agent = DIRECT
                minus_8th_resolvers = list(filter(
                    lambda a: multiply(minus_8th_state, a.i_logic_state) == ILogicState.DIRECT,
                    dispatch_pool,
                ))

                if minus_8th_resolvers:
                    # Use -8th channel exit
                    minus_8th_agent = self._select_by_proximity(minus_8th_resolvers, task_basin)
                    best_agent = minus_8th_agent
                    best_outcome = ILogicState.DIRECT
                    resolved = True
                    best_confidence += 0.5  # -8th channel bonus

        # --- RESOLUTION CHAINING ---
        # When single-agent dispatch can't resolve, compose a multi-agent
        # chain that algebraically cancels the condition to DIRECT.
        # SPIRAL  -> SPIRAL agent (=INVERSE) -> INVERSE agent (=DIRECT)
        # ANTI_SPIRAL -> SPIRAL agent (=DIRECT)  [single-step chain]
        chain = None
        if not resolved and best_outcome != ILogicState.ABSORBED:
            chain = self.find_resolution_path(task_state)
            if chain and chain.resolved:
                resolved = True

        # Build reasoning string
        token_prefix = ""
        if token is not None and hasattr(token, 'l4_state'):
            token_prefix = (
                f"FML: basin={getattr(token, 'l1_basin', '?')}, "
                f"state={getattr(token, 'l4_state', '?')}, "
                f"energy={getattr(token, 'l5_energy', 0):.2f}, "
                f"morton=({getattr(token, 'l3_x', 0)},{getattr(token, 'l3_y', 0)},{getattr(token, 'l3_z', 0)}). "
            )

        if minus_8th_agent is not None:
            # -8th channel recovery from ABSORBED
            l8_pol = getattr(token, 'l8_polarity', '?') if token else '?'
            reasoning = (
                f"{token_prefix}"
                f"L4=ABSORBED, L8={l8_pol}. "
                f"-8th channel: NOT({l8_pol}) -> {best_agent.name}. "
                f"-8th x {ILogicState.name(best_agent.i_logic_state)} = DIRECT. "
                f"Recovered via double negation."
            )
        elif chain and chain.resolved:
            chain_names = " -> ".join(map(lambda a: a.name, chain.agents))
            reasoning = (
                f"{token_prefix}"
                f"Single: {ILogicState.name(task_state)} x "
                f"{ILogicState.name(best_agent.i_logic_state)} = "
                f"{ILogicState.name(best_outcome)}. "
                f"Chain: {chain_names} resolves to DIRECT."
            )
        else:
            reasoning = (
                f"{token_prefix}"
                f"Matched {'resolver' if token is not None else 'keywords'} "
                f"in L{best_agent.layer} {best_agent.layer_name}. "
                f"Task state {ILogicState.name(task_state)} x "
                f"Agent state {ILogicState.name(best_agent.i_logic_state)} = "
                f"{ILogicState.name(best_outcome)}."
            )

        return DispatchResult(
            agent=best_agent,
            task_state=task_state,
            outcome=best_outcome,
            outcome_name=ILogicState.name(best_outcome),
            resolved=resolved,
            confidence=best_confidence,
            reasoning=reasoning,
            chain=chain,
        )

    def dispatch_batch(self, task_descriptions: List[str],
                       tokens: Optional[List] = None) -> List[DispatchResult]:
        """Batch dispatch — one call, all tasks routed.

        No for-loops in the caller. The swarm handles the batch.
        Each dispatch is O(1) I-Logic multiplication.

        Args:
            task_descriptions: List of task descriptions.
            tokens: Optional list of FMLTokens (same length as tasks).
                    If provided, each dispatch uses its corresponding token.

        Returns:
            List of DispatchResult, one per task.
        """
        if tokens is not None:
            return list(map(
                lambda pair: self.dispatch(pair[0], token=pair[1]),
                zip(task_descriptions, tokens),
            ))
        return list(map(self.dispatch, task_descriptions))

    def dispatch_batch_with_physics(self, task_descriptions: List[str],
                                     tokens: Optional[List] = None) -> List[DispatchResult]:
        """Batch dispatch with physics — each task gets content physics.

        Composes: FML encode + dispatch + membrane + content mass + orbit.
        One call, all results. No caller for-loops.

        Args:
            task_descriptions: List of task descriptions.
            tokens: Optional list of FMLTokens (same length as tasks).

        Returns:
            List of DispatchResult with .physics populated on each.
        """
        if tokens is None:
            try:
                from .shells.fml import FMLEncoder
                tokens = FMLEncoder().encode_batch(task_descriptions)
            except ImportError:
                return self.dispatch_batch(task_descriptions)

        return list(map(
            lambda pair: self.dispatch_with_physics(pair[0], token=pair[1]),
            zip(task_descriptions, tokens),
        ))

    def map_field(self, contents: List[str]) -> dict:
        """Map a concept field through the full system — ONE call.

        The system encodes, routes, computes physics, interactions,
        and light positioning. Uses C backend for coordinates.
        Batch APIs for routing. No caller for-loops.

        Composes internally:
          1. FML encode_batch → 12-layer tokens (all computed)
          2. C backend process_batch_raw → native coordinate processing
          3. dispatch_batch → I-Logic agent routing (algebraic)
          4. dispatch_batch_with_physics → content mass, orbit, membrane
          5. WorldShell get_body_state → body physics per token
          6. compute_interaction → pairwise force/resonance/membrane
          7. LightShell lookup → Fernandez sphere positioning

        The output IS the system's computation. Not interpretation.

        Args:
            contents: List of content strings to map.

        Returns:
            Structured dict: tokens, c_backend, routing, physics,
            world (bodies + interactions), light, behavioral_groups.
        """
        from itertools import combinations

        # ── ENCODE: FML batch → 12-layer tokens with weights ──
        try:
            from .shells.fml import FMLEncoder
            tokens = FMLEncoder().encode_batch(contents)
        except ImportError:
            return {"error": "FML encoder unavailable"}

        # ── C BACKEND: batch process coordinates through native loop ──
        from ._core import (process_batch_raw, create_coords_array,
                            NATIVE_AVAILABLE)
        c_backend = None
        if NATIVE_AVAILABLE:
            coords_flat = [v for t in tokens
                           for v in (t.l3_x, t.l3_y, t.l3_z)]
            coords = create_coords_array(coords_flat)
            raw = process_batch_raw(coords, len(tokens))
            if raw:
                c_backend = list(map(lambda i: {
                    'morton_code': raw[i].morton_code,
                    'basin': raw[i].basin,
                    'diagonal': raw[i].diagonal,
                    'i_logic_state': raw[i].i_logic_state,
                    'phi_depth': raw[i].phi_depth,
                    'phi_coefficient': raw[i].phi_coefficient,
                    'fernandez_token': raw[i].fernandez_token,
                    'processing_ns': raw[i].processing_ns,
                }, range(len(tokens))))

        # ── ROUTE: dispatch batch with physics → agent + mass + orbit ──
        routing = self.dispatch_batch_with_physics(contents, tokens=tokens)

        # ── WORLD: body state + pairwise interactions ──
        from .shells.world import get_body_state, \
            compute_interaction as _interact
        hex_ids = list(map(lambda t: t.hex_id, tokens))
        bodies = dict(zip(hex_ids, map(get_body_state, hex_ids)))

        pairs = list(combinations(range(len(tokens)), 2))
        interactions = list(map(
            lambda p: {
                'a': contents[p[0]], 'b': contents[p[1]],
                'hex_a': hex_ids[p[0]], 'hex_b': hex_ids[p[1]],
                **_interact(hex_ids[p[0]], hex_ids[p[1]])
            },
            pairs
        ))

        # ── LIGHT: Fernandez sphere positioning ──
        from .shells.light import (LightShell, basin_to_light_units,
                                   basin_to_c, c_to_ilogic)
        _light = LightShell()
        light = list(map(
            lambda t: {
                'content': t.text,
                'basin': t.l1_basin,
                'light_units': basin_to_light_units(t.l1_basin),
                'c_parameter': basin_to_c(t.l1_basin),
                'i_logic': c_to_ilogic(basin_to_c(t.l1_basin)),
                'lookup': _light.lookup(basin=t.l1_basin),
            },
            tokens
        ))

        # ── FIELD: emergent groups from L6 behavioral physics ──
        committed = list(map(
            lambda i: contents[i],
            filter(lambda i: tokens[i].l6_commitment >= 3,
                   range(len(tokens)))
        ))
        neutral = list(map(
            lambda i: contents[i],
            filter(lambda i: tokens[i].l6_commitment <= 2
                   and tokens[i].l6_stability <= 2,
                   range(len(tokens)))
        ))

        # ── SYNTHESIS: what falls out of the data ──
        co_located = list(filter(
            lambda inter: inter.get('distance', {}).get('value', 999) < 1.0,
            interactions
        ))
        destructive = list(filter(
            lambda inter: inter.get('resonance', {}).get('type') == 'DESTRUCTIVE',
            interactions
        ))
        complementary = list(filter(
            lambda inter: inter.get('resonance', {}).get('type') == 'COMPLEMENTARY',
            interactions
        ))

        # Agent ownership distribution (from routing, no manual counting)
        agent_ownership = {}
        for r in routing:
            name = r.agent.name
            agent_ownership[name] = agent_ownership.get(name, 0) + 1

        return {
            'field_size': len(contents),
            'c_backend': {
                'available': NATIVE_AVAILABLE,
                'results': c_backend,
            },
            'tokens': list(map(lambda t: t.to_dict(), tokens)),
            'routing': list(map(
                lambda pair: {
                    'task': pair[0],
                    'agent': pair[1].agent.name,
                    'agent_id': pair[1].agent.agent_id,
                    'layer': pair[1].agent.layer,
                    'layer_name': pair[1].agent.layer_name,
                    'task_state': ILogicState.name(pair[1].task_state),
                    'outcome': pair[1].outcome_name,
                    'resolved': pair[1].resolved,
                    'confidence': round(pair[1].confidence, 3),
                    'physics': pair[1].physics,
                    'chain': (pair[1].chain.path_description
                              if pair[1].chain and pair[1].chain.resolved
                              else None),
                },
                zip(contents, routing)
            )),
            'world': {
                'bodies': bodies,
                'interactions': interactions,
                'co_located': co_located,
                'destructive': destructive,
                'complementary': complementary,
            },
            'light': light,
            'behavioral_groups': {
                'committed': committed,
                'neutral': neutral,
            },
            'agent_ownership': agent_ownership,
        }

    def dispatch_with_physics(self, task_description: str,
                               token=None) -> DispatchResult:
        """Dispatch a task AND compute its content physics.

        This is the full pipeline:
          1. FML encode → 9-layer token (if not provided)
          2. dispatch() → algebraic agent selection
          3. membrane_load_balance() → T/R ratio for the token's basin
          4. content_mass() → mass from energy × transmission × K11
          5. content_orbit() → epicycle physics from content mass

        The content defines the mass. The mass defines the orbit.
        The orbit defines the resolution speed. The membrane regulates
        the boundary between external depth and internal SRL time.

        EXTERNAL: depth d (iterations the question needs)
        MEMBRANE: t = √d (compression happens here)
        INTERNAL: t steps (what actually executes)

        The membrane absorbs d - √d. That absorbed energy is what
        gets reflected back or transmitted through.

        Returns:
            DispatchResult with .physics populated:
              content_mass, orbital_period, escape_velocity,
              membrane T/R, SRL compression, interpretation.
        """
        # Step 1: Ensure we have an FML token
        if token is None:
            try:
                from .shells.fml import FMLEncoder
                enc = FMLEncoder()
                token = enc.encode(task_description)
            except ImportError:
                # No FML available — fall back to dispatch without physics
                return self.dispatch(task_description)

        # Step 2: Dispatch with token
        result = self.dispatch(task_description, token=token)

        # Step 3: Membrane T/R for this content's basin
        try:
            from .circular.srl_integration import membrane_load_balance, srl_distance
            membrane = membrane_load_balance(token.l1_basin)
            t_ratio = membrane.get("transmission_ratio", 0.5)
            r_ratio = membrane.get("reflection_ratio", 0.5)
        except ImportError:
            t_ratio = 0.5
            r_ratio = 0.5

        # Step 4: Content mass
        try:
            from .shells.mass import compute_content_mass, get_content_mass_data
            content_mass_val = compute_content_mass(token.l5_energy, t_ratio)
            mass_data = get_content_mass_data(
                token.l5_energy, t_ratio, r_ratio,
                token.l1_basin, token.l4_state
            )
        except ImportError:
            content_mass_val = 0.0
            mass_data = {}

        # Step 5: Content orbit (epicycle physics)
        try:
            from .shells.epicycle import create_content_epicycle
            orbit_data = create_content_epicycle(
                content_mass_val, token.l5_energy, t_ratio,
                (token.l3_x, token.l3_y, token.l3_z),
                token.l4_state
            )
        except ImportError:
            orbit_data = {}

        # SRL compression
        depth = max(1, int(token.l5_energy * 25))
        try:
            srl_t = srl_distance(depth)
        except Exception:
            srl_t = int(math.sqrt(depth))

        # Assemble physics
        result.physics = {
            "content_mass": round(content_mass_val, 6),
            "mass_classification": mass_data.get("mass_classification", "UNKNOWN"),
            "membrane": {
                "basin": token.l1_basin,
                "transmission": round(t_ratio, 4),
                "reflection": round(r_ratio, 4),
                "action": "TRANSMIT" if t_ratio > 0.5 else "REFLECT",
            },
            "orbital": orbit_data.get("orbital", {}),
            "resonance": orbit_data.get("resonance", {}),
            "srl": {
                "depth": depth,
                "time": srl_t,
                "compression": f"{depth} iterations → {srl_t} SRL steps",
            },
            "fml": {
                "energy": round(token.l5_energy, 4),
                "l4_state": token.l4_state,
                "l6_emotion": token.l6_state,
                "polarity": token.l8_polarity,
                "wavelength": round(token.l7_wavelength, 2),
            },
            "interpretation": (
                f"Content mass {content_mass_val:.3f} "
                f"({'TRANSMIT' if t_ratio > 0.5 else 'REFLECT'} at L9, "
                f"T={t_ratio:.0%}/R={r_ratio:.0%}). "
                f"{'Heavy content passes through membrane → fast orbit → resolves.'
                   if content_mass_val > K11 * 0.5
                   else 'Light content reflects at membrane → needs more context.'
                   if content_mass_val > 0.01
                   else 'Zero mass at C=0 → no orbit → REFLECT. Content absent.'}"
            ),
        }

        return result

    def compose_workflow(
        self, agent_names: List[str]
    ) -> WorkflowResult:
        """Compose a multi-agent workflow via I-Logic path composition.

        Takes a list of agent names in execution order. Composes their
        I-Logic states using the algebraic compose() function to predict
        the overall workflow outcome.

        Example:
            wf = swarm.compose_workflow(["Navigator", "Generator", "Validator"])
            # DIRECT x DIRECT x INVERSE = INVERSE
            # -> workflow ends in INVERSE state (needs resolution)
        """
        agents = list(filter(None, map(self.get_agent_by_name, agent_names)))
        states = list(map(lambda a: a.i_logic_state, agents))

        if not states:
            return WorkflowResult(
                agents=[], states=[], final_state=ILogicState.ABSORBED,
                final_state_name="ABSORBED", steps=0, resolved=False,
                path_description="No valid agents in workflow",
            )

        # Compose all states (O(N) in agents, O(1) per multiplication)
        final_state, steps_taken = compose(states)
        final_name = ILogicState.name(final_state)

        path_desc = " -> ".join(map(
            lambda a: f"{a.name}:{ILogicState.name(a.i_logic_state)}",
            agents,
        )) + f" = {final_name}"

        return WorkflowResult(
            agents=agents,
            states=states,
            final_state=final_state,
            final_state_name=final_name,
            steps=steps_taken,
            resolved=(final_state == ILogicState.DIRECT),
            path_description=path_desc,
        )

    def find_resolver(self, condition_state: complex) -> Optional[AgentRole]:
        """Find the agent whose state resolves a condition to DIRECT.

        condition x agent_state = DIRECT -> agent is the resolver.

        For INVERSE condition: needs INVERSE agent (INVERSE x INVERSE = DIRECT)
        For SPIRAL condition: needs ANTI_SPIRAL agent (but we don't have one)
                              or compose two SPIRAL agents (SPIRAL x SPIRAL = INVERSE,
                              then INVERSE x INVERSE = DIRECT)
        """
        return next(
            filter(
                lambda a: multiply(condition_state, a.i_logic_state) == ILogicState.DIRECT,
                self._agents.values(),
            ),
            None,
        )

    def find_resolution_path(
        self, condition_state: complex, max_depth: int = 4
    ) -> Optional[WorkflowResult]:
        """Find a multi-agent path that resolves a condition.

        Tries single agents first, then pairs via itertools.product.
        Uses I-Logic composition to predict outcomes algebraically.
        No for-loops — uses next(filter(...)) over agents / product.
        """
        cond = condition_state
        cond_name = ILogicState.name(cond)

        # Try single agents: next agent where cond × agent = DIRECT
        single = next(
            filter(
                lambda a: multiply(cond, a.i_logic_state) == ILogicState.DIRECT,
                self._agents.values(),
            ),
            None,
        )
        if single is not None:
            return WorkflowResult(
                agents=[single], states=[single.i_logic_state],
                final_state=ILogicState.DIRECT, final_state_name="DIRECT",
                steps=1, resolved=True,
                path_description=f"{single.name} resolves {cond_name} to DIRECT",
            )

        # Try pairs: next (a1, a2) where cond × a1 × a2 = DIRECT
        if max_depth >= 2:
            agents_list = list(self._agents.values())
            pair = next(
                filter(
                    lambda p: (
                        multiply(cond, p[0].i_logic_state) != ILogicState.ABSORBED
                        and multiply(
                            multiply(cond, p[0].i_logic_state),
                            p[1].i_logic_state,
                        ) == ILogicState.DIRECT
                    ),
                    _product(agents_list, agents_list),
                ),
                None,
            )
            if pair is not None:
                a1, a2 = pair
                return WorkflowResult(
                    agents=[a1, a2],
                    states=[a1.i_logic_state, a2.i_logic_state],
                    final_state=ILogicState.DIRECT,
                    final_state_name="DIRECT",
                    steps=2, resolved=True,
                    path_description=(
                        f"{a1.name} + {a2.name} resolves "
                        f"{cond_name} to DIRECT"
                    ),
                )

        return None

    # -----------------------------------------------------------------
    # QUERY API
    # -----------------------------------------------------------------

    def get_agent(self, agent_id: int) -> Optional[AgentRole]:
        """Get agent by ID. O(1)."""
        return self._agents.get(agent_id)

    def get_agent_by_name(self, name: str) -> Optional[AgentRole]:
        """Get agent by name (case-insensitive). O(1)."""
        agent_id = self._name_index.get(name.lower())
        if agent_id is not None:
            return self._agents.get(agent_id)
        return None

    def get_agents_for_layer(self, layer: int) -> List[AgentRole]:
        """Get all agents assigned to a layer."""
        ids = self._layer_index.get(layer, [])
        return list(map(lambda i: self._agents[i], ids))

    def get_agents_for_basin(self, basin_id: int) -> List[AgentRole]:
        """Get agents whose basin range contains the given basin."""
        return list(filter(
            lambda a: a.basin_range[0] <= basin_id <= a.basin_range[1],
            self._agents.values(),
        ))

    def list_agents(self) -> List[AgentRole]:
        """List all agents in order."""
        return list(map(lambda i: self._agents[i], sorted(self._agents.keys())))

    @property
    def agent_count(self) -> int:
        return len(self._agents)

    # -----------------------------------------------------------------
    # TOOL REGISTRY BRIDGE
    # -----------------------------------------------------------------

    def set_registry(self, registry) -> None:
        """Connect the spatial ToolRegistry so agents can resolve tools.

        Each agent's home_basin becomes its coordinate into the registry.
        Pre-warms the proximity cache for all 19 agent basins — subsequent
        agent_tools() calls are pure dict access.

        Args:
            registry: ToolRegistry instance (from cubesquare.mcp.tools).
        """
        self._registry = registry
        # Pre-warm _t0_cross for each agent's home_basin.
        # This triggers lazy cache in resolve_by_coupling for unoccupied basins.
        tuple(map(
            lambda a: registry.resolve_by_coupling(a.home_basin, limit=1),
            self._agents.values(),
        ))

    @property
    def has_registry(self) -> bool:
        """Whether a ToolRegistry is connected."""
        return self._registry is not None

    def agent_tools(self, agent_ref, limit: int = 0,
                    t: float = 0.0) -> List[dict]:
        """Get tools ranked by coupling to an agent's home basin.

        agent_ref: agent name (str), agent_id (int), or AgentRole.
        t: time parameter for UIF synchronization.
           t=0 (default): pure spatial proximity (O(1) dict access).
           t!=0: time-modulated coupling phi = K5*K11*cos(w_agent*t)*cos(w_tool*t).
                 Tools resonate differently at different times — the
                 ranking shifts as the system breathes.

        Returns: [{name, basin, layer, hex_id, coupling, same_basin}, ...]
        """
        if self._registry is None:
            return []
        agent = self._resolve_agent(agent_ref)
        if agent is None:
            return []
        return self._registry.resolve_by_coupling(
            agent.home_basin, t=t, limit=limit
        )

    @staticmethod
    def srl_t(depth: int, i_state: complex = ILogicState.DIRECT) -> float:
        """Compute SRL time from depth + I-Logic phase.

        SRL formula: t = sqrt(d)
        I-Logic phase: theta = arg(state) on the unit circle.
            DIRECT      = 1   -> theta = 0
            SPIRAL      = +i  -> theta = pi/2
            INVERSE     = -1  -> theta = pi
            ANTI_SPIRAL = -i  -> theta = 3*pi/2 (-pi/2)

        Combined: t_eff = sqrt(d) + theta

        This is WHY DIRECT and INVERSE agents see opposite couplings
        at the same depth -- they are pi apart. When one sees
        constructive interference, the other sees destructive.
        SPIRAL and ANTI_SPIRAL are also pi apart.

        The I-Logic multiplication table IS the phase rotation table:
          SPIRAL x SPIRAL = i * i = -1 = INVERSE
          ...because pi/2 + pi/2 = pi = INVERSE's angle.

        Args:
            depth: SRL iteration depth (1-625).
            i_state: Agent's I-Logic state (complex number).

        Returns:
            Effective time parameter for resolve_by_coupling.
        """
        t_srl = math.sqrt(max(depth, 0))
        if i_state == ILogicState.ABSORBED or abs(i_state) < 1e-10:
            return t_srl
        theta = cmath.phase(i_state)  # 0, pi/2, pi, -pi/2
        return t_srl + theta

    def agent_tools_at_depth(self, agent_ref, depth: int,
                             limit: int = 0) -> List[dict]:
        """Resolve tools for an agent at SRL depth using its I-Logic phase.

        Combines SRL time (t = sqrt(d)) with the agent's I-Logic angular
        position on the unit circle. The agent's state IS its phase --
        the algebra dictates the coupling.

        DIRECT agents  (phase 0):     t = sqrt(d)
        SPIRAL agents  (phase pi/2):  t = sqrt(d) + pi/2
        INVERSE agents (phase pi):    t = sqrt(d) + pi
        ANTI_SPIRAL    (phase -pi/2): t = sqrt(d) - pi/2

        At depth d=1:
          DIRECT    sees t=1.0      -> certain tools resonate
          INVERSE   sees t=1.0+pi   -> OPPOSITE resonance (pi apart)
          SPIRAL    sees t=1.0+pi/2 -> 90deg shifted resonance
          ANTI_SPIRAL sees t=1.0-pi/2 -> complementary to SPIRAL

        Each agent at L10 (C=0) has this same identity -- both inside
        (L1-L9 home basin) and outside (L10 self-reference). The phase
        is deterministic: the algebra IS the routing.

        Args:
            agent_ref: agent name (str), agent_id (int), or AgentRole.
            depth: SRL iteration depth (1-625).
            limit: Max tools to return (0 = all).

        Returns: [{name, basin, layer, hex_id, coupling, same_basin}, ...]
        """
        if self._registry is None:
            return []
        agent = self._resolve_agent(agent_ref)
        if agent is None:
            return []
        t = self.srl_t(depth, agent.i_logic_state)
        return self._registry.resolve_by_coupling(
            agent.home_basin, t=t, limit=limit
        )

    def agent_l10_identity(self, agent_ref) -> dict:
        """Get an agent's L10 dual identity.

        Every agent exists at TWO positions:
          1. INTERNAL: L1-L9 home basin (layer specialist)
          2. EXTERNAL: L10 Agent Identity at C=0 (self-reference)

        At L10/C=0, the agent is an entity -- it has its own Fernandez
        sphere, its own phase (I-Logic state), and its own coupling
        to every tool. P-P=0 (home), P/P=1 (identity).

        The same math works from both positions. The agent resolves
        tools from its internal basin OR from L10 -- the coupling
        shifts but the algebra is the same.
        """
        agent = self._resolve_agent(agent_ref)
        if agent is None:
            return {"error": f"Unknown agent: {agent_ref}"}

        i_state = agent.i_logic_state
        phase = cmath.phase(i_state) if abs(i_state) > 1e-10 else 0.0

        internal_top = []
        external_top = []
        if self._registry is not None:
            # Internal: from L1-L9 home basin at t=0
            internal_top = self._registry.resolve_by_coupling(
                agent.home_basin, t=0.0, limit=3
            )
            # External: from L10 (basin 65, center) with phase-shifted t
            # At L10 the agent sees the whole system from C=0
            external_top = self._registry.resolve_by_coupling(
                65, t=phase, limit=3
            )

        return {
            "agent": agent.name,
            "id": agent.agent_id,
            "layer_internal": agent.layer,
            "layer_external": 10,
            "home_basin": agent.home_basin,
            "c0_basin": 65,
            "i_logic_state": ILogicState.name(i_state),
            "phase_angle": round(phase, 6),
            "phase_over_pi": round(phase / math.pi, 4) if phase != 0 else 0.0,
            "identity": {
                "P_minus_P": 0,  # home position
                "P_over_P": 1,   # identity
            },
            "internal_top3": list(map(
                lambda t: {"name": t["name"], "basin": t["basin"]},
                internal_top,
            )),
            "external_top3": list(map(
                lambda t: {"name": t["name"], "basin": t["basin"],
                           "coupling": t["coupling"]},
                external_top,
            )),
        }

    def agent_call_tool(self, agent_ref, tool_name: str,
                        command: str = "status",
                        arguments: dict = None) -> dict:
        """Call a specific tool from an agent's coordinate.

        agent_ref: agent name (str), agent_id (int), or AgentRole.
        tool_name: MCP tool name (or None for UIF auto-routing).

        Returns the tool result with _uif coupling metadata.
        """
        if self._registry is None:
            return {"error": "No ToolRegistry connected. Call set_registry() first."}
        agent = self._resolve_agent(agent_ref)
        if agent is None:
            return {"error": f"Unknown agent: {agent_ref}"}
        return self._registry.call_from_coordinate(
            agent.home_basin, command=command,
            arguments=arguments, tool_name=tool_name
        )

    def agent_auto_call(self, agent_ref,
                        command: str = "status",
                        arguments: dict = None) -> dict:
        """Auto-route a call from an agent to its best-coupled tool.

        The agent doesn't pick the tool — the spatial structure does.
        Same-basin tools are tried first (distance 0), then nearest basins.
        """
        if self._registry is None:
            return {"error": "No ToolRegistry connected. Call set_registry() first."}
        agent = self._resolve_agent(agent_ref)
        if agent is None:
            return {"error": f"Unknown agent: {agent_ref}"}
        return self._registry.call_from_coordinate(
            agent.home_basin, command=command,
            arguments=arguments, tool_name=None
        )

    # -----------------------------------------------------------------
    # EXECUTE — Agents do real work through the spatial system
    # -----------------------------------------------------------------

    @staticmethod
    def _infer_command(task: str, agent: AgentRole) -> str:
        """Infer the tool command from the task's verbs.

        The agent decides WHAT to do based on the task's verbs.
        SRL_VERBS classifies verbs by I-Logic state (algebra-derived).
        SPECIALIST_KEYWORDS has per-agent action + territory words.
        Shell handlers fall back to "status" for unknown commands —
        every inferred verb is safe to pass through.

        Priority:
          1. Agent's specialist verbs (most specific — agent expertise)
          2. All SRL verbs (broader — any algebraically classified verb)
          3. "status" (default — introspect the tool)
        """
        terms = set(task.lower().split())

        # 1. Agent's own specialist keywords — most specific match
        agent_kw = SPECIALIST_KEYWORDS.get(agent.name, frozenset())
        agent_verbs = terms & agent_kw
        if agent_verbs:
            return next(iter(agent_verbs))

        # 2. Any verb from SRL_VERBS — algebra-derived broader match
        all_verbs = reduce(lambda acc, v: acc | v, SRL_VERBS.values(), frozenset())
        matched = terms & all_verbs
        if matched:
            return next(iter(matched))

        # 3. Default — the agent introspects the tool
        return "status"

    def execute(self, task: str) -> dict:
        """Dispatch a task AND let the selected agent work.

        Everything is inside C=0. Every agent IS a C=0 from its own
        point of view. The task IS a position in the space. Tools are
        resolved from the TASK's coordinates — not the agent's home —
        because from C=0 the agent can reach everything.

        The flow:
          1. flip(task) → task IS a position (basin, layer, hex)
          2. dispatch() → I-Logic selects the agent (WHO processes)
          3. Resolve tools from the TASK's basin (WHAT to use)
             The task position determines the relevant tool,
             not the agent's home. The agent operates at C=0.
          4. Agent calls the tool from the task's coordinate
          5. If chained, each chain agent also works from C=0

        Why task coordinates, not agent coordinates?
          The agent IS C=0 — it can see all tools equally.
          The task has a SPECIFIC position in the 131-basin space.
          Tools near the task are specialised for that content.
          The agent is the processor. The task is the address.
        """
        if self._registry is None:
            return {"error": "No ToolRegistry. Call set_registry() first."}

        # Task → spatial coordinates (the task IS a position)
        from .shells.flip import flip
        task_record = flip(task, 'text')
        task_basin = task_record.basin

        # I-Logic routing selects the agent (WHO)
        result = self.dispatch(task)
        agent = result.agent

        # Agent decides WHAT to do based on task context
        command = self._infer_command(task, agent)

        # The verb IS the routing.
        # resolve_by_command finds tools that HAVE the inferred command,
        # ranked by proximity to the task basin. No hoping — the system
        # KNOWS which tools support which commands via _command_index.
        task_tools = self._registry.resolve_by_command(task_basin, command, limit=5)

        # Dual view: agent's proximity tools (spatial, not command-filtered)
        agent_tools = self._registry.resolve_by_coupling(agent.home_basin, limit=3)

        # Agent calls the nearest tool that SUPPORTS the command
        tool_name = task_tools[0]['name'] if task_tools else None
        tool_output = (
            self._registry.call_from_coordinate(
                task_basin, command=command, tool_name=tool_name)
            if tool_name else None
        )

        # Build execution trace
        trace = {
            "task": task,
            "task_coordinates": {
                "hex_id": task_record.hex_id,
                "basin": task_basin,
                "layer": task_record.layer,
            },
            "agent": {
                "name": agent.name,
                "id": agent.agent_id,
                "layer": agent.layer,
                "basin": agent.home_basin,
                "state": ILogicState.name(agent.i_logic_state),
            },
            "routing": {
                "task_state": ILogicState.name(result.task_state),
                "outcome": result.outcome_name,
                "resolved": result.resolved,
                "confidence": round(result.confidence, 3),
            },
            "execution": {
                # Tools visible from the task's position
                "task_tools": list(map(
                    lambda t: {"name": t["name"], "basin": t["basin"],
                               "coupling": t["coupling"]},
                    task_tools,
                )),
                # Tools visible from the agent's C=0 (dual view)
                "agent_tools": list(map(
                    lambda t: {"name": t["name"], "basin": t["basin"]},
                    agent_tools,
                )),
                "tool_called": tool_name,
                "command_inferred": command,
                "called_from": "task_basin_%d" % task_basin,
                "result": tool_output,
            },
        }

        # Chain execution: each chain agent also operates from C=0,
        # resolving tools from the task position (not their own).
        if result.chain and result.chain.agents:
            def _exec_chain(a):
                # Chain agent at C=0 — infers its OWN verb from expertise
                # then uses resolve_by_command: the verb IS the routing.
                chain_cmd = self._infer_command(task, a)
                ct = self._registry.resolve_by_command(
                    task_basin, chain_cmd, limit=1)
                cn = ct[0]['name'] if ct else None
                cr = (self._registry.call_from_coordinate(
                    task_basin, command=chain_cmd, tool_name=cn)
                    if cn else None)
                return {"agent": a.name, "basin": a.home_basin,
                        "command": chain_cmd, "tool": cn, "result": cr}
            trace["chain_execution"] = list(map(
                _exec_chain, result.chain.agents
            ))

        return trace

    def execute_batch(self, tasks: list) -> list:
        """Batch execute — each task dispatched and executed.

        Uses map() — no Python for-loops.
        """
        return list(map(self.execute, tasks))

    def _resolve_agent(self, ref) -> Optional[AgentRole]:
        """Resolve an agent from name, id, or AgentRole."""
        if isinstance(ref, AgentRole):
            return ref
        if isinstance(ref, int):
            return self._agents.get(ref)
        if isinstance(ref, str):
            return self.get_agent_by_name(ref)
        return None

    # -----------------------------------------------------------------
    # FML + PHI4 ROUTING (coordinate-based, no hardcoded keywords)
    # -----------------------------------------------------------------

    def _try_fml_encode(self, text: str):
        """FML-encode a task description. Returns FMLToken or None.

        Lazy import — FML is optional. If available, every task gets
        a deterministic 9-layer coordinate (basin, morton, state, energy).
        """
        if not hasattr(self, '_fml_encoder'):
            try:
                from .shells.fml import FMLEncoder
                self._fml_encoder = FMLEncoder()
            except ImportError:
                self._fml_encoder = None
        if self._fml_encoder is None:
            return None
        try:
            return self._fml_encoder.encode(text)
        except Exception:
            return None

    def _phi4_classify_layer(self, text: str) -> Optional[int]:
        """Use Phi4 (via Ollama) to classify which CST layer a task belongs to.

        Returns layer number (1-9) or None if Phi4 unavailable.
        This is the LLM disambiguation step — only called when
        multiple agents are algebraically valid and basin proximity
        produces a tie.
        """
        if not hasattr(self, '_phi4_available'):
            try:
                import ollama
                models = ollama.list()
                if hasattr(models, 'models'):
                    model_names = [m.model.split(':')[0] for m in models.models]
                else:
                    model_names = [m['name'].split(':')[0] for m in models.get('models', [])]
                self._phi4_available = 'phi4-mini' in model_names or 'phi4' in model_names
                self._phi4_model = 'phi4-mini' if 'phi4-mini' in model_names else 'phi4'
            except Exception:
                self._phi4_available = False

        if not self._phi4_available:
            return None

        try:
            import ollama
            response = ollama.chat(
                model=self._phi4_model,
                messages=[{
                    'role': 'system',
                    'content': (
                        "You classify tasks into exactly one CST layer. "
                        "Reply with ONLY the layer number (1-9).\n"
                        "1=Structure/geometry 2=Meaning/semantics 3=Location/spatial "
                        "4=Operations/functions 5=Probability/testing 6=State/emotion "
                        "7=Timing/frequency 8=Force/energy 9=Boundary/membrane"
                    )
                }, {
                    'role': 'user',
                    'content': text[:200]
                }],
                options={'temperature': 0},  # Deterministic output
            )
            answer = response['message']['content'].strip()
            # Extract the first digit 1-9
            for ch in answer:
                if ch.isdigit() and 1 <= int(ch) <= 9:
                    return int(ch)
        except Exception:
            pass
        return None

    def _select_by_proximity(self, candidates: list, task_basin: int):
        """Select the agent closest to the task's basin coordinate.

        Pure math — no keywords. The agent whose home_basin is nearest
        to the task's basin wins. Ties broken by agent_id (deterministic).
        """
        return min(candidates,
                   key=lambda a: (abs(a.home_basin - task_basin), a.agent_id))

    def _proximity_score(self, agent, task_basin: int) -> float:
        """Score an agent by basin proximity. 1.0 = same basin, 0.0 = far.

        Inverse distance normalized to 131 basins.
        """
        distance = abs(agent.home_basin - task_basin)
        return 1.0 / (1.0 + distance / PRIME_131)

    # -----------------------------------------------------------------
    # INTERNAL (legacy keyword fallback)
    # -----------------------------------------------------------------

    def _infer_task_state(self, terms: set) -> complex:
        """Infer I-Logic state from the task's VERBS via SRL_VERBS.

        SRL_VERBS are 4 sets derived from what each I-Logic state
        mathematically does:
          DIRECT (1×x=x):       constructive → build, create, navigate
          INVERSE (-1×-1=1):    corrective  → fix, heal, guard
          SPIRAL (+i rotation):  exploratory → locate, sync, schedule
          ANTI_SPIRAL (-i):      convergent  → validate, stabilize
          ABSORBED (0):          transform   → stop, halt, collapse

        The verbs follow from the algebra — not from intuition.
        ALWAYS_DIRECT guard: DIRECT verbs are never reclassified.
        """
        # ALWAYS_DIRECT guard — constructive/navigational verbs.
        # Never reclassified, even if other words suggest INVERSE/SPIRAL.
        if terms & ALWAYS_DIRECT:
            return ILogicState.DIRECT

        # Score each SRL group by verb overlap
        scores = dict(map(
            lambda sv: (sv[0], len(terms & sv[1])),
            SRL_VERBS.items(),
        ))

        best_state = max(scores, key=scores.get)
        if scores[best_state] == 0:
            return ILogicState.DIRECT
        return best_state

    def _specialist_score(self, task_terms: set, agent: AgentRole, task_basin: int = 65) -> float:
        """Score an agent against task terms using structural matching.

        Two components — both derived from layer mathematics:
          Keyword overlap:  agent action identity + LAYER_TERRITORY nouns  × 2.0
          Basin proximity:  task structural hash → agent home basin       × tiebreaker

        SPECIALIST_KEYWORDS = agent's action verbs ∪ layer territory.
        Territory nouns come from LAYER_DOMAINS descriptions.
        score = overlap * 2.0 + proximity
        """
        specialist_kw = SPECIALIST_KEYWORDS.get(agent.name, frozenset())
        overlap = len(task_terms & specialist_kw)
        # Structural tiebreaker: task basin → agent home basin
        proximity = 1.0 / (1.0 + abs(task_basin - agent.home_basin))
        return overlap * 2.0 + proximity

    def _select_specialist(self, task_terms: set, candidates: List[AgentRole], task_basin: int = 65) -> AgentRole:
        """Within a resolved I-Logic group, pick the best specialist.

        score = keyword_overlap * 2.0 + basin_proximity
        Keywords = agent action identity + LAYER_TERRITORY nouns.
        No for-loop — uses max() with key function.
        """
        return max(candidates, key=lambda a: self._specialist_score(task_terms, a, task_basin))

    def summary(self) -> dict:
        """Swarm summary. No for-loops — reduce + map."""
        state_counts = reduce(
            lambda acc, a: {
                **acc,
                ILogicState.name(a.i_logic_state): acc.get(
                    ILogicState.name(a.i_logic_state), 0
                ) + 1,
            },
            self._agents.values(),
            {},
        )

        return {
            "agent_count": len(self._agents),
            "layers_covered": sorted(set(map(
                lambda a: a.layer, self._agents.values(),
            ))),
            "state_distribution": state_counts,
            "agents": list(map(lambda a: a.to_dict(), self.list_agents())),
        }
