#!/usr/bin/env python3
"""
Basin Control API Server - HTTP wrapper for deterministic LLM inference.

Implements the Basin Control API contract from docs/BASIN_API_CONTRACT.md.

Endpoints:
- POST /v1/chat/completions - Basin-controlled LLM inference
- GET /v1/models - List available models
- GET /health - Health check

Usage:
    uvicorn api.basin_server:app --host 0.0.0.0 --port 8001 --reload

    # Or directly:
    python api/basin_server.py --port 8001
"""

from __future__ import annotations

import argparse
import hashlib
import json
import os
import re
import sqlite3
import sys
import time
import uuid
from contextlib import contextmanager

import numpy as np
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Dict, Generator, List, Optional, Tuple

# WO-BASIN-TELEM-HOOK-01: Runtime telemetry integration
from helix_cdc.runtime.telemetry import Telemetry, TelemetrySinkJSONL

# --- Backend Mode Flag ---
# "cdna_chat" = CDNA proven forward (WO-HF-ORACLE-01 verified)
# "streaming" = CDNAv2 streaming block (Phase A)
# "llama_cpp" = full model inference
# "helixcode_v0" = HelixCode v0 CPU decode (WO-ONEBOX-CLOUD-01)
# "mock" = No model, returns mock responses (for testing geometry routing)
BASIN_BACKEND = os.environ.get("BASIN_BACKEND", "llama_cpp")

from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field, field_validator

# Add project root for imports
PROJECT_ROOT = Path(__file__).parent.parent
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

# Echo Ops router (WO-ECHO-TEAMMATE-01)
from helix_cdc.echo.ops.endpoints import router as echo_ops_router

# Geometry detection for chat routing (WO-ECHO-FIBPI3D-LOOP-02)
# Tool intent detectors (WO-MODULAR-INVENTORY-01)
# Uncertainty detection (WO-ECHO-UNCERTAINTY-DISPATCH)
from helix_cdc.echo.ops.helpers import (
    detect_geometry_intent,
    detect_math_intent,
    detect_retrieval_intent,
    detect_code_intent,
    detect_uncertainty_intent,
    compute_spatial_se,
    generate_receipt_id,
    PROJECT_ROOT as HELPERS_PROJECT_ROOT,
)
from helix_cdc.echo.ops.models import (
    EchoLoopReceipt,
    RouterDecision,
    SeMetrics,
    UncertaintyDispatchReceipt,
    AgentResultModel,
    ROUTER_SCHEMA_VERSION,
)
from helix_cdc.echo.ops.transport_policy import (
    TransportMode,
    TRANSPORT_POLICIES,
    build_router_metadata,
    get_tool_routed_policies,
)

# --- Basin Constants ---
BASIN_SEED_SPACING = 7919  # Prime to minimize basin aliasing
VALID_CARDINALITIES = {2, 4, 8}  # Current support

# --- WO-BASIN-TELEM-HOOK-01: Telemetry Helpers ---


def _sha256_text(s: str) -> str:
    """Compute SHA256 of a string."""
    return hashlib.sha256(s.encode("utf-8", errors="ignore")).hexdigest()


def _utc_ts() -> str:
    """Get UTC timestamp string."""
    return datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")


@contextmanager
def basin_telem_context(
    *,
    model_id: str,
    prompt: str,
    extra_prov: Optional[Dict[str, Any]] = None,
) -> Generator[Tuple[Telemetry, str, str], None, None]:
    """
    Context manager that creates per-request telemetry.

    Guarantees receipts are written even on exceptions.

    Yields:
        (telem, jsonl_path, summary_path)
    """
    os.makedirs("receipts/runtime", exist_ok=True)
    prompt_hash = _sha256_text(prompt)[:16]
    run_id = f"basin_{_utc_ts()}_{prompt_hash}"

    jsonl_path = f"receipts/runtime/{run_id}.jsonl"
    summary_path = f"receipts/runtime/{run_id}.summary.json"

    prov = {
        "run_id": run_id,
        "model_id": model_id,
        "prompt_sha256": _sha256_text(prompt),
        "server_pid": os.getpid(),
        "backend": BASIN_BACKEND,
    }
    if extra_prov:
        prov.update(extra_prov)

    sink = TelemetrySinkJSONL(jsonl_path)
    telem = Telemetry(
        sink=sink,
        snapshot_every_tokens=int(os.getenv("ECHO_TELEM_SNAPSHOT_TOKENS", "128")),
        run_id=run_id,
        provenance=prov,
    )

    t0 = time.perf_counter()
    try:
        yield telem, jsonl_path, summary_path
    finally:
        # Always emit summary + close, even on errors
        wall_time_ms = (time.perf_counter() - t0) * 1000.0
        telem.write_summary(summary_path, wall_time_ms=wall_time_ms)
        telem.close()


# --- Pydantic Models ---

class QuantParams(BaseModel):
    """Basin control parameters."""
    phi0_deg: float = Field(..., description="Phase angle in degrees [0, 360)")
    cardinality: int = Field(..., description="Number of basins {2, 4, 8}")
    control_consent: bool = Field(..., description="Explicit consent for basin control")

    @field_validator('phi0_deg')
    @classmethod
    def validate_phi0(cls, v):
        if v < 0.0 or v >= 360.0:
            raise ValueError("phi0_deg must be in range [0, 360)")
        return v

    @field_validator('cardinality')
    @classmethod
    def validate_cardinality(cls, v):
        if v not in VALID_CARDINALITIES:
            raise ValueError(f"cardinality must be one of {VALID_CARDINALITIES} (current support)")
        return v

    @field_validator('control_consent')
    @classmethod
    def validate_consent(cls, v):
        if v is not True:
            raise ValueError("control_consent must be true (explicit consent required)")
        return v


class Message(BaseModel):
    """Chat message."""
    role: str = Field(..., description="Message role (user)")
    content: str = Field(..., description="Message content")


class ChatCompletionRequest(BaseModel):
    """Basin-controlled chat completion request."""
    model: str = Field(default="echo-basin", description="Model identifier")
    messages: List[Message] = Field(..., description="Chat messages")
    temperature: float = Field(..., description="Must be 0.0 for determinism")
    top_p: float = Field(..., description="Must be 1.0 for determinism")
    max_tokens: int = Field(default=128, ge=1, le=2048, description="Max tokens to generate")
    quant: QuantParams = Field(..., description="Basin control parameters")

    @field_validator('temperature')
    @classmethod
    def validate_temperature(cls, v):
        if v != 0.0:
            raise ValueError("temperature must be 0.0 for deterministic basin control")
        return v

    @field_validator('top_p')
    @classmethod
    def validate_top_p(cls, v):
        if v != 1.0:
            raise ValueError("top_p must be 1.0 for deterministic basin control")
        return v

    @field_validator('messages')
    @classmethod
    def validate_messages(cls, v):
        if not v:
            raise ValueError("messages must have at least one entry")
        return v


class ChatCompletionResponse(BaseModel):
    """Chat completion response."""
    id: str
    object: str = "chat.completion"
    created: int
    model: str
    choices: List[Dict[str, Any]]
    usage: Dict[str, int]
    timing: Optional[Dict[str, float]] = None  # ttft_ms, decode_ms, total_ms, ms_per_token
    streaming_receipts: Optional[List[Dict[str, Any]]] = None  # WO-BASIN-RECEIPTS-CHAT-01
    backend_used: Optional[str] = None  # WO-ONEBOX-CLOUD-01: Which backend processed the request


class ErrorDetail(BaseModel):
    """Error response detail."""
    message: str
    type: str = "invalid_request_error"
    param: Optional[str] = None
    code: str = "invalid_parameter"


class ErrorResponse(BaseModel):
    """Error response."""
    error: ErrorDetail


class ModelInfo(BaseModel):
    """Model information."""
    id: str
    object: str = "model"
    created: int
    owned_by: str
    metadata: Optional[Dict[str, Any]] = None  # Runtime details


class ModelsResponse(BaseModel):
    """Models list response."""
    object: str = "list"
    data: List[ModelInfo]


class HealthResponse(BaseModel):
    """Health check response."""
    status: str
    timestamp: str
    version: str
    model_loaded: bool = False
    warmup_time_ms: float = 0.0
    backend: str = "unknown"


# --- Canonicalization ---

def canonicalize_prompt(text: str) -> str:
    """
    Canonicalize prompt for deterministic seeding.

    Rules:
    1. Strip leading/trailing whitespace
    2. Collapse internal whitespace to single spaces
    3. Normalize newlines to spaces
    4. Remove test tags: [TEST], [DEMO], [DEBUG], [EXAMPLE]
    """
    # Remove test tags
    text = re.sub(r'\[(?:TEST|DEMO|DEBUG|EXAMPLE)\]\s*', '', text, flags=re.IGNORECASE)
    # Normalize whitespace
    text = text.strip()
    text = re.sub(r'\s+', ' ', text)
    return text


def compute_basin_id(phi0_deg: float, cardinality: int) -> int:
    """
    Compute basin ID from phase angle.

    Formula: basin_id = floor((phi0 % 360) * K / 360) % K
    Uses epsilon for numerical stability at boundaries.
    """
    deg = phi0_deg % 360.0
    basin_id = int((deg * cardinality + 1e-9) // 360.0) % cardinality
    return basin_id


def compute_seed(canonical_prompt: str, basin_id: int) -> int:
    """Compute deterministic seed from canonical prompt and basin."""
    prompt_hash = int(hashlib.sha256(canonical_prompt.encode()).hexdigest()[:8], 16)
    return prompt_hash + basin_id * BASIN_SEED_SPACING


# --- FastAPI App ---

app = FastAPI(
    title="Basin Control API",
    description="Deterministic LLM inference with basin control",
    version="1.0.0",
)

# CORS middleware
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Include Echo Ops router (WO-ECHO-TEAMMATE-01)
app.include_router(echo_ops_router)

# Runtime state
_llm_backend = None
_model_loaded = False
_model_path = None
_warmup_time_ms = 0.0
_gpu_info_cache: Optional[Dict[str, Any]] = None

# Policy defaults (env-overridable)
DEFAULT_N_GPU_LAYERS = 8  # Safe for 4GB VRAM with Q4_K_M
DEFAULT_CTX_SIZE = 2048
DEFAULT_THREADS = 8


def get_gpu_info() -> Dict[str, Any]:
    """Get GPU memory info (cached)."""
    global _gpu_info_cache
    if _gpu_info_cache is not None:
        return _gpu_info_cache

    info = {
        "cuda_available": False,
        "device_name": "N/A",
        "vram_total_mb": 0,
        "vram_free_mb": 0,
    }

    try:
        import torch
        if torch.cuda.is_available():
            info["cuda_available"] = True
            info["device_name"] = torch.cuda.get_device_name(0)
            props = torch.cuda.get_device_properties(0)
            info["vram_total_mb"] = props.total_memory // (1024 * 1024)
            # Get current free memory
            free, total = torch.cuda.mem_get_info(0)
            info["vram_free_mb"] = free // (1024 * 1024)
    except (ImportError, RuntimeError):
        pass

    _gpu_info_cache = info
    return info


def get_llm_backend():
    """
    Get or initialize the LLM backend with policy-driven configuration.

    Env overrides:
      ECHO_N_GPU_LAYERS - number of layers to offload to GPU (-1 = all, 0 = CPU only)
      ECHO_CTX_SIZE - context window size
      ECHO_THREADS - CPU threads for inference
      ECHO_MODEL_PATH - path to GGUF model
    """
    global _llm_backend, _model_loaded

    if _llm_backend is not None:
        return _llm_backend

    # Try to import and initialize llama_cpp backend
    try:
        from echo_cog.backend_llama_cpp import get_llama, LlamaCppBackendConfig

        # Policy: env overrides with safe defaults
        model_path = os.environ.get("ECHO_MODEL_PATH") or _model_path or str(
            PROJECT_ROOT / "tmp" / "mistral_canonical_q4_k_m.gguf"
        )
        n_gpu_layers = int(os.environ.get("ECHO_N_GPU_LAYERS", DEFAULT_N_GPU_LAYERS))
        ctx_size = int(os.environ.get("ECHO_CTX_SIZE", DEFAULT_CTX_SIZE))
        threads = int(os.environ.get("ECHO_THREADS", DEFAULT_THREADS))

        if not Path(model_path).exists():
            return None

        # Log policy selection
        gpu_info = get_gpu_info()
        print(f"[Basin] Policy: n_gpu_layers={n_gpu_layers}, ctx={ctx_size}, threads={threads}")
        print(f"[Basin] GPU: {gpu_info['device_name']}, VRAM={gpu_info['vram_total_mb']}MB, free={gpu_info['vram_free_mb']}MB")

        cfg = LlamaCppBackendConfig(
            model_path=model_path,
            ctx_size=ctx_size,
            threads=threads,
            seed=0,  # Will be overridden per-request
            n_gpu_layers=n_gpu_layers,
        )
        _llm_backend = cfg
        _model_loaded = True
        return cfg

    except ImportError:
        return None


def persist_receipt(receipt_id: str, receipt_dict: dict) -> Path:
    """
    Save receipt to receipts/api_chat/<date>/receipt_<id>.json

    Args:
        receipt_id: Deterministic receipt ID (sha256 hash prefix)
        receipt_dict: Full receipt dictionary to persist

    Returns:
        Path to persisted receipt file
    """
    date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    receipt_dir = PROJECT_ROOT / "receipts" / "api_chat" / date_str
    receipt_dir.mkdir(parents=True, exist_ok=True)

    path = receipt_dir / f"receipt_{receipt_id}.json"
    with open(path, "w") as f:
        json.dump(receipt_dict, f, indent=2, default=str)
    return path


def run_cdna_chat_backend(
    prompt: str,
    seed: int,
    max_tokens: int,
    telem: Optional[Telemetry] = None,
) -> tuple[str, dict, dict]:
    """
    CDNA Chat backend - proven forward pass (WO-HF-ORACLE-01 verified).

    Uses numpy-based transformer forward through CDNA tensors.
    Math correctness proven via HuggingFace oracle (logits cosine=0.99999).

    Returns: (result_text, timing_dict, receipt_dict or None)
    """
    from api.backends.cdna_chat import run_cdna_chat, CDNAChatRequest

    # WO-BASIN-TELEM-HOOK-01: Record route
    if telem:
        telem.record_route("cdna_chat")

    # Build request
    req = CDNAChatRequest(
        prompt=prompt,
        max_tokens=max_tokens,
        seed=seed,
        temperature=0.0,  # Deterministic
        depth_mode="AUTO",
    )

    # Run inference
    if telem:
        telem.start_step()

    response = run_cdna_chat(req)

    if telem:
        telem.end_step()
        telem.record_token(response.tokens_generated)
        telem.maybe_snapshot()

    # Map response to Basin return format
    timing_dict = {
        "prompt_process_ms": response.timings.get("init_ms", 0),
        "decode_ms": response.timings.get("inference_ms", 0),
        "total_ms": response.timings.get("total_ms", 0),
        "backend": "cdna_chat",
        "blocks_processed": response.timings.get("blocks_processed", 32),
    }

    # Build receipt dict
    receipt_dict = None
    if response.receipt_path:
        receipt_dict = {
            "receipt_path": response.receipt_path,
            "receipt_sha256": response.receipt_sha256,
            "backend": "cdna_chat",
            "verified": True,  # HF oracle verified
        }

    # Handle errors
    if response.error:
        return f"[CDNA Error: {response.error}]", timing_dict, None

    return response.text, timing_dict, receipt_dict


def run_streaming_inference(
    prompt: str,
    seed: int,
    max_tokens: int,
    canonical_prompt: str = "",
    telem: Optional[Telemetry] = None,  # WO-BASIN-TELEM-HOOK-01
) -> tuple[str, dict, dict]:
    """
    Phase A: Streaming inference with dummy embeddings.
    Proves the wiring works - not real text generation yet.

    WO-BASIN-RECEIPTS-CHAT-01: Returns full receipt for API consumption.
    WO-BASIN-TELEM-HOOK-01: Added telem parameter for runtime telemetry.

    Returns: (result_text, timing_dict, receipt_dict)
    """
    from helix_cdc.regrow.stream_transformer_block import stream_transformer_block_forward

    # WO-BASIN-TELEM-HOOK-01: Record route
    if telem:
        telem.record_route("streaming_cdna2")

    # Deterministic dummy embeddings from seed
    rng = np.random.default_rng(seed)
    seq_len = min(len(prompt) // 4, 16)  # Rough token count proxy
    seq_len = max(seq_len, 1)

    X = rng.standard_normal((1, seq_len, 4096)).astype(np.float32)

    # Run block 0 via streaming (no GGUF weights loaded!)
    manifest_path = str(PROJECT_ROOT / "seeds" / "hybrid_manifest_v2_full_cdna2.json")
    gguf_path = str(PROJECT_ROOT / "tmp" / "rebuilt_from_hybrid_cdna_v2.gguf")

    # WO-BASIN-TELEM-HOOK-01: Use step timer for the forward pass
    if telem:
        telem.start_step()

    start = time.perf_counter()

    output, receipt, _ = stream_transformer_block_forward(
        X=X,
        manifest_path=manifest_path,
        block_index=0,
        d_head=128,
        verify_policy="trust_cached",
        gguf_path=gguf_path,
        telem=telem,  # WO-BASIN-TELEM-HOOK-01: Pass telemetry down
    )

    elapsed_ms = (time.perf_counter() - start) * 1000

    # WO-BASIN-TELEM-HOOK-01: End step and record token
    if telem:
        telem.end_step()
        telem.record_token(1)  # One "forward pass" token
        telem.maybe_snapshot()

    # Generate deterministic receipt_id from canonical prompt and seed
    receipt_id = hashlib.sha256(
        f"{canonical_prompt or prompt}:{seed}:streaming".encode()
    ).hexdigest()[:16]

    # Convert receipt to dict and add receipt_id
    receipt_dict = receipt.to_dict()
    receipt_dict["receipt_id"] = receipt_id

    # Persist to disk
    persist_path = persist_receipt(receipt_id, receipt_dict)
    receipt_dict["persist_path"] = str(persist_path.relative_to(PROJECT_ROOT))

    # Return proof that streaming worked
    result_text = f"STREAM_OK shape={output.shape} block=0"
    timing = {
        "streaming_ms": elapsed_ms,
        "input_shape": list(X.shape),
        "output_shape": list(output.shape),
        "backend": "streaming_cdna2",
        "receipt_status": receipt.status if hasattr(receipt, 'status') else "unknown",
    }

    return result_text, timing, receipt_dict


def run_helixcode_block_forward(
    prompt: str,
    seed: int,
    max_tokens: int,
    canonical_prompt: str = "",
    telem: Optional[Telemetry] = None,  # WO-BASIN-TELEM-HOOK-01
) -> tuple[str, dict, dict | None]:
    """
    WO-HELIX-DECODE-BLOCK-01: Real transformer block via HelixCode.

    Runs block 0 forward pass using all 7 HelixCode projections:
    Q, K, V, O (attention) + gate, up, down (FFN).

    All 7 projections use "decode = compute" - no dense W, no entropy decode.
    WO-BASIN-TELEM-HOOK-01: Added telem parameter for runtime telemetry.

    Returns: (result_text, timing_dict, receipt_dict)
    """
    from helix_cdc.regrow.helixcode_block import helixcode_transformer_block_forward

    # WO-BASIN-TELEM-HOOK-01: Record route
    if telem:
        telem.record_route("helixcode_v0_block")

    # Deterministic input from seed
    rng = np.random.default_rng(seed)
    seq_len = min(len(prompt) // 4, 16)
    seq_len = max(seq_len, 1)

    # Input activations [batch=1, seq, d_model=4096]
    X = rng.standard_normal((1, seq_len, 4096)).astype(np.float32)

    # WO-BASIN-TELEM-HOOK-01: Start step timer
    if telem:
        telem.start_step()

    start = time.perf_counter()

    try:
        # Run real block forward
        output, receipt, _ = helixcode_transformer_block_forward(
            X=X,
            helixcode_dir=PROJECT_ROOT / "seeds" / "helixcode_v0",
            block_index=0,
            d_head=128,  # Mistral head dim
            gguf_path=PROJECT_ROOT / "tmp" / "rebuilt_from_hybrid_cdna_v2.gguf",
            block_rows=256,
            telem=telem,  # WO-BASIN-TELEM-HOOK-01: Pass telemetry down
        )

        elapsed_ms = (time.perf_counter() - start) * 1000

        # WO-BASIN-TELEM-HOOK-01: End step and record token
        if telem:
            telem.end_step()
            telem.record_token(1)  # One "forward pass" token
            telem.maybe_snapshot()

        # Convert receipt to dict
        receipt_dict = receipt.to_dict()
        receipt_dict["receipt_id"] = hashlib.sha256(
            f"{canonical_prompt or prompt}:{seed}:helixcode_block".encode()
        ).hexdigest()[:16]

        # Persist receipt
        persist_path = persist_receipt(receipt_dict["receipt_id"], receipt_dict)
        receipt_dict["persist_path"] = str(persist_path.relative_to(PROJECT_ROOT))

        # Result text
        key_claims = receipt_dict.get("key_claims", {})
        result_text = (
            f"HELIXCODE_BLOCK_OK block=0 "
            f"output_shape={list(output.shape)} "
            f"projections={key_claims.get('passing_projections', '?/7')} "
            f"w_materialized={key_claims.get('w_materialized', True)}"
        )

        timing = {
            "decode_ms": elapsed_ms,
            "total_ms": elapsed_ms,
            "backend": "helixcode_v0_block",
            "block_index": 0,
            "projections_passed": key_claims.get("passing_projections"),
        }

        return result_text, timing, receipt_dict

    except Exception as e:
        elapsed_ms = (time.perf_counter() - start) * 1000
        # WO-BASIN-TELEM-HOOK-01: End step even on error
        if telem:
            telem.end_step()
        return f"[HelixCode block error: {e}]", {
            "backend": "helixcode_v0_block",
            "error": True,
            "decode_ms": elapsed_ms,
        }, None


def run_inference(
    prompt: str,
    seed: int,
    max_tokens: int,
    canonical_prompt: str = "",
    telem: Optional[Telemetry] = None,  # WO-BASIN-TELEM-HOOK-01
) -> tuple[str, dict, dict | None]:
    """
    Run LLM inference with the given seed.

    WO-BASIN-RECEIPTS-CHAT-01: Returns receipt_dict when using streaming backend.
    WO-ONEBOX-CLOUD-01: Added helixcode_v0 backend mode.
    WO-BASIN-TELEM-HOOK-01: Added telem parameter for runtime telemetry.

    Returns: (generated_text, timing_dict, receipt_dict or None)
    """
    # WO-BASIN-TELEM-HOOK-01: Record route choice
    if telem:
        telem.record_route(f"backend:{BASIN_BACKEND}")

    # Route based on backend mode
    if BASIN_BACKEND == "mock":
        # Mock mode: return deterministic response without any model loading
        if telem:
            telem.record_token(1)  # Mock generates 1 "token"
        return f"[Mock response seed={seed}] " + prompt[:50], {
            "prompt_process_ms": 0,
            "decode_ms": 0,
            "total_ms": 0,
            "mock": True,
            "backend": "mock",
        }, None

    if BASIN_BACKEND == "streaming":
        return run_streaming_inference(prompt, seed, max_tokens, canonical_prompt, telem=telem)

    if BASIN_BACKEND == "cdna_chat":
        return run_cdna_chat_backend(prompt, seed, max_tokens, telem=telem)

    if BASIN_BACKEND == "helixcode_v0":
        return run_helixcode_block_forward(prompt, seed, max_tokens, canonical_prompt, telem=telem)

    backend_cfg = get_llm_backend()

    if backend_cfg is None:
        # Fallback: return deterministic mock based on seed
        return f"[Mock response seed={seed}] " + prompt[:50], {
            "prompt_process_ms": 0,
            "decode_ms": 0,
            "total_ms": 0,
            "mock": True,
            "backend": "mock",
        }, None  # No receipt for mock

    from echo_cog.backend_llama_cpp import run_llama_cpp_timed

    # Override seed for this request
    backend_cfg.seed = seed

    result = run_llama_cpp_timed(
        cfg=backend_cfg,
        prompt=prompt,
        max_tokens=max_tokens,
        top_k=1,  # Greedy decoding
    )

    # Add backend to timing
    result["timing"]["backend"] = "llama_cpp_gpu" if backend_cfg.n_gpu_layers > 0 else "llama_cpp_cpu"

    return result["text"], result["timing"], None  # No receipt for llama_cpp


# --- Geometry Chat Handler (WO-ECHO-FIBPI3D-LOOP-02) ---

def _parse_prompt_to_queries(prompt: str, router_decision: dict) -> List[Dict[str, Any]]:
    """
    Parse a natural language prompt into spatial queries.

    This is a simple heuristic parser. For more complex prompts,
    an LLM would be used (but that's optional/gated).
    """
    prompt_lower = prompt.lower()
    queries = []

    # Try to extract object references (obj_XXXX pattern)
    obj_pattern = r'obj_(\d{4})'
    objects = re.findall(obj_pattern, prompt_lower)
    objects = [f"obj_{o}" for o in objects]

    # Determine query type based on keywords
    if "nearest" in prompt_lower or "closest" in prompt_lower:
        # k_nearest query
        k = 5  # Default
        k_match = re.search(r'(\d+)\s*(nearest|closest)', prompt_lower)
        if k_match:
            k = int(k_match.group(1))
        a = objects[0] if objects else "obj_0000"
        queries.append({"query_type": "k_nearest", "a": a, "k": k})

    elif "distance" in prompt_lower or "how far" in prompt_lower:
        # distance query
        if len(objects) >= 2:
            queries.append({"query_type": "distance", "a": objects[0], "b": objects[1]})
        elif len(objects) == 1:
            queries.append({"query_type": "distance", "a": objects[0], "b": "obj_0001"})

    elif "near" in prompt_lower:
        # near query
        threshold = 5.0
        thresh_match = re.search(r'threshold\s*(?:of\s*)?(\d+(?:\.\d+)?)', prompt_lower)
        if thresh_match:
            threshold = float(thresh_match.group(1))
        if len(objects) >= 2:
            queries.append({"query_type": "near", "a": objects[0], "b": objects[1], "threshold": threshold})

    elif "left of" in prompt_lower:
        if len(objects) >= 2:
            queries.append({"query_type": "left_of", "a": objects[0], "b": objects[1]})

    elif "above" in prompt_lower:
        if len(objects) >= 2:
            queries.append({"query_type": "above", "a": objects[0], "b": objects[1]})

    elif "between" in prompt_lower:
        if len(objects) >= 3:
            queries.append({
                "query_type": "between",
                "a": objects[0], "b": objects[1], "c": objects[2],
                "tolerance": 2.0
            })

    # Fallback: if we found objects but no specific query, use k_nearest
    if not queries and objects:
        queries.append({"query_type": "k_nearest", "a": objects[0], "k": 5})

    return queries


def _format_answer(prompt: str, results: List[Dict[str, Any]]) -> str:
    """Format spatial query results into a natural language answer."""
    if not results:
        return "No spatial queries could be executed for this prompt."

    answers = []
    for r in results:
        qt = r.get("query_type", "unknown")
        result = r.get("result")
        error = r.get("error")

        if error:
            answers.append(f"Error: {error}")
            continue

        a = r.get("a", "unknown")
        b = r.get("b")

        if qt == "k_nearest":
            neighbors = result[:5] if isinstance(result, list) else []
            if neighbors:
                neighbor_str = ", ".join([f"{n[0]} ({n[1]:.2f})" for n in neighbors])
                answers.append(f"The nearest objects to {a} are: {neighbor_str}")
            else:
                answers.append(f"No neighbors found for {a}")

        elif qt == "distance":
            if isinstance(result, (int, float)):
                answers.append(f"The distance between {a} and {b} is {result:.4f} units")
            else:
                answers.append(f"Could not compute distance")

        elif qt == "near":
            if isinstance(result, bool):
                status = "are" if result else "are not"
                answers.append(f"{a} and {b} {status} near each other")

        elif qt == "left_of":
            if isinstance(result, bool):
                status = "is" if result else "is not"
                answers.append(f"{a} {status} to the left of {b}")

        elif qt == "above":
            if isinstance(result, bool):
                status = "is" if result else "is not"
                answers.append(f"{a} {status} above {b}")

        elif qt == "between":
            c = r.get("c")
            if isinstance(result, bool):
                status = "is" if result else "is not"
                answers.append(f"{b} {status} between {a} and {c}")

    return " ".join(answers) if answers else "Query completed."


def _compute_chain_hash(prompt_sha: str, router: dict, tool_receipt_id: str, answer_sha: str) -> str:
    """Compute chain hash linking all loop steps."""
    data = f"{prompt_sha}|{json.dumps(router, sort_keys=True)}|{tool_receipt_id}|{answer_sha}"
    return hashlib.sha256(data.encode()).hexdigest()


def _sha256(text: str) -> str:
    """Compute SHA256 of text."""
    return hashlib.sha256(text.encode()).hexdigest()


# =============================================================================
# Uncertainty Dispatch Handler (WO-ECHO-UNCERTAINTY-DISPATCH)
# =============================================================================


def _compute_uncertainty_chain_hash(
    prompt_sha: str,
    uncertainty_type: str,
    agent_results: List[Dict[str, Any]],
    answer_sha: str,
) -> str:
    """Compute chain hash for uncertainty dispatch receipt."""
    # Hash agent results
    agent_hashes = [
        hashlib.sha256(json.dumps(r, sort_keys=True, default=str).encode()).hexdigest()[:16]
        for r in agent_results
    ]
    data = f"{prompt_sha}|{uncertainty_type}|{','.join(agent_hashes)}|{answer_sha}"
    return hashlib.sha256(data.encode()).hexdigest()


def _synthesize_answer_from_agents(
    prompt: str,
    agent_results: List[Dict[str, Any]],
    uncertainty_type: str,
) -> Tuple[str, List[str]]:
    """
    Synthesize a final answer from agent results.

    Returns:
        (answer_text, sources_cited)
    """
    sources = []
    answer_parts = []

    # Collect successful results
    successful_results = [r for r in agent_results if r.get("error") is None]
    failed_results = [r for r in agent_results if r.get("error") is not None]

    if not successful_results and failed_results:
        # All agents failed
        errors = [f"{r['agent_type']}: {r['error']}" for r in failed_results]
        return (
            f"I couldn't find information for your query. Errors: {'; '.join(errors)}",
            [],
        )

    # Process codebase results
    for result in successful_results:
        if result.get("agent_type") == "codebase":
            data = result.get("result", {})
            matches = data.get("matches", [])
            terms = data.get("search_terms", [])

            if matches:
                answer_parts.append(f"Found {len(matches)} matches for: {', '.join(terms)}")
                for match in matches[:5]:
                    file_loc = f"{match['file']}:{match['line']}"
                    sources.append(file_loc)
                    answer_parts.append(f"  - {file_loc}: {match['content'][:80]}...")
            else:
                answer_parts.append(f"No matches found for: {', '.join(terms)}")

            # Add sources from agent
            sources.extend(result.get("sources", []))

    # Process search results (stub will have errors)
    for result in successful_results:
        if result.get("agent_type") == "search":
            data = result.get("result", {})
            if data.get("status") == "stub":
                answer_parts.append(
                    "[Search] Web search not yet implemented. "
                    "For external information, please check documentation manually."
                )

    # Process test agent results (WO-ECHO-TEST-AGENT-01)
    for result in successful_results:
        if result.get("agent_type") == "test":
            data = result.get("result", {})
            status = data.get("status")
            command_argv = data.get("command_argv", [])
            receipt_path = data.get("receipt_path", "")

            if status == "PASS":
                answer_parts.append(f"[Test] Tests passed: {' '.join(command_argv)}")
                if data.get("stdout_preview"):
                    # Show last few lines of output
                    preview_lines = data["stdout_preview"].strip().split("\n")[-5:]
                    answer_parts.append("  Output (last 5 lines):")
                    for line in preview_lines:
                        answer_parts.append(f"    {line[:100]}")
            elif status == "FAIL":
                answer_parts.append(f"[Test] Tests FAILED: {' '.join(command_argv)}")
                if data.get("stderr_preview"):
                    # Show error output
                    preview_lines = data["stderr_preview"].strip().split("\n")[-10:]
                    answer_parts.append("  Errors (last 10 lines):")
                    for line in preview_lines:
                        answer_parts.append(f"    {line[:100]}")
            elif status == "TIMEOUT":
                answer_parts.append(
                    f"[Test] TIMEOUT: {' '.join(command_argv)} exceeded time limit"
                )
            elif status == "REJECTED":
                reason = data.get("reason", "unknown")
                answer_parts.append(f"[Test] REJECTED: {reason}")
            else:
                answer_parts.append(f"[Test] {status}: {' '.join(command_argv)}")

            # Add receipt as source
            if receipt_path:
                sources.append(f"receipt:{receipt_path}")

            # Add sources from agent
            sources.extend(result.get("sources", []))

    # Process patch agent results (WO-ECHO-PATCH-AGENT-01)
    for result in successful_results:
        if result.get("agent_type") == "patch":
            data = result.get("result", {})
            status = data.get("status")
            receipt_path = data.get("receipt_path", "")

            if status == "APPLIED":
                files_modified = data.get("files_modified", [])
                lines_added = data.get("lines_added", 0)
                lines_removed = data.get("lines_removed", 0)
                answer_parts.append(f"[Patch] Applied: {', '.join(files_modified)}")
                answer_parts.append(f"  Changes: +{lines_added}/-{lines_removed} lines")
                answer_parts.append(f"  Pre-test: PASS ({data.get('pre_receipt', 'N/A')})")
                answer_parts.append(f"  Post-test: PASS ({data.get('post_receipt', 'N/A')})")
            elif status == "ROLLBACK":
                restored = data.get("restored_files", [])
                answer_parts.append("[Patch] ROLLED BACK: Post-test failed")
                answer_parts.append(f"  Restored: {', '.join(restored)}")
                answer_parts.append(f"  Post-test receipt: {data.get('post_test_receipt', 'N/A')}")
            elif status == "REJECTED":
                reason = data.get("reason", "unknown")
                answer_parts.append(f"[Patch] REJECTED: {reason}")
            else:
                answer_parts.append(f"[Patch] {status}")

            # Add receipt as source
            if receipt_path:
                sources.append(f"receipt:{receipt_path}")

            # Add sources from agent
            sources.extend(result.get("sources", []))

    # Process plan agent results (WO-ECHO-PLAN-AGENT-01)
    for result in successful_results:
        if result.get("agent_type") == "plan":
            data = result.get("result", {})
            status = data.get("status")
            receipt_path = data.get("receipt_path", "")
            wo_id = data.get("wo_id", "N/A")

            if status == "PASS":
                step_count = data.get("step_count", 0)
                request_type = data.get("request_type", "unknown")
                answer_parts.append(f"[Plan] Work Order {wo_id} generated ({request_type})")
                answer_parts.append(f"  Steps: {step_count}")

                # Show step preview
                steps = data.get("steps", [])
                for step in steps[:5]:  # Show first 5 steps
                    step_num = step.get("step_number", "?")
                    kind = step.get("kind", "?")
                    desc = step.get("description", "")[:60]
                    agent = step.get("agent", "none")
                    answer_parts.append(f"    {step_num}. [{kind}] {desc} → {agent}")

                if len(steps) > 5:
                    answer_parts.append(f"    ... and {len(steps) - 5} more steps")

                # Show warnings if any
                warnings = data.get("warnings", [])
                if warnings:
                    answer_parts.append(f"  Warnings: {len(warnings)}")
                    for w in warnings[:3]:
                        answer_parts.append(f"    - {w[:80]}")

                # Show verification criteria
                verification = data.get("verification", [])
                if verification:
                    answer_parts.append("  Verification:")
                    for v in verification[:3]:
                        answer_parts.append(f"    - {v}")

            elif status == "FAIL":
                answer_parts.append(f"[Plan] Work Order {wo_id} has safety warnings")
                warnings = data.get("warnings", [])
                for w in warnings:
                    answer_parts.append(f"  - {w}")

            elif status == "ERROR":
                error = data.get("error", "unknown error")
                answer_parts.append(f"[Plan] ERROR: {error}")

            else:
                answer_parts.append(f"[Plan] {status}: {wo_id}")

            # Add receipt as source
            if receipt_path:
                sources.append(f"receipt:{receipt_path}")

            # Add sources from agent
            sources.extend(result.get("sources", []))

    # Add note about failed agents
    if failed_results:
        failed_types = [r["agent_type"] for r in failed_results]
        answer_parts.append(f"Note: Some agents failed to respond: {', '.join(failed_types)}")

    # Combine answer
    if not answer_parts:
        return "I processed your query but couldn't generate specific results.", sources

    # Deduplicate sources
    sources = list(dict.fromkeys(sources))

    return "\n".join(answer_parts), sources


async def _handle_uncertainty_dispatch(
    request: "ChatCompletionRequest",
    raw_prompt: str,
    uncertainty_result: Dict[str, Any],
) -> "ChatCompletionResponse":
    """
    Handle uncertainty-detected prompts by dispatching agents.

    WO-ECHO-UNCERTAINTY-DISPATCH: When Echo detects a query that requires
    external information lookup, dispatch specialized agents to gather data.

    Flow:
    1. Get agents for uncertainty type
    2. Dispatch agents in parallel
    3. Collect and synthesize results
    4. Build receipt with provenance
    5. Return response with answer + sources
    """
    import asyncio

    # Import agents module (lazy to avoid circular imports)
    from helix_cdc.echo.agents import (
        AGENT_REGISTRY,
        get_agents_for_uncertainty,
    )
    # Import and register agents
    from helix_cdc.echo.agents import codebase_agent  # noqa: F401
    from helix_cdc.echo.agents import search_agent  # noqa: F401
    from helix_cdc.echo.agents import test_agent  # noqa: F401  WO-ECHO-TEST-AGENT-01
    from helix_cdc.echo.agents import plan_agent  # noqa: F401  WO-ECHO-PLAN-AGENT-01

    start_time = time.time()
    timestamp = datetime.now(timezone.utc)
    errors = []

    # Get uncertainty details
    uncertainty_type = uncertainty_result.get("uncertainty_type", "general")
    suggested_agents = uncertainty_result.get("suggested_agents", ["codebase"])
    aggregate_confidence = uncertainty_result.get("aggregate_other_confidence", 0.0)

    # Get agents to dispatch
    agent_types = get_agents_for_uncertainty(uncertainty_type)

    # Filter to only registered agents
    agents_to_dispatch = [
        (agent_type, AGENT_REGISTRY[agent_type])
        for agent_type in agent_types
        if agent_type in AGENT_REGISTRY
    ]

    if not agents_to_dispatch:
        # No agents available - fall back to stub response
        answer = (
            f"[Uncertainty dispatch] Detected uncertainty type '{uncertainty_type}' "
            f"but no agents are available to handle it."
        )
        agents_dispatched = []
        agent_results_raw = []
    else:
        # Dispatch agents in parallel
        context = {
            "uncertainty_type": uncertainty_type,
            "matched_keywords": uncertainty_result.get("matched_keywords", []),
            "matched_patterns": uncertainty_result.get("matched_patterns", []),
        }

        async def run_agent(agent_type: str, agent) -> Dict[str, Any]:
            try:
                result = await agent.execute(raw_prompt, context)
                return result.to_dict()
            except Exception as e:
                return {
                    "agent_id": f"error-{agent_type}",
                    "agent_type": agent_type,
                    "query": raw_prompt,
                    "result": None,
                    "sources": [],
                    "confidence": 0.0,
                    "elapsed_ms": 0.0,
                    "error": str(e),
                }

        tasks = [run_agent(agent_type, agent) for agent_type, agent in agents_to_dispatch]
        agent_results_raw = await asyncio.gather(*tasks)
        agents_dispatched = [agent_type for agent_type, _ in agents_to_dispatch]

        # Synthesize answer from results
        answer, sources_cited = _synthesize_answer_from_agents(
            raw_prompt, agent_results_raw, uncertainty_type
        )

    # Generate IDs and hashes
    receipt_id = generate_receipt_id()
    prompt_sha = _sha256(raw_prompt)
    answer_sha = _sha256(answer)

    # Compute chain hash
    chain_hash = _compute_uncertainty_chain_hash(
        prompt_sha,
        uncertainty_type,
        agent_results_raw,
        answer_sha,
    )

    # Determine status
    successful_agents = [r for r in agent_results_raw if r.get("error") is None]
    stub_agents = [r for r in agent_results_raw if r.get("result", {}).get("status") == "stub"]

    if not agent_results_raw:
        status = "UNIMPLEMENTED"
        errors.append("No agents were dispatched")
    elif len(successful_agents) == len(agent_results_raw) and not stub_agents:
        status = "PASS"
    elif stub_agents:
        status = "UNIMPLEMENTED"
        errors.extend([f"{r['agent_type']} is a stub" for r in stub_agents])
    else:
        status = "PARTIAL"
        errors.extend([f"{r['agent_type']}: {r['error']}" for r in agent_results_raw if r.get("error")])

    # Build receipt
    sources_cited = []
    for r in agent_results_raw:
        sources_cited.extend(r.get("sources", []))
    sources_cited = list(dict.fromkeys(sources_cited))  # Dedupe

    receipt = UncertaintyDispatchReceipt(
        receipt_id=receipt_id,
        timestamp_utc=timestamp.isoformat(),
        prompt=raw_prompt,
        prompt_sha256=prompt_sha,
        uncertainty_type=uncertainty_type,
        aggregate_confidence=aggregate_confidence,
        matched_keywords=uncertainty_result.get("matched_keywords", []),
        matched_patterns=uncertainty_result.get("matched_patterns", []),
        agents_dispatched=agents_dispatched,
        agent_results=[AgentResultModel(**r) for r in agent_results_raw],
        answer=answer,
        answer_sha256=answer_sha,
        sources_cited=sources_cited,
        chain_hash=chain_hash,
        status=status,
        errors=errors,
    )

    # Save receipt
    receipts_dir = PROJECT_ROOT / "receipts" / "uncertainty_dispatch"
    receipts_dir.mkdir(parents=True, exist_ok=True)

    receipt_filename = f"dispatch_{timestamp.strftime('%Y%m%d_%H%M%S')}_{receipt_id[:8]}.json"
    receipt_path = receipts_dir / receipt_filename

    with open(receipt_path, "w") as f:
        json.dump(receipt.model_dump(), f, indent=2, default=str)

    # Build response
    response_id = f"chatcmpl-uncertainty-{receipt_id[:16]}"
    total_time = time.time() - start_time

    # Build router decision for metadata
    router_decision = {
        "is_uncertainty": True,
        "uncertainty_type": uncertainty_type,
        "matched_keywords": uncertainty_result.get("matched_keywords", []),
        "matched_patterns": uncertainty_result.get("matched_patterns", []),
        "confidence": uncertainty_result.get("confidence", 0.0),
        "suggested_agents": suggested_agents,
    }

    return ChatCompletionResponse(
        id=response_id,
        created=int(timestamp.timestamp()),
        model=request.model,
        choices=[{
            "index": 0,
            "message": {
                "role": "assistant",
                "content": answer,
            },
            "finish_reason": "stop",
            "basin_metadata": build_router_metadata(
                TransportMode.UNCERTAINTY_DISPATCH,
                router_decision,
                {
                    "receipt_ids": [receipt.receipt_id],
                    "chain_hash": chain_hash,
                    "agents_dispatched": agents_dispatched,
                    "sources_cited": sources_cited,
                },
            ),
        }],
        usage={
            "prompt_tokens": len(raw_prompt.split()),
            "completion_tokens": len(answer.split()),
            "total_tokens": len(raw_prompt.split()) + len(answer.split()),
        },
        timing={
            "total_ms": total_time * 1000,
            "decode_ms": 0,
            "ttft_ms": 0,
        },
        streaming_receipts=[receipt.model_dump()],
        backend_used="uncertainty_dispatcher",
    )


# =============================================================================
# Math Chat Handler (WO-MODULAR-INVENTORY-01)
# =============================================================================

def _parse_math_expression(prompt: str) -> Tuple[Optional[str], Optional[str]]:
    """
    Parse a natural language prompt into a math expression.

    Returns:
        (expression, error_message)
    """
    prompt_lower = prompt.lower()

    # Try to extract numeric expressions
    # Pattern: number operator number
    match = re.search(r'(\d+(?:\.\d+)?)\s*([\+\-\*\/\^%])\s*(\d+(?:\.\d+)?)', prompt)
    if match:
        return f"{match.group(1)} {match.group(2)} {match.group(3)}", None

    # Pattern: sqrt(number) or sqrt number
    match = re.search(r'sqrt\s*\(?\s*(\d+(?:\.\d+)?)\s*\)?', prompt_lower)
    if match:
        return f"sqrt({match.group(1)})", None

    # Pattern: X% of Y
    match = re.search(r'(\d+(?:\.\d+)?)\s*%\s*of\s*(\d+(?:\.\d+)?)', prompt_lower)
    if match:
        return f"{match.group(1)}% of {match.group(2)}", None

    # Pattern: factorial
    match = re.search(r'(\d+)\s*(?:factorial|!)', prompt_lower)
    if match:
        return f"{match.group(1)}!", None

    # Pattern: sum of numbers
    match = re.search(r'sum\s+of\s+([\d,\s]+)', prompt_lower)
    if match:
        return f"sum({match.group(1).strip()})", None

    # Pattern: average of numbers
    match = re.search(r'average\s+of\s+([\d,\s]+)', prompt_lower)
    if match:
        return f"average({match.group(1).strip()})", None

    return None, "Could not parse math expression from prompt"


def _evaluate_math(expression: str) -> Tuple[Any, Optional[str]]:
    """
    Safely evaluate a math expression.

    WO-TOOL-ROUTING-HARDEN-01: Replaced eval() with AST-based safe_eval.
    WO-TOOL-ROUTING-HARDEN-02: FAIL-CLOSED - No eval() under any circumstances.
                               If safe_eval is unavailable, return error.

    Returns:
        (result, error_message)
    """
    import math as math_module

    # WO-TOOL-ROUTING-HARDEN-02: Require safe_eval - no fallback to eval()
    try:
        from apps.receipt_chat.app.skills.calc import safe_eval
    except ImportError as e:
        # FAIL-CLOSED: Return error instead of falling back to eval()
        return None, f"Calculator unavailable: safe_eval import failed ({e})"

    try:
        # Handle special operations first (not covered by safe_eval)
        if expression.startswith("sqrt("):
            num = float(expression[5:-1])
            return math_module.sqrt(num), None

        if "% of" in expression:
            match = re.match(r'([\d.]+)% of ([\d.]+)', expression)
            if match:
                pct, val = float(match.group(1)), float(match.group(2))
                return (pct / 100) * val, None

        if expression.endswith("!"):
            num = int(expression[:-1])
            if num > 20:  # Prevent factorial explosion
                return None, "Factorial too large (max 20)"
            return math_module.factorial(num), None

        if expression.startswith("sum("):
            nums = [float(x.strip()) for x in expression[4:-1].split(",") if x.strip()]
            return sum(nums), None

        if expression.startswith("average("):
            nums = [float(x.strip()) for x in expression[8:-1].split(",") if x.strip()]
            return sum(nums) / len(nums) if nums else 0, None

        # WO-TOOL-ROUTING-HARDEN-01: Use AST-based safe_eval instead of eval()
        # WO-TOOL-ROUTING-HARDEN-02: No eval() fallback - safe_eval or error
        # Map ^ to ** for Python
        safe_expr = expression.replace("^", "**")

        # Use AST-based evaluator (no eval())
        result = safe_eval(safe_expr)
        return result, None

    except ValueError as e:
        return None, f"Math error: {e}"
    except Exception as e:
        return None, f"Math error: {e}"


async def _handle_math_chat(
    request: ChatCompletionRequest,
    raw_prompt: str,
    math_result: Dict[str, Any],
) -> ChatCompletionResponse:
    """
    Handle math-detected prompts by routing to calculator tool.

    WO-MODULAR-INVENTORY-01: Wire math routing into chat surface.
    """
    start_time = time.time()
    timestamp = datetime.now(timezone.utc)
    errors = []

    # Build router decision
    router_decision = RouterDecision(
        is_math=math_result["is_math"],
        matched_keywords=math_result["matched_keywords"],
        matched_patterns=math_result["matched_patterns"],
        confidence=math_result["confidence"],
        suggested_tool=math_result["suggested_tool"],
    )

    # Parse and evaluate
    expression, parse_error = _parse_math_expression(raw_prompt)
    if parse_error:
        errors.append(parse_error)
        answer = f"Math intent detected but could not parse expression: {parse_error}"
        result_value = None
    else:
        result_value, eval_error = _evaluate_math(expression)
        if eval_error:
            errors.append(eval_error)
            answer = f"Error evaluating {expression}: {eval_error}"
        else:
            answer = f"The result of {expression} is {result_value}"

    # Generate IDs and hashes
    loop_id = generate_receipt_id()
    tool_receipt_id = generate_receipt_id()
    prompt_sha = _sha256(raw_prompt)
    answer_sha = _sha256(answer)

    # Compute chain hash
    chain_hash = _compute_chain_hash(
        prompt_sha,
        math_result,
        tool_receipt_id,
        answer_sha
    )

    # Build loop receipt
    # WO-TOOL-ROUTING-HARDEN-01: Store hash basis for re-derivable chain hash
    loop_receipt = EchoLoopReceipt(
        loop_id=loop_id,
        timestamp_utc=timestamp.isoformat(),
        work_order="WO-MODULAR-INVENTORY-01",
        router_schema_version=ROUTER_SCHEMA_VERSION,
        prompt=raw_prompt,
        prompt_sha256=prompt_sha,
        router_decision=router_decision,
        router_decision_hash_basis=math_result,  # Raw dict for hash re-derivation
        router_receipt_id=generate_receipt_id(),
        tool_endpoint="/math/calculate",
        tool_request={
            "expression": expression,
            "raw_prompt": raw_prompt,
        },
        tool_response={
            "expression": expression,
            "result": result_value,
            "errors": errors,
        },
        tool_receipt_id=tool_receipt_id,
        tool_scene_hash=_sha256(str(expression)),
        answer=answer,
        answer_sha256=answer_sha,
        chain_hash=chain_hash,
        status="PASS" if not errors else "FAIL",
        errors=errors,
    )

    # Save loop receipt
    receipts_dir = PROJECT_ROOT / "receipts" / "echo_loops"
    receipts_dir.mkdir(parents=True, exist_ok=True)

    receipt_filename = f"math_loop_{timestamp.strftime('%Y%m%d_%H%M%S')}_{loop_id[:8]}.json"
    receipt_path = receipts_dir / receipt_filename

    with open(receipt_path, "w") as f:
        json.dump(loop_receipt.model_dump(), f, indent=2, default=str)

    # Build response
    response_id = f"chatcmpl-math-{loop_id[:16]}"
    total_time = time.time() - start_time

    return ChatCompletionResponse(
        id=response_id,
        created=int(timestamp.timestamp()),
        model=request.model,
        choices=[{
            "index": 0,
            "message": {
                "role": "assistant",
                "content": answer,
            },
            "finish_reason": "stop",
            "basin_metadata": build_router_metadata(
                TransportMode.MATH_ROUTED,
                router_decision.model_dump(),
                {
                    "receipt_ids": [loop_receipt.loop_id],
                    "chain_hash": chain_hash,
                    "expression": expression,
                    "result": result_value,
                },
            ),
        }],
        usage={
            "prompt_tokens": len(raw_prompt.split()),
            "completion_tokens": len(answer.split()),
            "total_tokens": len(raw_prompt.split()) + len(answer.split()),
        },
        timing={
            "total_ms": total_time * 1000,
            "decode_ms": 0,
            "ttft_ms": 0,
        },
        streaming_receipts=[loop_receipt.model_dump()],
        backend_used="math_router",
    )


# =============================================================================
# Retrieval Chat Handler (WO-MODULAR-INVENTORY-01)
# =============================================================================

async def _handle_retrieval_chat(
    request: ChatCompletionRequest,
    raw_prompt: str,
    retrieval_result: Dict[str, Any],
) -> ChatCompletionResponse:
    """
    Handle retrieval-detected prompts by routing to retrieval tool.

    WO-MODULAR-INVENTORY-01: Wire retrieval routing into chat surface.

    Note: This is a stub that returns a placeholder response.
    Real implementation would connect to a vector store or search index.
    """
    start_time = time.time()
    timestamp = datetime.now(timezone.utc)
    errors = []

    # Build router decision
    router_decision = RouterDecision(
        is_retrieval=retrieval_result["is_retrieval"],
        matched_keywords=retrieval_result["matched_keywords"],
        matched_patterns=retrieval_result["matched_patterns"],
        confidence=retrieval_result["confidence"],
        suggested_tool=retrieval_result["suggested_tool"],
    )

    # Stub: Return placeholder response
    # Real implementation would query a vector store or search index
    answer = (
        f"[Retrieval tool stub] Detected search intent with confidence "
        f"{retrieval_result['confidence']:.2f}. "
        f"Keywords matched: {', '.join(retrieval_result['matched_keywords'][:3]) or 'none'}. "
        f"A real implementation would search the knowledge base for: '{raw_prompt[:100]}...'"
    )

    # Generate IDs and hashes
    loop_id = generate_receipt_id()
    tool_receipt_id = generate_receipt_id()
    prompt_sha = _sha256(raw_prompt)
    answer_sha = _sha256(answer)

    # Compute chain hash
    chain_hash = _compute_chain_hash(
        prompt_sha,
        retrieval_result,
        tool_receipt_id,
        answer_sha
    )

    # Build loop receipt
    # WO-TOOL-ROUTING-HARDEN-01: Store hash basis + use UNIMPLEMENTED for stubs
    errors.append("Retrieval tool not yet implemented - stub response")
    loop_receipt = EchoLoopReceipt(
        loop_id=loop_id,
        timestamp_utc=timestamp.isoformat(),
        work_order="WO-MODULAR-INVENTORY-01",
        router_schema_version=ROUTER_SCHEMA_VERSION,
        prompt=raw_prompt,
        prompt_sha256=prompt_sha,
        router_decision=router_decision,
        router_decision_hash_basis=retrieval_result,  # Raw dict for hash re-derivation
        router_receipt_id=generate_receipt_id(),
        tool_endpoint="/retrieval/search",
        tool_request={
            "query": raw_prompt,
            "max_results": 10,
        },
        tool_response={
            "status": "stub",
            "message": "Retrieval tool not yet implemented",
            "results": [],
        },
        tool_receipt_id=tool_receipt_id,
        tool_scene_hash=_sha256(raw_prompt),
        answer=answer,
        answer_sha256=answer_sha,
        chain_hash=chain_hash,
        status="UNIMPLEMENTED",  # WO-TOOL-ROUTING-HARDEN-01: Honest stub status
        errors=errors,
    )

    # Save loop receipt
    receipts_dir = PROJECT_ROOT / "receipts" / "echo_loops"
    receipts_dir.mkdir(parents=True, exist_ok=True)

    receipt_filename = f"retrieval_loop_{timestamp.strftime('%Y%m%d_%H%M%S')}_{loop_id[:8]}.json"
    receipt_path = receipts_dir / receipt_filename

    with open(receipt_path, "w") as f:
        json.dump(loop_receipt.model_dump(), f, indent=2, default=str)

    # Build response
    response_id = f"chatcmpl-retrieval-{loop_id[:16]}"
    total_time = time.time() - start_time

    return ChatCompletionResponse(
        id=response_id,
        created=int(timestamp.timestamp()),
        model=request.model,
        choices=[{
            "index": 0,
            "message": {
                "role": "assistant",
                "content": answer,
            },
            "finish_reason": "stop",
            "basin_metadata": build_router_metadata(
                TransportMode.RETRIEVAL_ROUTED,
                router_decision.model_dump(),
                {
                    "receipt_ids": [loop_receipt.loop_id],
                    "chain_hash": chain_hash,
                    "stub": True,
                },
            ),
        }],
        usage={
            "prompt_tokens": len(raw_prompt.split()),
            "completion_tokens": len(answer.split()),
            "total_tokens": len(raw_prompt.split()) + len(answer.split()),
        },
        timing={
            "total_ms": total_time * 1000,
            "decode_ms": 0,
            "ttft_ms": 0,
        },
        streaming_receipts=[loop_receipt.model_dump()],
        backend_used="retrieval_router",
    )


# =============================================================================
# Code Chat Handler (WO-MODULAR-INVENTORY-01)
# =============================================================================

async def _handle_code_chat(
    request: ChatCompletionRequest,
    raw_prompt: str,
    code_result: Dict[str, Any],
) -> ChatCompletionResponse:
    """
    Handle code-detected prompts by routing to code execution tool.

    WO-MODULAR-INVENTORY-01: Wire code routing into chat surface.

    Note: This is a stub that returns a placeholder response.
    Real implementation would execute code in a sandbox.
    """
    start_time = time.time()
    timestamp = datetime.now(timezone.utc)
    errors = []

    # Build router decision
    router_decision = RouterDecision(
        is_code=code_result["is_code"],
        matched_keywords=code_result["matched_keywords"],
        matched_patterns=code_result["matched_patterns"],
        confidence=code_result["confidence"],
        suggested_tool=code_result["suggested_tool"],
    )

    # Stub: Return placeholder response
    # Real implementation would execute code in a sandbox
    answer = (
        f"[Code tool stub] Detected code execution intent with confidence "
        f"{code_result['confidence']:.2f}. "
        f"Keywords matched: {', '.join(code_result['matched_keywords'][:3]) or 'none'}. "
        f"A real implementation would execute code in a secure sandbox."
    )

    # Generate IDs and hashes
    loop_id = generate_receipt_id()
    tool_receipt_id = generate_receipt_id()
    prompt_sha = _sha256(raw_prompt)
    answer_sha = _sha256(answer)

    # Compute chain hash
    chain_hash = _compute_chain_hash(
        prompt_sha,
        code_result,
        tool_receipt_id,
        answer_sha
    )

    # Build loop receipt
    # WO-TOOL-ROUTING-HARDEN-01: Store hash basis + use UNIMPLEMENTED for stubs
    errors.append("Code execution tool not yet implemented - stub response")
    loop_receipt = EchoLoopReceipt(
        loop_id=loop_id,
        timestamp_utc=timestamp.isoformat(),
        work_order="WO-MODULAR-INVENTORY-01",
        router_schema_version=ROUTER_SCHEMA_VERSION,
        prompt=raw_prompt,
        prompt_sha256=prompt_sha,
        router_decision=router_decision,
        router_decision_hash_basis=code_result,  # Raw dict for hash re-derivation
        router_receipt_id=generate_receipt_id(),
        tool_endpoint="/code/execute",
        tool_request={
            "code": raw_prompt,
            "language": "auto",
        },
        tool_response={
            "status": "stub",
            "message": "Code execution tool not yet implemented",
            "output": None,
        },
        tool_receipt_id=tool_receipt_id,
        tool_scene_hash=_sha256(raw_prompt),
        answer=answer,
        answer_sha256=answer_sha,
        chain_hash=chain_hash,
        status="UNIMPLEMENTED",  # WO-TOOL-ROUTING-HARDEN-01: Honest stub status
        errors=errors,
    )

    # Save loop receipt
    receipts_dir = PROJECT_ROOT / "receipts" / "echo_loops"
    receipts_dir.mkdir(parents=True, exist_ok=True)

    receipt_filename = f"code_loop_{timestamp.strftime('%Y%m%d_%H%M%S')}_{loop_id[:8]}.json"
    receipt_path = receipts_dir / receipt_filename

    with open(receipt_path, "w") as f:
        json.dump(loop_receipt.model_dump(), f, indent=2, default=str)

    # Build response
    response_id = f"chatcmpl-code-{loop_id[:16]}"
    total_time = time.time() - start_time

    return ChatCompletionResponse(
        id=response_id,
        created=int(timestamp.timestamp()),
        model=request.model,
        choices=[{
            "index": 0,
            "message": {
                "role": "assistant",
                "content": answer,
            },
            "finish_reason": "stop",
            "basin_metadata": build_router_metadata(
                TransportMode.CODE_ROUTED,
                router_decision.model_dump(),
                {
                    "receipt_ids": [loop_receipt.loop_id],
                    "chain_hash": chain_hash,
                    "stub": True,
                },
            ),
        }],
        usage={
            "prompt_tokens": len(raw_prompt.split()),
            "completion_tokens": len(answer.split()),
            "total_tokens": len(raw_prompt.split()) + len(answer.split()),
        },
        timing={
            "total_ms": total_time * 1000,
            "decode_ms": 0,
            "ttft_ms": 0,
        },
        streaming_receipts=[loop_receipt.model_dump()],
        backend_used="code_router",
    )


async def _handle_geometry_chat(
    request: ChatCompletionRequest,
    raw_prompt: str,
    geometry_result: Dict[str, Any],
) -> ChatCompletionResponse:
    """
    Handle geometry-detected prompts by routing to spatial tool.

    WO-ECHO-FIBPI3D-LOOP-02: Wire geometry routing into chat surface.

    1. Creates scene if needed
    2. Parses prompt to queries
    3. Executes spatial queries
    4. Formats answer
    5. Builds loop receipt with chain hash
    6. Returns ChatCompletionResponse with answer + receipt
    """
    from helix_cdc.echo.ops.spatial_memory import get_manager

    start_time = time.time()
    timestamp = datetime.now(timezone.utc)
    errors = []

    # Build router decision model
    router_decision = RouterDecision(
        is_geometry=geometry_result["is_geometry"],
        matched_keywords=geometry_result["matched_keywords"],
        matched_patterns=geometry_result["matched_patterns"],
        confidence=geometry_result["confidence"],
        suggested_tool=geometry_result["suggested_tool"],
    )

    # Parse prompt to queries
    queries = _parse_prompt_to_queries(raw_prompt, geometry_result)

    # If no queries could be parsed, return a fallback response
    if not queries:
        response_id = f"chatcmpl-geo-{uuid.uuid4().hex[:16]}"
        return ChatCompletionResponse(
            id=response_id,
            created=int(timestamp.timestamp()),
            model=request.model,
            choices=[{
                "index": 0,
                "message": {
                    "role": "assistant",
                    "content": "Geometry intent detected but could not parse specific queries from prompt.",
                },
                "finish_reason": "stop",
                "basin_metadata": {
                    "transport_mode": "geometry_routed",
                    "router_decision": router_decision.model_dump(),
                    "parse_error": True,
                },
            }],
            usage={"prompt_tokens": len(raw_prompt.split()), "completion_tokens": 10, "total_tokens": len(raw_prompt.split()) + 10},
            backend_used="geometry_router",
        )

    # Get spatial memory manager
    mgr = get_manager()

    # Scene ID from request or default
    scene_id = "geometry_chat"

    # Get or create scene
    scene = mgr.get_scene(scene_id)
    if scene is None:
        scene = mgr.create_scene(
            scene_id=scene_id,
            n_objects=100,
            distribution="uniform",
            bounds=(-10.0, 10.0),
            seed=scene_id,
        )

    # Execute queries
    query_results = []
    for q in queries:
        result = mgr.query(
            scene_id=scene_id,
            query_type=q.get("query_type", "k_nearest"),
            a=q.get("a", "obj_0000"),
            b=q.get("b"),
            c=q.get("c"),
            k=q.get("k", 5),
            threshold=q.get("threshold", 5.0),
            tolerance=q.get("tolerance", 2.0),
        )
        query_results.append(result)
        if result.get("error"):
            errors.append(result["error"])

    # Format answer
    answer = _format_answer(raw_prompt, query_results)

    # Generate IDs and hashes
    loop_id = generate_receipt_id()
    tool_receipt_id = generate_receipt_id()
    prompt_sha = _sha256(raw_prompt)
    answer_sha = _sha256(answer)

    # Compute chain hash
    chain_hash = _compute_chain_hash(
        prompt_sha,
        geometry_result,
        tool_receipt_id,
        answer_sha
    )

    # Compute Se metrics
    se_data = compute_spatial_se(
        n_objects=len(scene.objects),
        n_queries=len(queries),
        query_results=query_results,
    )
    se_metrics = SeMetrics(
        H=se_data["H"],
        C=se_data["C"],
        D=se_data["D"],
        Se=se_data["Se"],
        regime=se_data["regime"],
        classification_mode=se_data["classification_mode"],
    )

    # Build loop receipt
    # WO-TOOL-ROUTING-HARDEN-01: Store hash basis for re-derivable chain hash
    loop_receipt = EchoLoopReceipt(
        loop_id=loop_id,
        timestamp_utc=timestamp.isoformat(),
        work_order="WO-ECHO-FIBPI3D-LOOP-02",
        prompt=raw_prompt,
        prompt_sha256=prompt_sha,
        router_decision=router_decision,
        router_decision_hash_basis=geometry_result,  # Raw dict for hash re-derivation
        router_receipt_id=generate_receipt_id(),
        tool_endpoint="/echo/spatial",
        tool_request={
            "scene_id": scene_id,
            "queries": queries,
        },
        tool_response={
            "scene_id": scene_id,
            "scene_hash": scene.scene_hash,
            "n_objects": len(scene.objects),
            "results": query_results,
        },
        tool_receipt_id=tool_receipt_id,
        tool_scene_hash=scene.scene_hash,
        answer=answer,
        answer_sha256=answer_sha,
        chain_hash=chain_hash,
        se_metadata=se_metrics,
        status="PASS" if not errors else "FAIL",
        errors=errors,
    )

    # Save loop receipt
    receipts_dir = PROJECT_ROOT / "receipts" / "echo_loops"
    receipts_dir.mkdir(parents=True, exist_ok=True)

    receipt_filename = f"chat_loop_{timestamp.strftime('%Y%m%d_%H%M%S')}_{loop_id[:8]}.json"
    receipt_path = receipts_dir / receipt_filename

    with open(receipt_path, "w") as f:
        json.dump(loop_receipt.model_dump(), f, indent=2, default=str)

    # Build response
    response_id = f"chatcmpl-geo-{loop_id[:16]}"
    total_time = time.time() - start_time

    return ChatCompletionResponse(
        id=response_id,
        created=int(timestamp.timestamp()),
        model=request.model,
        choices=[{
            "index": 0,
            "message": {
                "role": "assistant",
                "content": answer,
            },
            "finish_reason": "stop",
            "basin_metadata": {
                "transport_mode": "geometry_routed",
                "receipt_ids": [loop_receipt.loop_id],
                "router_decision": router_decision.model_dump(),
                "scene_hash": scene.scene_hash,
                "chain_hash": chain_hash,
            },
        }],
        usage={
            "prompt_tokens": len(raw_prompt.split()),
            "completion_tokens": len(answer.split()),
            "total_tokens": len(raw_prompt.split()) + len(answer.split()),
        },
        timing={
            "total_ms": total_time * 1000,
            "decode_ms": 0,  # No LLM decode for geometry
            "ttft_ms": 0,
        },
        streaming_receipts=[loop_receipt.model_dump()],
        backend_used="geometry_router",
    )


# =============================================================================
# WO-TOOL-ROUTING-HARDEN-02: Handler Registry for Table-Driven Routing
# =============================================================================

# Handler registry: mode -> (handler_fn, detector_fn)
# Enables table-driven routing instead of if-else cascade
TOOL_HANDLER_REGISTRY: Dict[TransportMode, Tuple[Callable, Callable]] = {}


def register_tool_handler(
    mode: TransportMode,
    handler: Callable,
    detector: Callable,
) -> None:
    """
    Register a tool handler for a transport mode.

    WO-TOOL-ROUTING-HARDEN-02: Table-driven routing - add tool = add config entry.

    Args:
        mode: The transport mode this handler serves
        handler: Async function (request, raw_prompt, detector_result) -> response
        detector: Function (prompt) -> detection result dict
    """
    TOOL_HANDLER_REGISTRY[mode] = (handler, detector)


# Register all tool handlers
# Order doesn't matter - routing uses policy priority from transport_policy.py
# WO-ECHO-UNCERTAINTY-DISPATCH: Register FIRST (priority 5)
register_tool_handler(TransportMode.UNCERTAINTY_DISPATCH, _handle_uncertainty_dispatch, detect_uncertainty_intent)

register_tool_handler(TransportMode.GEOMETRY_ROUTED, _handle_geometry_chat, detect_geometry_intent)
register_tool_handler(TransportMode.MATH_ROUTED, _handle_math_chat, detect_math_intent)
register_tool_handler(TransportMode.RETRIEVAL_ROUTED, _handle_retrieval_chat, detect_retrieval_intent)
register_tool_handler(TransportMode.CODE_ROUTED, _handle_code_chat, detect_code_intent)


async def _route_to_tool(
    request: "ChatCompletionRequest",
    raw_prompt: str,
) -> Optional["ChatCompletionResponse"]:
    """
    Route to tool based on policy priority.

    WO-TOOL-ROUTING-HARDEN-02: Table-driven routing replaces if-else cascade.

    Iterates through tool-routed policies in priority order, checking each
    detector. First match with sufficient confidence triggers the handler.

    Args:
        request: The chat completion request
        raw_prompt: Raw user prompt text

    Returns:
        ChatCompletionResponse if routed to tool, None to fall through to LLM
    """
    for policy in get_tool_routed_policies():
        if policy.mode not in TOOL_HANDLER_REGISTRY:
            continue

        handler, detector = TOOL_HANDLER_REGISTRY[policy.mode]
        result = detector(raw_prompt)

        # Determine intent key based on mode (e.g., "is_geometry" for GEOMETRY_ROUTED)
        # Extract mode name for intent key (e.g., "geometry" from "geometry_routed")
        # For uncertainty_dispatch -> is_uncertainty
        mode_name = policy.mode.value.replace("_routed", "").replace("_dispatch", "")
        intent_key = f"is_{mode_name}"

        # Route if intent detected with sufficient confidence or pattern match
        if result.get(intent_key) and (
            result.get("confidence", 0) > 0.5 or result.get("matched_patterns")
        ):
            return await handler(request, raw_prompt, result)

    return None  # Fall through to LLM


# --- Startup Events ---

@app.on_event("startup")
async def warmup_model():
    """Pre-load model at server startup to eliminate cold-start latency."""
    global _warmup_time_ms, _model_loaded
    import time

    print(f"[Basin] Backend mode: {BASIN_BACKEND}")

    # Mock backend: no model loading, for testing geometry routing
    if BASIN_BACKEND == "mock":
        print("[Basin] Mock backend - no model loading (test mode)")
        _model_loaded = False
        _warmup_time_ms = 0.0
        return

    # Streaming backend: just verify manifest loads
    if BASIN_BACKEND == "streaming":
        print("[Basin] Streaming backend - loading manifest only...")
        start = time.perf_counter()

        try:
            # Just verify manifest loads (no weights!)
            manifest_path = PROJECT_ROOT / "seeds" / "hybrid_manifest_v2_full_cdna2.json"
            with open(manifest_path) as f:
                manifest = json.load(f)

            _warmup_time_ms = (time.perf_counter() - start) * 1000
            _model_loaded = True

            print(f"[Basin] Manifest loaded in {_warmup_time_ms:.1f}ms")
            print(f"[Basin] Shards: {len(manifest.get('shards', []))} tensors")
            print(f"[Basin] Peak memory: ~100-150MB (streaming)")
        except Exception as e:
            print(f"[Basin] WARNING: Manifest load failed: {e}")
            _model_loaded = False
        return

    # WO-HELIX-DECODE-BLOCK-01: HelixCode v0 backend - verify block 0 tensors + GGUF norm source
    if BASIN_BACKEND == "helixcode_v0":
        print("[Basin] HelixCode v0 block backend - validating block 0 tensors...")
        start = time.perf_counter()

        try:
            helixcode_dir = PROJECT_ROOT / "seeds" / "helixcode_v0"

            # Verify all 7 block 0 tensors exist
            required_tensors = [
                "blk_0_attn_q_weight",
                "blk_0_attn_k_weight",
                "blk_0_attn_v_weight",
                "blk_0_attn_output_weight",
                "blk_0_ffn_gate_weight",
                "blk_0_ffn_up_weight",
                "blk_0_ffn_down_weight",
            ]
            missing = [t for t in required_tensors if not (helixcode_dir / t).exists()]
            if missing:
                print(f"[Basin] WARNING: Missing block 0 tensors: {missing}")
                _model_loaded = False
                return

            # Validate one tensor to ensure format is correct
            from helix_cdc.regrow.helixcode_v0 import HelixCodeV0
            test_path = helixcode_dir / "blk_0_attn_q_weight"
            with HelixCodeV0.load(str(test_path)) as hx:
                sane = hx.indices_sane()
                gpu_avail = hx.gpu_available()

            # Verify GGUF exists for norm weights
            gguf_path = PROJECT_ROOT / "tmp" / "rebuilt_from_hybrid_cdna_v2.gguf"
            gguf_exists = gguf_path.exists()

            _warmup_time_ms = (time.perf_counter() - start) * 1000
            _model_loaded = True

            print(f"[Basin] HelixCode v0 block ready in {_warmup_time_ms:.1f}ms")
            print(f"[Basin] Block 0 tensors: 7/7 present")
            print(f"[Basin] Test tensor: {test_path.name}, indices_sane={sane}, gpu_available={gpu_avail}")
            if gguf_exists:
                print(f"[Basin] Norm source GGUF: {gguf_path.name}")
            else:
                print(f"[Basin] WARNING: Norm source GGUF not found at {gguf_path}")
        except Exception as e:
            print(f"[Basin] WARNING: HelixCode v0 warmup failed: {e}")
            _model_loaded = False
        return

    # WO-HF-ORACLE-01: CDNA Chat backend - verify GGUF + manifest
    if BASIN_BACKEND == "cdna_chat":
        print("[Basin] CDNA Chat backend - validating HF-proven forward path...")
        start = time.perf_counter()

        try:
            # Verify GGUF exists
            gguf_path = PROJECT_ROOT / "tmp" / "mistral_fp8combined_canonical.gguf"
            if not gguf_path.exists():
                print(f"[Basin] WARNING: GGUF not found at {gguf_path}")
                _model_loaded = False
                return

            # Verify manifest exists
            manifest_path = PROJECT_ROOT / "seeds" / "hybrid_manifest_v2_pilot.json"
            manifest_ok = manifest_path.exists()

            # Verify HF oracle receipt exists
            oracle_dir = PROJECT_ROOT / "receipts" / "hf_oracle"
            oracle_ok = (oracle_dir / "LATEST_FULL.txt").exists()

            _warmup_time_ms = (time.perf_counter() - start) * 1000
            _model_loaded = True

            print(f"[Basin] CDNA Chat ready in {_warmup_time_ms:.1f}ms")
            print(f"[Basin] GGUF: {gguf_path.name} (verified)")
            print(f"[Basin] Manifest: {'OK' if manifest_ok else 'MISSING'}")
            print(f"[Basin] HF Oracle: {'PROVEN (cosine=0.99999)' if oracle_ok else 'Run make prove first'}")
        except Exception as e:
            print(f"[Basin] WARNING: CDNA Chat warmup failed: {e}")
            _model_loaded = False
        return

    # llama_cpp backend: full model warmup
    print("[Basin] Starting model warmup...")
    start = time.perf_counter()

    try:
        backend = get_llm_backend()

        if backend is not None:
            # Force the actual model load by getting the Llama instance
            from echo_cog.backend_llama_cpp import get_llama
            llm = get_llama(backend)

            elapsed_ms = (time.perf_counter() - start) * 1000
            _warmup_time_ms = elapsed_ms

            print(f"[Basin] Model warmed up in {elapsed_ms:.1f}ms")
            print(f"[Basin] Model path: {backend.model_path}")
            print(f"[Basin] GPU layers: {backend.n_gpu_layers}")

            # Check CUDA availability
            try:
                import torch
                if torch.cuda.is_available():
                    print(f"[Basin] CUDA available: {torch.cuda.get_device_name(0)}")
                else:
                    print("[Basin] CUDA not available - using CPU")
            except ImportError:
                print("[Basin] PyTorch not installed - GPU status unknown")
        else:
            print("[Basin] WARNING: No model backend available - running in mock mode")
    except Exception as e:
        print(f"[Basin] WARNING: Model warmup failed: {e}")
        print("[Basin] Running in mock mode - zero-copy endpoints still functional")
        _model_loaded = False


@app.on_event("shutdown")
async def cleanup_model():
    """Clean up model resources on shutdown."""
    global _llm_backend
    if _llm_backend is not None:
        try:
            from echo_cog.backend_llama_cpp import close_llama
            close_llama()
            print("[Basin] Model cache cleared")
        except Exception as e:
            print(f"[Basin] Cleanup warning: {e}")
        _llm_backend = None


# --- API Endpoints ---

@app.get("/health", response_model=HealthResponse)
async def health_check():
    """Health check endpoint."""
    return HealthResponse(
        status="healthy",
        timestamp=datetime.now(timezone.utc).isoformat(),
        version="1.0.0",
        model_loaded=_model_loaded,
        warmup_time_ms=_warmup_time_ms,
        backend=BASIN_BACKEND,
    )


@app.get("/v1/models", response_model=ModelsResponse)
async def list_models():
    """List available models with runtime info including GPU/VRAM status."""
    backend = get_llm_backend()
    gpu_info = get_gpu_info()
    models = []

    if backend:
        models.append(
            ModelInfo(
                id="echo-basin",
                created=int(datetime.now().timestamp()),
                owned_by="helix-cdc",
                metadata={
                    "model_path": backend.model_path,
                    "ctx_size": backend.ctx_size,
                    "n_gpu_layers": backend.n_gpu_layers,
                    "threads": backend.threads,
                    "gpu": {
                        "device": gpu_info["device_name"],
                        "vram_total_mb": gpu_info["vram_total_mb"],
                        "vram_free_mb": gpu_info["vram_free_mb"],
                        "cuda_available": gpu_info["cuda_available"],
                    },
                },
            )
        )
    else:
        # No backend available - return mock info
        models.append(
            ModelInfo(
                id="echo-basin",
                created=int(datetime.now().timestamp()),
                owned_by="helix-cdc",
                metadata={"mode": "mock"},
            )
        )

    return ModelsResponse(data=models)


# --- Pydantic Models for /v1/artifacts ---

class ArtifactInfo(BaseModel):
    """Single artifact information."""
    name: str
    path: str
    tensor_name: str
    shape: List[int]
    dtype: str
    compression: str
    storage_layout: Optional[str] = None
    expected_matmul: Optional[str] = None
    created_utc: Optional[str] = None


class ArtifactsResponse(BaseModel):
    """Response for /v1/artifacts endpoint."""
    object: str = "list"
    artifacts: List[ArtifactInfo]
    total: int
    helixcode_v0_dir: str


@app.get("/v1/artifacts", response_model=ArtifactsResponse)
async def list_artifacts():
    """
    WO-ONEBOX-CLOUD-01: List available HelixCode v0 artifacts.

    Scans seeds/helixcode_v0/ for available tensors and returns their metadata.
    Used by the web UI to populate the model/artifact selector dropdown.
    """
    helixcode_dir = PROJECT_ROOT / "seeds" / "helixcode_v0"
    artifacts = []

    if helixcode_dir.exists():
        for tensor_dir in sorted(helixcode_dir.iterdir()):
            if tensor_dir.is_dir():
                manifest_path = tensor_dir / "manifest.json"
                if manifest_path.exists():
                    try:
                        with open(manifest_path) as f:
                            manifest = json.load(f)

                        artifacts.append(ArtifactInfo(
                            name=tensor_dir.name,
                            path=str(tensor_dir.relative_to(PROJECT_ROOT)),
                            tensor_name=manifest.get("tensor_name", tensor_dir.name),
                            shape=manifest.get("shape", [0, 0]),
                            dtype=manifest.get("dtype", "unknown"),
                            compression=manifest.get("compression", "unknown"),
                            storage_layout=manifest.get("storage_layout"),
                            expected_matmul=manifest.get("expected_matmul"),
                            created_utc=manifest.get("created_utc"),
                        ))
                    except (json.JSONDecodeError, IOError):
                        # Skip malformed manifests
                        pass

    return ArtifactsResponse(
        artifacts=artifacts,
        total=len(artifacts),
        helixcode_v0_dir=str(helixcode_dir.relative_to(PROJECT_ROOT)),
    )


@app.post("/v1/chat/completions", response_model=ChatCompletionResponse)
async def create_chat_completion(request: ChatCompletionRequest):
    """
    Create a basin-controlled chat completion.

    Strict parameter enforcement per BASIN_API_CONTRACT.md:
    - temperature MUST be 0.0
    - top_p MUST be 1.0
    - control_consent MUST be true
    - phi0_deg MUST be in [0, 360)
    - cardinality MUST be in {2, 4, 8}
    """
    # Extract and canonicalize prompt
    user_messages = [m for m in request.messages if m.role == "user"]
    if not user_messages:
        raise HTTPException(
            status_code=400,
            detail=ErrorResponse(
                error=ErrorDetail(
                    message="messages must contain at least one user message",
                    param="messages",
                )
            ).model_dump(),
        )

    raw_prompt = user_messages[-1].content
    canonical_prompt = canonicalize_prompt(raw_prompt)

    # ==========================================================================
    # WO-TOOL-ROUTING-HARDEN-02: Table-driven tool routing
    # Replaces if-else cascade with registry-based routing.
    # Priority order defined in transport_policy.py: geometry > math > retrieval > code > LLM
    # ==========================================================================
    tool_response = await _route_to_tool(request, raw_prompt)
    if tool_response is not None:
        return tool_response

    # ==========================================================================
    # No tool routing matched - fall through to LLM inference
    # WO-BASIN-TELEM-HOOK-01: Wrap inference with telemetry context
    # ==========================================================================

    # Compute basin routing
    basin_id = compute_basin_id(request.quant.phi0_deg, request.quant.cardinality)
    seed = compute_seed(canonical_prompt, basin_id)

    # Run inference with telemetry
    # WO-BASIN-RECEIPTS-CHAT-01: Capture receipt_dict from streaming backend
    # WO-BASIN-TELEM-HOOK-01: Create per-request telemetry
    with basin_telem_context(
        model_id=request.model,
        prompt=canonical_prompt,
        extra_prov={
            "basin_id": basin_id,
            "seed": seed,
        },
    ) as (telem, jsonl_path, summary_path):
        start_time = time.time()
        generated_text, timing, receipt_dict = run_inference(
            prompt=canonical_prompt,
            seed=seed,
            max_tokens=request.max_tokens,
            canonical_prompt=canonical_prompt,
            telem=telem,  # WO-BASIN-TELEM-HOOK-01: Pass telemetry
        )
        total_time = time.time() - start_time

        # Attach telemetry paths to timing
        timing["telemetry_jsonl"] = jsonl_path
        timing["telemetry_summary"] = summary_path

    # Build response
    response_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
    created_ts = int(datetime.now().timestamp())

    # Token counting (rough estimate: 1 token ~ 4 chars)
    prompt_tokens = len(canonical_prompt) // 4 + 1
    completion_tokens = len(generated_text) // 4 + 1

    # Build timing dict if not mock
    timing_dict = None
    is_mock = timing.get("mock", False)
    if not is_mock:
        timing_dict = {
            "ttft_ms": timing.get("ttft_ms", 0),
            "decode_ms": timing.get("decode_ms", 0),
            "total_ms": timing.get("total_ms", 0),
            "ms_per_token": timing.get("ms_per_token", 0),
        }

    # Get backend config for offload info
    backend_cfg = get_llm_backend()
    offload_info = None
    if backend_cfg and not is_mock:
        offload_info = {
            "n_gpu_layers": backend_cfg.n_gpu_layers,
            "ctx_size": backend_cfg.ctx_size,
            "threads": backend_cfg.threads,
        }

    # WO-ONEBOX-CLOUD-01: Extract backend_used from timing dict
    backend_used = timing.get("backend", "unknown")

    # WO-BASIN-RECEIPTS-CHAT-01: Determine transport mode and build receipt info
    if receipt_dict is not None:
        transport_mode = receipt_dict.get("schema", "streaming_cdna2").replace("_receipt", "")
        receipt_ids = [receipt_dict.get("receipt_id")]
        streaming_receipts = [receipt_dict]
    elif is_mock:
        transport_mode = "mock"
        receipt_ids = None
        streaming_receipts = None
    else:
        transport_mode = "partial_gpu"
        receipt_ids = None
        streaming_receipts = None

    basin_metadata = {
        "seed": seed,
        "basin_id": basin_id,
        "canonical_prompt_hash": hashlib.sha256(canonical_prompt.encode()).hexdigest()[:16],
        "transport_mode": transport_mode,
        "offload": offload_info,
    }
    if receipt_ids:
        basin_metadata["receipt_ids"] = receipt_ids

    return ChatCompletionResponse(
        id=response_id,
        created=created_ts,
        model=request.model,
        choices=[
            {
                "index": 0,
                "message": {
                    "role": "assistant",
                    "content": generated_text,
                },
                "finish_reason": "stop",
                "basin_metadata": basin_metadata,
            }
        ],
        usage={
            "prompt_tokens": prompt_tokens,
            "completion_tokens": completion_tokens,
            "total_tokens": prompt_tokens + completion_tokens,
        },
        timing=timing_dict,
        streaming_receipts=streaming_receipts,
        backend_used=backend_used,  # WO-ONEBOX-CLOUD-01
    )


# --- Error Handlers ---

from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
    """Handle validation errors with proper Basin API error format."""
    errors = exc.errors()
    if errors:
        err = errors[0]
        loc = err.get("loc", [])
        param = ".".join(str(l) for l in loc[1:]) if len(loc) > 1 else None
        msg = err.get("msg", "validation error")

        # Map common validation errors to Basin API messages
        if "temperature" in str(loc):
            msg = "temperature must be 0.0 for deterministic basin control"
            param = "temperature"
        elif "top_p" in str(loc):
            msg = "top_p must be 1.0 for deterministic basin control"
            param = "top_p"
        elif "control_consent" in str(loc):
            msg = "control_consent must be true (explicit consent required)"
            param = "quant.control_consent"
        elif "phi0_deg" in str(loc):
            msg = "phi0_deg must be in range [0, 360)"
            param = "quant.phi0_deg"
        elif "cardinality" in str(loc):
            msg = f"cardinality must be one of {VALID_CARDINALITIES} (current support)"
            param = "quant.cardinality"

        return JSONResponse(
            status_code=400,
            content={
                "error": {
                    "message": msg,
                    "type": "invalid_request_error",
                    "param": param,
                    "code": "invalid_parameter",
                }
            },
        )

    return JSONResponse(
        status_code=400,
        content={
            "error": {
                "message": "Invalid request",
                "type": "invalid_request_error",
                "code": "invalid_parameter",
            }
        },
    )


# --- Zero-Copy API Endpoints (WO-ZC-SHIM) ---

class RouteRequest(BaseModel):
    """SER planning request for tensor routing."""
    tensor_name: str = Field(..., description="Tensor name (e.g., blk.0.attn_q.weight)")
    row_start: int = Field(default=0, ge=0, description="Start row for partial access")
    row_end: Optional[int] = Field(default=None, description="End row (exclusive)")
    transport_policy: str = Field(
        default="zero_copy",
        description="Transport policy: zero_copy, pinned_copy, device_prefetch"
    )


class RouteResponse(BaseModel):
    """SER planning response."""
    tensor_name: str
    shard_path: str
    sidecar_path: Optional[str]
    codec: str
    shape: List[int]
    row_range: List[int]
    block_range: List[int]
    bytes_to_load: int
    transport_policy: str
    estimated_latency_ms: float


class SliceResponse(BaseModel):
    """Response for /slice endpoint."""
    tensor_name: str
    file_path: str
    offset: int
    length: int
    row_range: List[int]


class ComposeRequest(BaseModel):
    """Request to pre-register spans into BAR LRU."""
    spans: List[Dict[str, Any]] = Field(..., description="List of {path, offset, nbytes}")
    budget_mb: int = Field(default=512, description="Budget in MB")


class ComposeResponse(BaseModel):
    """Response with registered handles."""
    registered: int
    evicted: int
    handles: List[str]
    budget_utilization: float


class GenerateRequest(BaseModel):
    """Request for streaming generation."""
    prompt: str = Field(..., description="Input prompt")
    max_tokens: int = Field(default=64, ge=1, le=512)
    transport_policy: str = Field(default="zero_copy")
    manifest: str = Field(default="seeds/hybrid_manifest_v2_full_cdna2.json")


class GenerateResponse(BaseModel):
    """Response with generated text and stats."""
    text: str
    tokens_generated: int
    ttft_ms: float
    total_ms: float
    h2d_bytes: int
    transport_policy: str


# Global manifest cache
_manifest_cache: Dict[str, Dict] = {}


def _get_manifest(path: str) -> Dict:
    """Load and cache manifest."""
    if path not in _manifest_cache:
        manifest_path = PROJECT_ROOT / path
        if not manifest_path.exists():
            raise HTTPException(status_code=404, detail=f"Manifest not found: {path}")
        with open(manifest_path) as f:
            _manifest_cache[path] = json.load(f)
    return _manifest_cache[path]


def _find_shard(manifest: Dict, tensor_name: str) -> Optional[Dict]:
    """Find shard entry by tensor name."""
    for shard in manifest.get("shards", []):
        if shard.get("tensor_name") == tensor_name:
            return shard
    return None


@app.post("/route", response_model=RouteResponse)
async def route_tensor(request: RouteRequest):
    """
    SER planning: Return routing info for a tensor.

    Given a tensor name and optional row range, returns:
    - Shard file path and codec
    - Block range to load
    - Estimated bytes and latency
    """
    manifest = _get_manifest("seeds/hybrid_manifest_v2_full_cdna2.json")
    shard = _find_shard(manifest, request.tensor_name)

    if shard is None:
        raise HTTPException(
            status_code=404,
            detail=f"Tensor not found in manifest: {request.tensor_name}"
        )

    shape = shard.get("shape", [0, 0])
    rows = shape[0]
    cols = shape[1] if len(shape) > 1 else 1

    row_start = request.row_start
    row_end = request.row_end if request.row_end else rows

    # Validate row range
    if row_start >= row_end or row_end > rows:
        raise HTTPException(
            status_code=400,
            detail=f"Invalid row range [{row_start}, {row_end}) for tensor with {rows} rows"
        )

    # Calculate block range (assuming block_rows from manifest)
    block_rows = shard.get("block_rows", manifest.get("block_rows", 16))
    start_block = row_start // block_rows
    end_block = (row_end - 1) // block_rows + 1

    # Estimate bytes
    bytes_per_row = cols  # uint8 indices
    bytes_to_load = (row_end - row_start) * bytes_per_row

    # Estimate latency based on transport policy
    if request.transport_policy == "zero_copy":
        estimated_latency_ms = 0.5  # BAR1 access latency
    elif request.transport_policy == "pinned_copy":
        estimated_latency_ms = bytes_to_load / (10 * 1024**3) * 1000 + 0.5
    else:  # device_prefetch
        estimated_latency_ms = bytes_to_load / (12 * 1024**3) * 1000 + 0.3

    return RouteResponse(
        tensor_name=request.tensor_name,
        shard_path=shard.get("path", ""),
        sidecar_path=shard.get("outlier_sidecar_path"),
        codec=shard.get("codec", "unknown"),
        shape=shape,
        row_range=[row_start, row_end],
        block_range=[start_block, end_block],
        bytes_to_load=bytes_to_load,
        transport_policy=request.transport_policy,
        estimated_latency_ms=estimated_latency_ms,
    )


@app.get("/slice", response_model=SliceResponse)
async def get_slice(
    tensor: str,
    row_start: int = 0,
    row_len: int = 2048,
    manifest: str = "seeds/hybrid_manifest_v2_full_cdna2.json",
):
    """
    Return file/offset/length for a row range.

    This enables the client to mmap the exact region needed.
    """
    # Security: Only allow seeds/ manifests
    if ".." in manifest or not manifest.startswith("seeds/"):
        raise HTTPException(status_code=400, detail="Invalid manifest path")

    manifest_data = _get_manifest(manifest)
    shard = _find_shard(manifest_data, tensor)

    if shard is None:
        raise HTTPException(
            status_code=404,
            detail=f"Tensor not found: {tensor}"
        )

    shape = shard.get("shape", [0, 0])
    rows = shape[0]
    cols = shape[1] if len(shape) > 1 else 1

    row_end = min(row_start + row_len, rows)

    if row_start >= rows:
        raise HTTPException(
            status_code=400,
            detail=f"row_start {row_start} >= total rows {rows}"
        )

    # Calculate offset within file
    # For CDNAv2, we need block-level access
    block_rows = shard.get("block_rows", 16)
    start_block = row_start // block_rows
    end_block = (row_end - 1) // block_rows + 1

    # Estimate offset (header + codebook + index + blocks)
    # This is approximate - real implementation would parse the file
    header_overhead = 1024 + 48 * (rows // block_rows + 1)
    bytes_per_block = block_rows * cols
    offset = header_overhead + start_block * bytes_per_block
    length = (end_block - start_block) * bytes_per_block

    # Return relative path only (don't expose full filesystem path)
    return SliceResponse(
        tensor_name=tensor,
        file_path=shard.get("path", ""),  # Relative path only
        offset=offset,
        length=length,
        row_range=[row_start, row_end],
    )


@app.post("/compose", response_model=ComposeResponse)
async def compose_spans(request: ComposeRequest):
    """
    Pre-register spans into BAR LRU.

    This prepares memory regions for zero-copy access.
    Returns handles that can be used in subsequent /generate calls.
    """
    try:
        from helix_cdc.zc import BARLRU
    except ImportError:
        # Stub response if ZC module not built
        return ComposeResponse(
            registered=0,
            evicted=0,
            handles=[],
            budget_utilization=0.0,
        )

    lru = BARLRU(budget_bytes=request.budget_mb * 1024 * 1024)

    handles = []
    registered = 0
    evicted = 0

    for span_req in request.spans:
        path = span_req.get("path", "")
        offset = span_req.get("offset", 0)
        nbytes = span_req.get("nbytes", 4096)

        try:
            span, created = lru.acquire(path, offset, nbytes)
            handle = f"{path}:{offset}:{nbytes}"
            handles.append(handle)
            if created:
                registered += 1
        except Exception as e:
            # Log but continue
            print(f"Failed to register span {path}: {e}")

    stats = lru.stats()

    return ComposeResponse(
        registered=registered,
        evicted=stats.get("evictions", 0),
        handles=handles,
        budget_utilization=stats.get("utilization", 0.0),
    )


@app.post("/generate", response_model=GenerateResponse)
async def generate_streaming(request: GenerateRequest):
    """
    Run generation with specified transport policy.

    This is a simplified demonstration endpoint.
    Real implementation would use the streaming block code.
    """
    import time as _time

    t0 = _time.perf_counter()

    # Simulate TTFT based on transport policy
    if request.transport_policy == "zero_copy":
        ttft_ms = 5.0 + np.random.uniform(-0.5, 0.5)
        h2d_bytes = 0
    elif request.transport_policy == "pinned_copy":
        h2d_bytes = 832 * 1024 * 1024  # ~832 MB for full block
        ttft_ms = 15.0 + np.random.uniform(-1.0, 1.0)
    else:  # device_prefetch
        h2d_bytes = 832 * 1024 * 1024
        ttft_ms = 10.0 + np.random.uniform(-0.5, 0.5)

    # Simulate generation
    tokens = min(request.max_tokens, 64)
    _time.sleep(ttft_ms / 1000 + tokens * 0.002)  # Simulate latency

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

    # Generate mock text
    text = f"[Mock response, policy={request.transport_policy}] " + request.prompt[:50]

    return GenerateResponse(
        text=text,
        tokens_generated=tokens,
        ttft_ms=ttft_ms,
        total_ms=total_ms,
        h2d_bytes=h2d_bytes,
        transport_policy=request.transport_policy,
    )


# --- AI Context Continuity Endpoints ---

class InventoryItem(BaseModel):
    """Single item in the inventory."""
    type: str  # receipt, manifest, proof_pack, shard
    path: str
    name: str
    timestamp: Optional[str] = None
    status: Optional[str] = None
    size_bytes: Optional[int] = None


class InventoryResponse(BaseModel):
    """Full inventory of receipts, manifests, and proofs."""
    receipts: List[InventoryItem]
    manifests: List[InventoryItem]
    proof_packs: List[InventoryItem]
    shards: List[InventoryItem]
    total_count: int
    timestamp: str


class StatusResponse(BaseModel):
    """Current system status for AI context."""
    system_health: str
    model_loaded: bool
    warmup_time_ms: float
    work_orders: List[Dict[str, Any]]
    proof_status: Dict[str, Any]
    last_activity: str


class ReceiptDetail(BaseModel):
    """Detailed receipt content."""
    path: str
    content: Dict[str, Any]
    sha256: str
    size_bytes: int


class ChangelogEntry(BaseModel):
    """Single changelog entry."""
    timestamp: str
    type: str  # commit, receipt, manifest
    message: str
    files: List[str]


class ChangelogResponse(BaseModel):
    """Recent changes for AI context."""
    entries: List[ChangelogEntry]
    since: str
    count: int


def _scan_receipts() -> List[InventoryItem]:
    """Scan all receipt files."""
    items = []
    receipts_dir = PROJECT_ROOT / "receipts"

    if receipts_dir.exists():
        for f in receipts_dir.rglob("*.json"):
            try:
                stat = f.stat()
                content = json.loads(f.read_text())
                items.append(InventoryItem(
                    type="receipt",
                    path=str(f.relative_to(PROJECT_ROOT)),
                    name=f.name,
                    timestamp=content.get("timestamp_utc") or content.get("provenance", {}).get("timestamp_utc"),
                    status=content.get("claim", {}).get("status"),
                    size_bytes=stat.st_size,
                ))
            except Exception:
                items.append(InventoryItem(
                    type="receipt",
                    path=str(f.relative_to(PROJECT_ROOT)),
                    name=f.name,
                ))

    return items


def _scan_manifests() -> List[InventoryItem]:
    """Scan all manifest files."""
    items = []
    seeds_dir = PROJECT_ROOT / "seeds"

    if seeds_dir.exists():
        for f in seeds_dir.glob("*manifest*.json"):
            try:
                stat = f.stat()
                content = json.loads(f.read_text())
                items.append(InventoryItem(
                    type="manifest",
                    path=str(f.relative_to(PROJECT_ROOT)),
                    name=f.name,
                    timestamp=content.get("created_utc"),
                    status="active" if "hybrid_manifest_v2_full_cdna2" in f.name else "legacy",
                    size_bytes=stat.st_size,
                ))
            except Exception:
                items.append(InventoryItem(
                    type="manifest",
                    path=str(f.relative_to(PROJECT_ROOT)),
                    name=f.name,
                ))

    return items


def _scan_proof_packs() -> List[InventoryItem]:
    """Scan proof pack directories."""
    items = []
    packs_dir = PROJECT_ROOT / "receipts" / "proof_packs"

    if packs_dir.exists():
        for pack_dir in packs_dir.iterdir():
            if pack_dir.is_dir():
                readme = pack_dir / "README.md"
                status = "unknown"
                if readme.exists():
                    content = readme.read_text()
                    if "PASS" in content:
                        status = "PASS"
                    elif "FAIL" in content:
                        status = "FAIL"

                items.append(InventoryItem(
                    type="proof_pack",
                    path=str(pack_dir.relative_to(PROJECT_ROOT)),
                    name=pack_dir.name,
                    status=status,
                ))

    return items


def _scan_shards() -> List[InventoryItem]:
    """Scan CDNAv2 shard files (summary only)."""
    items = []
    seeds_dir = PROJECT_ROOT / "seeds"

    shard_dirs = ["cdna_v2_pilot", "cdna_v2_attention"]

    for sdir in shard_dirs:
        shard_path = seeds_dir / sdir
        if shard_path.exists():
            for f in shard_path.glob("*.hxz"):
                try:
                    stat = f.stat()
                    items.append(InventoryItem(
                        type="shard",
                        path=str(f.relative_to(PROJECT_ROOT)),
                        name=f.name,
                        size_bytes=stat.st_size,
                    ))
                except Exception:
                    pass

    return items


def _get_proof_status() -> Dict[str, Any]:
    """Get proof status summary from CLAUDE.md."""
    claude_md = PROJECT_ROOT / "CLAUDE.md"
    status = {}

    if claude_md.exists():
        content = claude_md.read_text()

        # Parse proof status table
        lines = content.split("\n")
        in_table = False
        for line in lines:
            if "| Claim |" in line:
                in_table = True
                continue
            if in_table and line.startswith("|"):
                parts = [p.strip() for p in line.split("|")]
                if len(parts) >= 4:
                    claim = parts[1]
                    stat = parts[2]
                    # Skip separator lines (e.g., |-------|--------|)
                    if claim and stat and not claim.startswith("-"):
                        status[claim] = stat
            elif in_table and not line.startswith("|"):
                in_table = False

    return status


def _get_recent_commits(n: int = 10) -> List[ChangelogEntry]:
    """Get recent git commits as changelog entries."""
    import subprocess
    entries = []

    try:
        result = subprocess.run(
            ["git", "log", f"-{n}", "--pretty=format:%H|%ai|%s", "--name-only"],
            cwd=PROJECT_ROOT,
            capture_output=True,
            text=True,
            timeout=5,
        )

        if result.returncode == 0:
            current_commit = None
            current_files = []

            for line in result.stdout.split("\n"):
                if "|" in line:
                    # New commit
                    if current_commit:
                        entries.append(ChangelogEntry(
                            timestamp=current_commit["timestamp"],
                            type="commit",
                            message=current_commit["message"],
                            files=current_files[:10],  # Limit files
                        ))

                    parts = line.split("|")
                    current_commit = {
                        "hash": parts[0],
                        "timestamp": parts[1],
                        "message": parts[2] if len(parts) > 2 else "",
                    }
                    current_files = []
                elif line.strip():
                    current_files.append(line.strip())

            # Add last commit
            if current_commit:
                entries.append(ChangelogEntry(
                    timestamp=current_commit["timestamp"],
                    type="commit",
                    message=current_commit["message"],
                    files=current_files[:10],
                ))

    except Exception:
        pass

    return entries


@app.get("/inventory", response_model=InventoryResponse)
async def get_inventory():
    """
    List all receipts, manifests, and proofs.

    This endpoint provides full context for AI agents to understand
    what artifacts exist in the system.
    """
    receipts = _scan_receipts()
    manifests = _scan_manifests()
    proof_packs = _scan_proof_packs()
    shards = _scan_shards()

    return InventoryResponse(
        receipts=receipts,
        manifests=manifests,
        proof_packs=proof_packs,
        shards=shards,
        total_count=len(receipts) + len(manifests) + len(proof_packs) + len(shards),
        timestamp=datetime.now(timezone.utc).isoformat(),
    )


@app.get("/v1/receipts/{receipt_id}")
async def get_receipt_by_id(receipt_id: str):
    """
    Fetch a stored streaming receipt by ID.

    WO-BASIN-RECEIPTS-CHAT-01: Retrieves receipts persisted from chat completions.

    Args:
        receipt_id: The deterministic receipt ID (sha256 prefix from chat endpoint)

    Returns:
        Receipt with path, content, and sha256 hash
    """
    # Security: validate receipt_id format (should be hex string)
    if not receipt_id or len(receipt_id) > 64:
        raise HTTPException(status_code=400, detail="Invalid receipt_id format")
    if not all(c in "0123456789abcdef" for c in receipt_id.lower()):
        raise HTTPException(status_code=400, detail="Invalid receipt_id: must be hex string")

    # Search receipts/api_chat/**/receipt_<id>.json
    api_chat_dir = PROJECT_ROOT / "receipts" / "api_chat"
    if not api_chat_dir.exists():
        raise HTTPException(status_code=404, detail=f"Receipt not found: {receipt_id}")

    for path in api_chat_dir.rglob(f"receipt_{receipt_id}.json"):
        try:
            content = json.loads(path.read_text())
            sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
            return {
                "path": str(path.relative_to(PROJECT_ROOT)),
                "content": content,
                "sha256": sha256,
                "size_bytes": path.stat().st_size,
            }
        except (json.JSONDecodeError, IOError) as e:
            raise HTTPException(status_code=500, detail=f"Error reading receipt: {e}")

    raise HTTPException(status_code=404, detail=f"Receipt not found: {receipt_id}")


@app.get("/receipt/{receipt_path:path}", response_model=ReceiptDetail)
async def get_receipt(receipt_path: str):
    """
    Fetch any specific receipt JSON by path.

    Example: /receipt/receipts/proof_packs/WO-STREAM-FULL-BLOCK-01/block_0_cdna2/stream_transformer_block_receipt.json
    """
    # Security: Prevent path traversal attacks
    if ".." in receipt_path or receipt_path.startswith("/"):
        raise HTTPException(status_code=400, detail="Invalid path: traversal not allowed")

    # Only allow receipts/ directory
    if not receipt_path.startswith("receipts/"):
        raise HTTPException(status_code=400, detail="Only receipts/ directory accessible")

    full_path = (PROJECT_ROOT / receipt_path).resolve()

    # Verify resolved path is still under PROJECT_ROOT
    try:
        full_path.relative_to(PROJECT_ROOT.resolve())
    except ValueError:
        raise HTTPException(status_code=400, detail="Path escapes project root")

    if not full_path.exists():
        raise HTTPException(status_code=404, detail=f"Receipt not found: {receipt_path}")

    if not str(full_path).endswith(".json"):
        raise HTTPException(status_code=400, detail="Only JSON receipts supported")

    try:
        content = json.loads(full_path.read_text())
        sha256 = hashlib.sha256(full_path.read_bytes()).hexdigest()
        stat = full_path.stat()

        return ReceiptDetail(
            path=receipt_path,
            content=content,
            sha256=sha256,
            size_bytes=stat.st_size,
        )
    except json.JSONDecodeError as e:
        raise HTTPException(status_code=500, detail=f"Invalid JSON: {e}")


@app.get("/status", response_model=StatusResponse)
async def get_status():
    """
    Current work orders, what's done/pending.

    Provides high-level status for AI agents to understand
    the current state of the project.
    """
    proof_status = _get_proof_status()

    # Parse work orders from docs or receipts
    work_orders = []

    # Check for recent proof packs as completed work
    packs_dir = PROJECT_ROOT / "receipts" / "proof_packs"
    if packs_dir.exists():
        for pack_dir in packs_dir.iterdir():
            if pack_dir.is_dir():
                work_orders.append({
                    "id": pack_dir.name,
                    "status": "complete",
                    "type": "proof_pack",
                })

    return StatusResponse(
        system_health="healthy" if _model_loaded else "degraded",
        model_loaded=_model_loaded,
        warmup_time_ms=_warmup_time_ms,
        work_orders=work_orders,
        proof_status=proof_status,
        last_activity=datetime.now(timezone.utc).isoformat(),
    )


@app.get("/changelog", response_model=ChangelogResponse)
async def get_changelog(since: Optional[str] = None, limit: int = 10):
    """
    What changed since last check.

    Provides recent commits and artifact changes for AI agents
    to understand what has changed.
    """
    entries = _get_recent_commits(limit)

    return ChangelogResponse(
        entries=entries,
        since=since or "all",
        count=len(entries),
    )


# --- Echo Ask Endpoint (WO-ECHO-ASK-01) ---

class EchoAskRequest(BaseModel):
    """Request for /echo/ask endpoint."""
    question: str = Field(..., description="Natural language question about the project")
    top_k: int = Field(default=5, ge=1, le=20, description="Number of evidence sources")
    include_proof_status: bool = Field(default=True, description="Include CLAUDE.md proof table")
    use_llm: bool = Field(default=True, description="Generate LLM answer vs structured only")


class EchoSource(BaseModel):
    """Single evidence source in echo response."""
    path: str
    status: Optional[str] = None
    snippet: str
    relevance_score: float
    chunk_id: Optional[str] = None      # WO-ECHO-ASK-02: chunk metadata
    source_type: Optional[str] = None   # WO-ECHO-ASK-02: receipt, code, doc, etc.
    sha256: Optional[str] = None        # WO-ECHO-ASK-02: for verification


class EchoAskResponse(BaseModel):
    """Response from /echo/ask endpoint."""
    answer: str
    sources: List[EchoSource]
    proof_status: Optional[Dict[str, str]] = None
    proof_summary: Optional[Dict[str, List[str]]] = None  # WO-ECHO-ASK-02: proven/disproven/unverified
    confidence: float
    confidence_reason: str  # WO-ECHO-ASK-02 Phase 2: no_evidence, weak_match, partial_match, supported, strongly_supported
    calibration_version: str = "v1.0"  # WO-ECHO-ASK-02 Phase 2: Bump on threshold changes
    timestamp: str
    mode: str  # "llm" or "structured"
    retrieval_mode: str = "unknown"  # WO-ECHO-ASK-02: "memory_index" or "keyword_fallback"


# Echo Ask prompt template
ECHO_ASK_PROMPT = """You are Echo, the AI assistant for the helix-cdc project.
You have access to proof receipts and project documentation.

## Project Proof Status (from CLAUDE.md)
{proof_status_table}

## Relevant Evidence
{evidence_chunks}

## Question
{question}

## Instructions
1. Answer based ONLY on the proof receipts and documentation above
2. Cite specific receipts when making claims
3. Clearly distinguish PROVEN vs DISPROVEN vs UNVERIFIED claims
4. If information is not available, say so explicitly
5. Keep answers concise but complete

Answer:"""


def _build_echo_context(question: str, top_k: int = 5) -> Dict[str, Any]:
    """
    Build comprehensive context for answering questions.

    WO-ECHO-ASK-02: Uses MemoryIndex.search() as primary retrieval,
    with keyword fallback when index is unavailable.

    Returns:
        {
            "proof_status": {...},      # From CLAUDE.md
            "chunks": [...],            # Raw Chunk objects from MemoryIndex
            "relevant_receipts": [...], # Matched receipts with scores
            "work_orders": [...],       # Matched WO references
            "retrieval_mode": str,      # "memory_index" or "keyword_fallback"
        }
    """
    context = {
        "proof_status": {},
        "chunks": [],           # WO-ECHO-ASK-02: raw Chunk objects
        "relevant_receipts": [],
        "work_orders": [],
        "retrieval_mode": "unknown",
    }

    # Get proof status from CLAUDE.md
    context["proof_status"] = _get_proof_status()

    # WO-ECHO-REMEMBER-01: Also search notes
    try:
        notes = _search_notes(question, limit=top_k)
        for note in notes:
            # Convert note to chunk-like format for context
            context["relevant_receipts"].append({
                "path": f"notes/{note['note_id']}",
                "status": None,
                "summary": note["note"][:300],
                "score": note.get("score", 0.5),
                "chunk_id": note["note_id"],
                "sha256": None,
                "source_type": "note",
                "category": note.get("category"),
            })
    except Exception:
        pass  # Notes DB may not exist yet

    # WO-ECHO-BRIDGE-02: Search EchoMemory checkpoints
    try:
        echo_mem_results = _search_echo_memory(question, limit=top_k)
        for r in echo_mem_results:
            context["relevant_receipts"].append({
                "path": r["path"],
                "status": None,
                "summary": r["text"][:300],
                "score": r["score"],
                "chunk_id": r["path"].split(":")[-1],
                "sha256": r.get("sha256"),
                "source_type": "echo_memory",
                "category": r.get("category"),
            })
    except Exception as e:
        print(f"[Echo] EchoMemory search failed: {e}")

    # WO-ECHO-ASK-02: Try MemoryIndex first
    try:
        from helix_cdc.echo.tools.memory import MemoryIndex
        idx = MemoryIndex()
        chunks = idx.search(question, k=top_k * 3)

        context["retrieval_mode"] = "memory_index"
        context["chunks"] = chunks

        # Extract receipts and work orders from chunks
        for chunk in chunks:
            # Build receipt entry from chunk
            status = None
            if "STATUS:" in chunk.text:
                status_match = re.search(r"STATUS:\s*(\w+)", chunk.text)
                if status_match:
                    status = status_match.group(1)
            elif "PASS" in chunk.text.upper():
                status = "PASS"
            elif "FAIL" in chunk.text.upper():
                status = "FAIL"

            if chunk.source_type == "receipt":
                context["relevant_receipts"].append({
                    "path": chunk.source_path,
                    "status": status,
                    "summary": chunk.text[:300],
                    "score": chunk.relevance_score,
                    "chunk_id": chunk.id,
                    "sha256": chunk.sha256,
                    "source_type": chunk.source_type,
                })
            else:
                # Non-receipt chunks still go in receipts for backward compat
                context["relevant_receipts"].append({
                    "path": chunk.source_path,
                    "status": status,
                    "summary": chunk.text[:300],
                    "score": chunk.relevance_score,
                    "chunk_id": chunk.id,
                    "sha256": chunk.sha256,
                    "source_type": chunk.source_type,
                })

            # Extract work order references from chunk text
            wo_match = re.search(r"WO[-_][\w-]+", chunk.text)
            if wo_match:
                context["work_orders"].append({
                    "id": wo_match.group(0),
                    "path": chunk.source_path,
                    "status": status,
                })

        # Dedupe work orders
        seen_wo = set()
        unique_wos = []
        for wo in context["work_orders"]:
            if wo["id"] and wo["id"] not in seen_wo:
                seen_wo.add(wo["id"])
                unique_wos.append(wo)
        context["work_orders"] = unique_wos[:20]

        return context

    except FileNotFoundError:
        # Index not built yet - use keyword fallback
        context["retrieval_mode"] = "keyword_fallback"
    except ImportError:
        # Module not available
        context["retrieval_mode"] = "keyword_fallback"
    except Exception as e:
        # Log but continue with fallback
        print(f"[Echo] MemoryIndex error: {e}")
        context["retrieval_mode"] = "keyword_fallback"

    # WO-ECHO-ASK-02: Keyword fallback (existing logic)
    q_lower = question.lower()
    keywords = set(q_lower.split())
    keywords = {w for w in keywords if len(w) > 2}

    receipts_dir = PROJECT_ROOT / "receipts"
    scored_receipts = []

    if receipts_dir.exists():
        for path in list(receipts_dir.rglob("*.json"))[:200]:  # Limit scan
            try:
                content = json.loads(path.read_text())
                rel_path = str(path.relative_to(PROJECT_ROOT))

                # Compute relevance score
                text_content = json.dumps(content).lower()
                score = sum(2 for kw in keywords if kw in text_content)

                # Boost score for work_order matches
                work_order = content.get("work_order", "")
                if work_order:
                    if any(kw in work_order.lower() for kw in keywords):
                        score += 5
                    # Track work orders
                    context["work_orders"].append({
                        "id": work_order,
                        "path": rel_path,
                        "status": content.get("status", content.get("claim", {}).get("status")),
                    })

                # Boost for status matches
                status = content.get("status", content.get("claim", {}).get("status"))
                if status and any(kw in status.lower() for kw in ["pass", "fail", "proven"]):
                    if any(kw in q_lower for kw in ["status", "working", "proven", "complete"]):
                        score += 3

                if score > 0:
                    # Extract summary
                    summary = ""
                    if "conclusion" in content and "skeptic_killer" in content["conclusion"]:
                        summary = content["conclusion"]["skeptic_killer"]
                    elif "summary" in content:
                        summary = content["summary"]
                    elif work_order:
                        summary = f"Work order: {work_order}"
                    elif status:
                        summary = f"Status: {status}"

                    scored_receipts.append({
                        "path": rel_path,
                        "status": status,
                        "summary": summary[:500] if summary else f"Receipt at {rel_path}",
                        "score": score,
                        "work_order": work_order,
                        "chunk_id": None,  # No chunk ID in fallback mode
                        "sha256": None,
                        "source_type": "receipt",
                    })

            except Exception:
                continue

    # Sort by score and dedupe work orders
    scored_receipts.sort(key=lambda x: -x["score"])
    context["relevant_receipts"] = scored_receipts[:top_k * 2]  # Keep more for filtering

    # Dedupe work orders
    seen_wo = set()
    unique_wos = []
    for wo in context["work_orders"]:
        if wo["id"] and wo["id"] not in seen_wo:
            seen_wo.add(wo["id"])
            unique_wos.append(wo)
    context["work_orders"] = unique_wos[:20]

    return context


def _generate_structured_answer(question: str, context: Dict, proof_status: Dict) -> str:
    """
    Generate answer from structured data without LLM.

    WO-ECHO-ASK-02: Updated to use chunks when available from MemoryIndex.
    """
    q_lower = question.lower()
    lines = []

    # Extract meaningful words (>= 3 chars to catch "svd", "api", etc.)
    q_words = [w for w in q_lower.split() if len(w) >= 3]
    # Filter out common stop words
    stop_words = {"the", "what", "which", "how", "does", "are", "is", "it", "for", "of", "to", "in", "on", "at", "with", "this", "that", "from", "like", "about", "any", "all", "some", "there", "been", "have", "has", "was", "were"}
    q_words = [w for w in q_words if w not in stop_words]

    # WO-ECHO-ASK-02: Use chunks when available
    chunks = context.get("chunks", [])
    receipts = context.get("relevant_receipts", [])

    # Pattern: Explicit work order questions (check first for specificity)
    if "work order" in q_lower or "work-order" in q_lower:
        wos = context.get("work_orders", [])
        if wos:
            lines.append(f"Found {len(wos)} work orders:")
            for wo in wos[:10]:
                status_str = f" [{wo['status']}]" if wo.get('status') else ""
                lines.append(f"- {wo['id']}{status_str}")
            return "\n".join(lines)

    # Pattern: "What's the status of X?" or "Is X working/proven?"
    if any(kw in q_lower for kw in ["status", "proven", "working", "complete", "done"]):
        # Check proof status table first
        matches = []
        for claim, status in (proof_status or {}).items():
            claim_lower = claim.lower()
            # Check if any significant word from question matches claim
            if any(w in claim_lower for w in q_words):
                matches.append(f"- {claim}: {status}")

        if matches:
            lines.append("Proof status from CLAUDE.md:")
            lines.extend(matches)

        # WO-ECHO-ASK-02: Check chunks first (MemoryIndex mode)
        if chunks:
            chunk_matches = []
            for c in chunks[:5]:
                if c.relevance_score > 0.4:
                    status_str = ""
                    if "PASS" in c.text.upper():
                        status_str = "[PASS]"
                    elif "FAIL" in c.text.upper():
                        status_str = "[FAIL]"
                    chunk_matches.append(f"- {status_str} {c.source_path} (score={c.relevance_score:.2f})")

            if chunk_matches:
                if lines:
                    lines.append("")
                lines.append("Related evidence:")
                lines.extend(chunk_matches[:5])
        else:
            # Fallback: check receipts
            receipt_matches = []
            for r in receipts[:5]:
                if r.get("status"):
                    receipt_matches.append(f"- [{r['status']}] {r['path']}")

            if receipt_matches:
                if lines:
                    lines.append("")
                lines.append("Related receipts:")
                lines.extend(receipt_matches[:5])

        if lines:
            return "\n".join(lines)

    # WO-ECHO-ASK-02: Show top chunk if relevance is good (MemoryIndex mode)
    if chunks and chunks[0].relevance_score >= 0.5:
        top = chunks[0]
        text_preview = top.text[:200].replace("\n", " ").strip()
        return f"Most relevant: {text_preview}...\n(source: {top.source_path}, score={top.relevance_score:.2f}, type={top.source_type})"

    # Fallback: Check keyword-matched receipts
    if receipts and receipts[0].get("score", 0) >= 4:
        top = receipts[0]
        return f"Most relevant: {top['summary']}\n(source: {top['path']}, keyword score={top['score']})"

    # Check if we have any proof status matches
    if proof_status:
        matches = []
        for claim, status in proof_status.items():
            claim_lower = claim.lower()
            if any(w in claim_lower for w in q_words):
                matches.append(f"- {claim}: {status}")
        if matches:
            lines.append("Related proof status from CLAUDE.md:")
            lines.extend(matches)
            return "\n".join(lines)

    return "No relevant information found in receipts or proof status."


def _generate_llm_answer(question: str, context: Dict, proof_status: Dict) -> str:
    """
    Generate answer using LLM backend.

    WO-ECHO-ASK-02: Uses actual chunk text from MemoryIndex, not summaries.
    """
    # Format proof status table
    proof_lines = []
    for claim, status in (proof_status or {}).items():
        proof_lines.append(f"- {claim}: {status}")
    proof_table = "\n".join(proof_lines) if proof_lines else "No proof status available."

    # WO-ECHO-ASK-02: Format evidence from actual chunks when available
    evidence_lines = []
    chunks = context.get("chunks", [])

    if chunks:
        # MemoryIndex mode: use actual chunk text
        for chunk in chunks[:10]:
            # Include actual text, not summaries
            text_snippet = chunk.text[:400].replace("\n", " ").strip()
            source_type = chunk.source_type or "unknown"
            evidence_lines.append(
                f"[{chunk.source_path}] (score={chunk.relevance_score:.2f}, type={source_type})\n"
                f"{text_snippet}"
            )
    else:
        # Fallback mode: use receipt summaries
        for r in context.get("relevant_receipts", [])[:10]:
            status_str = f" [{r['status']}]" if r.get("status") else ""
            source_type = r.get("source_type", "receipt")
            evidence_lines.append(f"[{r['path']}{status_str}] (type={source_type})\n{r['summary']}")

    evidence_chunks = "\n\n---\n\n".join(evidence_lines) if evidence_lines else "No relevant evidence found."

    # Build prompt
    prompt = ECHO_ASK_PROMPT.format(
        proof_status_table=proof_table,
        evidence_chunks=evidence_chunks,
        question=question,
    )

    # Try LLM backend
    backend_cfg = get_llm_backend()
    if backend_cfg is not None:
        try:
            from echo_cog.backend_llama_cpp import run_llama_cpp_timed

            result = run_llama_cpp_timed(
                cfg=backend_cfg,
                prompt=prompt,
                max_tokens=512,
                top_k=1,  # Greedy
            )
            return result["text"].strip()
        except Exception as e:
            # Fall back to structured if LLM fails
            return f"[LLM unavailable: {e}]\n\n" + _generate_structured_answer(question, context, proof_status)

    # No LLM available - use structured
    return "[No LLM backend loaded]\n\n" + _generate_structured_answer(question, context, proof_status)


def _estimate_confidence(context: Dict) -> Tuple[float, str]:
    """
    Estimate confidence based on evidence quality.

    WO-ECHO-ASK-02: Fixed confidence calculation to be properly calibrated.
    - Low relevance scores → low confidence
    - High relevance scores with receipts → high confidence
    - Never returns 0.99 for irrelevant queries

    Score distribution (from MemoryIndex hybrid BM25+vector):
    - Irrelevant queries: 0.10-0.26 (noise floor)
    - Partially relevant: 0.26-0.32
    - Relevant queries: 0.32-0.50+

    Returns:
        Tuple of (confidence: float, reason: str)
        Reasons: no_evidence, weak_match, partial_match, supported, strongly_supported
    """
    chunks = context.get("chunks", [])
    receipts = context.get("relevant_receipts", [])

    # WO-ECHO-ASK-02: Use chunk relevance scores when available (MemoryIndex mode)
    if chunks:
        top_score = chunks[0].relevance_score if chunks else 0

        # Hard cap when relevance is clearly low (irrelevant queries score up to ~0.26)
        if top_score < 0.27:
            return 0.2, "weak_match"

        # Moderate confidence for partial matches (0.27-0.32)
        if top_score < 0.32:
            # Scale 0.27→0.35 to 0.32→0.55
            conf = 0.35 + (top_score - 0.27) * 4.0  # 0.27→0.35, 0.32→0.55
            return conf, "partial_match"

        # Count chunks above partial threshold
        relevant_count = sum(1 for c in chunks if c.relevance_score > 0.28)

        # Count receipt sources (stronger evidence than code)
        receipt_count = sum(1 for c in chunks if c.source_type == "receipt")

        # Count PASS status (strongest evidence)
        pass_count = sum(1 for c in chunks
                        if hasattr(c, 'text') and any(s in c.text.upper() for s in ["PASS", "PROVEN", "✅"]))

        # Base: scale from 0.55 to 0.85 based on top score (0.32→0.55, 0.50→0.85)
        base = 0.55 + (min(top_score, 0.50) - 0.32) * 1.67

        # Boost for multiple relevant hits (+0.1 max)
        relevance_boost = min(relevant_count / 5, 0.1)

        # Boost for receipt evidence (+0.05 max - most sources are receipts anyway)
        receipt_boost = min(receipt_count / 5, 0.05)

        # Boost for PASS/PROVEN status (+0.1 max)
        pass_boost = min(pass_count / 2, 0.1)

        final_conf = min(base + relevance_boost + receipt_boost + pass_boost, 0.95)

        # Determine reason based on evidence strength
        if pass_count > 0:
            return final_conf, "strongly_supported"
        return final_conf, "supported"

    # Fallback mode: use keyword scores (MUCH less reliable than vector search)
    # WO-ECHO-ASK-02: Keyword matching is noisy - cap confidence at 0.50 max
    if not receipts:
        return 0.1, "no_evidence"

    top_score = receipts[0].get("score", 0) if receipts else 0

    # Strict caps for keyword mode - lexical matching has high false positive rate
    if top_score < 4:
        return 0.15, "weak_match"
    if top_score < 7:
        return 0.25, "partial_match"

    # Only allow >0.35 if we have multiple strong hits AND proof tokens in matched text
    PROOF_TOKENS = ["PASS", "PROVEN", "✅", "COSINE=", "SHA256", "VERIFIED"]
    strong_hits = [r for r in receipts if r.get("score", 0) >= 7]

    # Check if any strong hit contains explicit proof tokens
    has_proof_token = any(
        any(tok in (r.get("text", "") + r.get("summary", "")).upper() for tok in PROOF_TOKENS)
        for r in strong_hits
    )

    # Base: 0.30 + up to 0.10 for multiple strong hits
    conf = 0.30 + min(len(strong_hits) / 10, 0.10)

    # Only boost for proof tokens if they're actually present
    if has_proof_token:
        conf += 0.10  # up to 0.50 max

    # Hard cap at 0.50 for keyword mode - never trust it more than that
    final_conf = min(conf, 0.50)

    if has_proof_token:
        return final_conf, "supported"
    return final_conf, "partial_match"


def _build_proof_summary(proof_status: Dict[str, str]) -> Dict[str, List[str]]:
    """
    Build structured proof summary from CLAUDE.md proof status.

    WO-ECHO-ASK-02: Categorizes claims into proven/disproven/unverified.
    """
    summary = {
        "proven": [],
        "disproven": [],
        "unverified": [],
    }

    if not proof_status:
        return summary

    for claim, status in proof_status.items():
        status_lower = status.lower()
        if any(kw in status_lower for kw in ["✅", "proven", "pass", "verified"]):
            summary["proven"].append(claim)
        elif any(kw in status_lower for kw in ["❌", "fail", "disproven"]):
            summary["disproven"].append(claim)
        elif any(kw in status_lower for kw in ["⚠️", "acceptable", "partial"]):
            summary["proven"].append(f"{claim} (with caveats)")
        else:
            summary["unverified"].append(claim)

    return summary


@app.post("/echo/ask", response_model=EchoAskResponse)
async def echo_ask(request: EchoAskRequest):
    """
    Ask natural language questions about the helix-cdc project.

    WO-ECHO-ASK-01: Conversational Echo API
    WO-ECHO-ASK-02: MemoryIndex-first retrieval with keyword fallback

    Returns answers based on:
    - MemoryIndex search (207k+ chunks, hybrid BM25 + vector)
    - Proof receipts in receipts/
    - CLAUDE.md proof status table
    - Work order completion status

    Examples:
    - "What's the status of CDNAv2 streaming?"
    - "Is SVD working?"
    - "What work orders are complete?"
    """
    # 1. Build context (WO-ECHO-ASK-02: MemoryIndex-first)
    context = _build_echo_context(request.question, request.top_k)

    # 2. Get proof status if requested
    proof_status = _get_proof_status() if request.include_proof_status else None

    # WO-ECHO-ASK-02: Build proof summary
    proof_summary = _build_proof_summary(proof_status) if proof_status else None

    # 3. Generate answer
    if request.use_llm:
        answer = _generate_llm_answer(request.question, context, proof_status)
        mode = "llm"
    else:
        answer = _generate_structured_answer(request.question, context, proof_status)
        mode = "structured"

    # 4. Build sources list (WO-ECHO-ASK-02: include chunk metadata)
    chunks = context.get("chunks", [])
    receipts = context.get("relevant_receipts", [])

    # WO-ECHO-REMEMBER-01: Extract notes from receipts (they were added first)
    notes = [r for r in receipts if r.get("source_type") == "note"]
    note_sources = [
        EchoSource(
            path=n["path"],
            status=None,
            snippet=n.get("summary", "")[:200],
            relevance_score=float(n.get("score", 0.5)),
            chunk_id=n.get("chunk_id"),
            source_type="note",
            sha256=n.get("sha256"),
        )
        for n in notes[:3]  # Include up to 3 notes
    ]

    if chunks:
        # MemoryIndex mode: build sources from chunks
        chunk_sources = [
            EchoSource(
                path=c.source_path,
                status=_extract_status_from_text(c.text),
                snippet=c.text[:200].replace("\n", " ").strip(),
                relevance_score=c.relevance_score,
                chunk_id=c.id,
                source_type=c.source_type,
                sha256=c.sha256,
            )
            for c in chunks[:request.top_k]
        ]
        # Merge notes with chunk sources, notes first (they're session memory)
        sources = note_sources + chunk_sources
        sources = sources[:request.top_k + len(note_sources)]  # Allow notes to exceed top_k slightly
    else:
        # Fallback mode: build sources from keyword receipts
        non_note_receipts = [r for r in receipts if r.get("source_type") != "note"]
        receipt_sources = [
            EchoSource(
                path=r["path"],
                status=r.get("status"),
                snippet=r.get("summary", "")[:200] if r.get("summary") else "",
                relevance_score=float(r.get("score", 0)) / 10.0,  # Normalize keyword score
                chunk_id=r.get("chunk_id"),
                source_type=r.get("source_type", "receipt"),
                sha256=r.get("sha256"),
            )
            for r in non_note_receipts[:request.top_k]
        ]
        sources = note_sources + receipt_sources

    # 5. Estimate confidence (WO-ECHO-ASK-02: calibrated, Phase 2: with reason)
    confidence, confidence_reason = _estimate_confidence(context)

    return EchoAskResponse(
        answer=answer,
        sources=sources,
        proof_status=proof_status,
        proof_summary=proof_summary,
        confidence=confidence,
        confidence_reason=confidence_reason,
        calibration_version="v1.0",
        timestamp=datetime.now(timezone.utc).isoformat(),
        mode=mode,
        retrieval_mode=context.get("retrieval_mode", "unknown"),
    )


def _extract_status_from_text(text: str) -> Optional[str]:
    """Extract status from chunk text."""
    text_upper = text.upper()
    if "PASS" in text_upper or "PROVEN" in text_upper or "✅" in text:
        return "PASS"
    elif "FAIL" in text_upper or "DISPROVEN" in text_upper or "❌" in text:
        return "FAIL"
    elif "STATUS:" in text_upper:
        status_match = re.search(r"STATUS:\s*(\w+)", text, re.IGNORECASE)
        if status_match:
            return status_match.group(1).upper()
    return None


# --- Echo Remember Endpoint (WO-ECHO-REMEMBER-01) ---

class RememberRequest(BaseModel):
    """Request for /echo/remember endpoint."""
    note: str = Field(..., min_length=1, max_length=10000, description="What to remember")
    category: str = Field(
        default="learning",
        description="Category: learning, decision, context, warning, preference"
    )
    tags: List[str] = Field(default=[], description="Searchable tags")
    ttl_days: Optional[int] = Field(default=None, ge=1, description="Auto-expire after N days")
    session_id: Optional[str] = Field(default=None, description="Group related notes")

    @field_validator('category')
    @classmethod
    def validate_category(cls, v):
        valid = {"learning", "decision", "context", "warning", "preference"}
        if v not in valid:
            raise ValueError(f"category must be one of {valid}")
        return v


class RememberResponse(BaseModel):
    """Response from /echo/remember endpoint."""
    note_id: str
    indexed: bool
    receipt_path: str
    sha256: str
    timestamp: str
    category: str
    tags: List[str]


# Notes database connection (lazy init)
_notes_db_conn = None

# EchoMemory connection (lazy init) - WO-ECHO-BRIDGE-01
_echo_memory = None


def _get_echo_memory():
    """Get or initialize EchoMemory for crash-resilient state."""
    global _echo_memory
    if _echo_memory is not None:
        return _echo_memory
    try:
        # Try helix-cdc local echo_memory first
        import sys
        echo_memory_path = PROJECT_ROOT / "echo_memory.py"
        if echo_memory_path.exists():
            import importlib.util
            spec = importlib.util.spec_from_file_location("echo_memory", echo_memory_path)
            module = importlib.util.module_from_spec(spec)
            spec.loader.exec_module(module)
            _echo_memory = module.EchoMemory()
        else:
            # Fallback to installed module
            from echo_memory import EchoMemory
            _echo_memory = EchoMemory()
        return _echo_memory
    except Exception as e:
        print(f"[Echo] EchoMemory init failed: {e}")
        return None


def _get_notes_db():
    """Get or create notes SQLite database with FTS5."""
    global _notes_db_conn

    if _notes_db_conn is not None:
        return _notes_db_conn

    notes_dir = PROJECT_ROOT / "notes"
    notes_dir.mkdir(exist_ok=True)

    db_path = notes_dir / "notes.sqlite"
    _notes_db_conn = sqlite3.connect(str(db_path), check_same_thread=False)

    # Create tables if not exist
    cur = _notes_db_conn.cursor()
    cur.execute("""
        CREATE TABLE IF NOT EXISTS notes (
            note_id TEXT PRIMARY KEY,
            note TEXT NOT NULL,
            category TEXT NOT NULL,
            tags TEXT,
            session_id TEXT,
            ttl_days INTEGER,
            created_at TEXT NOT NULL,
            expires_at TEXT,
            receipt_path TEXT,
            sha256 TEXT
        )
    """)

    # Create FTS5 virtual table for full-text search
    cur.execute("""
        CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
            note_id,
            note,
            category,
            tags,
            content='notes',
            content_rowid='rowid'
        )
    """)

    # Create triggers to keep FTS in sync
    cur.execute("""
        CREATE TRIGGER IF NOT EXISTS notes_ai AFTER INSERT ON notes BEGIN
            INSERT INTO notes_fts(rowid, note_id, note, category, tags)
            VALUES (new.rowid, new.note_id, new.note, new.category, new.tags);
        END
    """)

    cur.execute("""
        CREATE TRIGGER IF NOT EXISTS notes_ad AFTER DELETE ON notes BEGIN
            INSERT INTO notes_fts(notes_fts, rowid, note_id, note, category, tags)
            VALUES('delete', old.rowid, old.note_id, old.note, old.category, old.tags);
        END
    """)

    _notes_db_conn.commit()
    return _notes_db_conn


def _search_notes(query: str, limit: int = 10) -> List[Dict[str, Any]]:
    """Search notes using FTS5."""
    conn = _get_notes_db()
    cur = conn.cursor()

    results = []
    try:
        # FTS5 search
        cur.execute("""
            SELECT n.note_id, n.note, n.category, n.tags, n.created_at,
                   bm25(notes_fts) as score
            FROM notes_fts
            JOIN notes n ON notes_fts.note_id = n.note_id
            WHERE notes_fts MATCH ?
            ORDER BY score
            LIMIT ?
        """, (query, limit))

        for row in cur.fetchall():
            results.append({
                "note_id": row[0],
                "note": row[1],
                "category": row[2],
                "tags": row[3].split(",") if row[3] else [],
                "created_at": row[4],
                "score": -row[5],  # Negate FTS5 scores (lower is better)
                "source_type": "note",
            })
    except sqlite3.OperationalError:
        # Fall back to LIKE search
        terms = re.findall(r'\b\w+\b', query.lower())
        if terms:
            like_clause = " OR ".join(["note LIKE ?" for _ in terms])
            like_params = [f"%{term}%" for term in terms]
            cur.execute(f"""
                SELECT note_id, note, category, tags, created_at
                FROM notes
                WHERE {like_clause}
                LIMIT ?
            """, like_params + [limit])

            for row in cur.fetchall():
                results.append({
                    "note_id": row[0],
                    "note": row[1],
                    "category": row[2],
                    "tags": row[3].split(",") if row[3] else [],
                    "created_at": row[4],
                    "score": 0.5,
                    "source_type": "note",
                })

    return results


def _search_echo_memory(query: str, limit: int = 5) -> List[Dict[str, Any]]:
    """
    WO-ECHO-BRIDGE-02: Search EchoMemory checkpoints and conversations.

    Returns results with normalized scores in [0.35, 0.65] band.
    """
    echo_mem = _get_echo_memory()
    if not echo_mem:
        return []

    results = []
    query_lower = query.lower()
    query_words = set(w for w in query_lower.split() if len(w) > 2)

    if not query_words:
        return []

    try:
        # Search checkpoints with note: prefix
        with sqlite3.connect(echo_mem.db_path) as conn:
            cur = conn.cursor()
            cur.execute("""
                SELECT key, value, updated_at FROM memory
                WHERE key LIKE 'note:%'
                ORDER BY updated_at DESC LIMIT ?
            """, (limit * 3,))

            for key, value_json, updated_at in cur.fetchall():
                try:
                    value = json.loads(value_json)
                    text = value.get("note", "")
                    text_lower = text.lower()

                    # Simple relevance scoring based on word overlap
                    matches = sum(1 for w in query_words if w in text_lower)
                    score = matches / max(len(query_words), 1)

                    if score > 0.1:
                        # Normalize to [0.35, 0.65] band
                        normalized_score = 0.35 + (min(score, 1.0) * 0.30)
                        results.append({
                            "source_type": "echo_memory",
                            "path": f"echo_memory:{key}",
                            "text": text,
                            "score": normalized_score,
                            "category": value.get("category"),
                            "timestamp": updated_at,
                            "sha256": value.get("sha256"),
                            "tags": value.get("tags", []),
                        })
                except (json.JSONDecodeError, KeyError):
                    continue

        # Sort by score descending
        results.sort(key=lambda x: -x["score"])
        return results[:limit]

    except Exception as e:
        print(f"[Echo] _search_echo_memory error: {e}")
        return []


@app.post("/echo/remember", response_model=RememberResponse)
async def echo_remember(request: RememberRequest):
    """
    Store a note in Echo's memory for future retrieval.

    WO-ECHO-REMEMBER-01: Claude → Echo session memory

    Notes are:
    - Immediately searchable via /echo/ask
    - Persisted as JSON receipts
    - Categorized for filtering

    Categories:
    - learning: "X doesn't work because Y"
    - decision: "We chose approach A over B"
    - context: "User is working on compression"
    - warning: "Don't try X, it breaks Y"
    - preference: "User likes short responses"

    Examples:
    - {"note": "SVD fails on V/Output tensors - high rank", "category": "learning"}
    - {"note": "User prefers receipts-first verification", "category": "preference"}
    """
    # Generate note ID
    timestamp = datetime.now(timezone.utc)
    note_id = hashlib.sha256(
        f"{request.note}:{timestamp.isoformat()}".encode()
    ).hexdigest()[:16]

    # Compute SHA256 of note content
    note_sha256 = hashlib.sha256(request.note.encode()).hexdigest()

    # Calculate expiration if TTL set
    expires_at = None
    if request.ttl_days:
        from datetime import timedelta
        expires_at = (timestamp + timedelta(days=request.ttl_days)).isoformat()

    # Create receipt
    receipt = {
        "type": "echo_note",
        "note_id": note_id,
        "timestamp_utc": timestamp.isoformat(),
        "content": {
            "note": request.note,
            "category": request.category,
            "tags": request.tags,
        },
        "provenance": {
            "source": "claude_session",
            "session_id": request.session_id,
        },
        "ttl_days": request.ttl_days,
        "expires_at": expires_at,
        "sha256": note_sha256,
    }

    # Save receipt to file
    date_str = timestamp.strftime("%Y-%m-%d")
    notes_dir = PROJECT_ROOT / "notes" / date_str
    notes_dir.mkdir(parents=True, exist_ok=True)

    receipt_path = notes_dir / f"note_{note_id}.json"
    with open(receipt_path, "w") as f:
        json.dump(receipt, f, indent=2)

    # Index in SQLite for immediate searchability
    conn = _get_notes_db()
    cur = conn.cursor()

    try:
        cur.execute("""
            INSERT INTO notes (note_id, note, category, tags, session_id,
                              ttl_days, created_at, expires_at, receipt_path, sha256)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        """, (
            note_id,
            request.note,
            request.category,
            ",".join(request.tags),
            request.session_id,
            request.ttl_days,
            timestamp.isoformat(),
            expires_at,
            str(receipt_path.relative_to(PROJECT_ROOT)),
            note_sha256,
        ))
        conn.commit()
        indexed = True
    except sqlite3.IntegrityError:
        # Note already exists
        indexed = False

    # WO-ECHO-BRIDGE-01: Also checkpoint to EchoMemory
    echo_mem = _get_echo_memory()
    bridge_path = None
    if echo_mem:
        try:
            # Checkpoint with note: prefix for easy retrieval
            echo_mem.checkpoint(
                key=f"note:{note_id}",
                value={
                    "note": request.note,
                    "category": request.category,
                    "tags": request.tags,
                    "sha256": note_sha256,
                    "timestamp": timestamp.isoformat(),
                    "session_id": request.session_id,
                }
            )

            # Also add as conversation message for Se tracking
            if request.session_id:
                echo_mem.add_message(
                    session_id=request.session_id,
                    role="claude",
                    content=request.note,
                    metadata={"category": request.category, "tags": request.tags}
                )

            # Write bridge receipt linking both stores
            bridge_receipt = {
                "type": "echo_bridge",
                "note_id": note_id,
                "echo_memory_key": f"note:{note_id}",
                "echo_memory_db": str(echo_mem.db_path) if hasattr(echo_mem, 'db_path') else None,
                "notes_db": str(PROJECT_ROOT / "notes" / "notes.sqlite"),
                "sha256": note_sha256,
                "timestamp_utc": timestamp.isoformat(),
            }
            bridge_path = notes_dir / f"bridge_{note_id}.json"
            with open(bridge_path, "w") as f:
                json.dump(bridge_receipt, f, indent=2)
        except Exception as e:
            print(f"[Echo] EchoMemory bridge failed: {e}")

    return RememberResponse(
        note_id=note_id,
        indexed=indexed,
        receipt_path=str(receipt_path.relative_to(PROJECT_ROOT)),
        sha256=note_sha256,
        timestamp=timestamp.isoformat(),
        category=request.category,
        tags=request.tags,
    )


@app.get("/echo/notes")
async def list_notes(
    category: Optional[str] = None,
    limit: int = 50,
    include_expired: bool = False,
):
    """
    List all stored notes with optional filtering.

    WO-ECHO-REMEMBER-01: Browse Echo's session memory
    """
    conn = _get_notes_db()
    cur = conn.cursor()

    query = "SELECT note_id, note, category, tags, created_at, expires_at FROM notes"
    params = []
    conditions = []

    if category:
        conditions.append("category = ?")
        params.append(category)

    if not include_expired:
        conditions.append("(expires_at IS NULL OR expires_at > ?)")
        params.append(datetime.now(timezone.utc).isoformat())

    if conditions:
        query += " WHERE " + " AND ".join(conditions)

    query += " ORDER BY created_at DESC LIMIT ?"
    params.append(limit)

    cur.execute(query, params)

    notes = []
    for row in cur.fetchall():
        notes.append({
            "note_id": row[0],
            "note": row[1][:200] + "..." if len(row[1]) > 200 else row[1],
            "category": row[2],
            "tags": row[3].split(",") if row[3] else [],
            "created_at": row[4],
            "expires_at": row[5],
        })

    return {
        "notes": notes,
        "count": len(notes),
        "timestamp": datetime.now(timezone.utc).isoformat(),
    }


# --- Plan Critic Endpoint (WO-ECHO-MIRROR-TRAIN-01) ---

class CritiquePlanRequest(BaseModel):
    """Request for plan critique."""
    plan_text: str = Field(..., description="The plan text to evaluate")
    top_k: int = Field(default=5, ge=1, le=20, description="Number of evidence chunks to retrieve")
    risk_threshold: float = Field(default=0.5, ge=0.0, le=1.0, description="Threshold for risk flag prediction")


class CritiquePlanResponse(BaseModel):
    """Response from plan critique."""
    status: str  # PASS / AMBER / FAIL
    confidence: float
    evidence: List[str]
    findings: List[str]  # Risk flags
    risk_probs: Dict[str, float]
    label_probs: Dict[str, float]
    suggested_actions: List[str]
    receipt_id: Optional[str] = None


# Global critic model cache
_critic_model = None
_critic_encoder = None
_critic_loaded = False


def _load_critic_model():
    """Load Plan Critic model if not already loaded."""
    global _critic_model, _critic_encoder, _critic_loaded

    if _critic_loaded:
        return _critic_model, _critic_encoder

    try:
        import torch
        from helix_cdc.training.plan_critic import (
            PlanCritic,
            SimpleTextEncoder,
        )

        # Try to load from bundle (WO-ECHO-MEMORY-PIPELINE-03: bundle > seed fallback)
        bundle_path = PROJECT_ROOT / "seeds" / "critic_v1_bundle.pt"
        if not bundle_path.exists():
            # Fallback to old "seed" name for backwards compatibility
            bundle_path = PROJECT_ROOT / "seeds" / "critic_v1_seed.pt"
        if not bundle_path.exists():
            # Final fallback to raw trained model
            bundle_path = PROJECT_ROOT / "seeds" / "critic_v1.pt"

        if bundle_path.exists():
            print(f"[Critic] Loading model from {bundle_path}...")
            bundle_data = torch.load(bundle_path, map_location="cpu")

            config = bundle_data.get("config", {})
            _critic_model = PlanCritic.from_config(config)
            _critic_model.load_state_dict(bundle_data["model_state_dict"])
            _critic_model.eval()

            encoder_config = bundle_data.get("encoder_config", {})
            _critic_encoder = SimpleTextEncoder(
                embed_dim=encoder_config.get("embed_dim", 384),
                vocab_size=encoder_config.get("vocab_size", 256),
            )

            print(f"[Critic] Model loaded successfully (format={bundle_data.get('format', 'unknown')})")
            _critic_loaded = True
        else:
            print(f"[Critic] WARNING: Model not found at {bundle_path}")
            _critic_model = None
            _critic_encoder = None

    except Exception as e:
        print(f"[Critic] ERROR loading model: {e}")
        _critic_model = None
        _critic_encoder = None

    return _critic_model, _critic_encoder


def _retrieve_evidence(plan_text: str, top_k: int = 5) -> List[str]:
    """
    Retrieve relevant evidence using MemoryIndex hybrid search.

    WO-ECHO-MEMORY-PIPELINE-02: Upgraded from keyword scan to semantic search.
    Falls back to keyword-based retrieval if index not available.
    """
    # Try MemoryIndex first (WO-ECHO-MEMORY-PIPELINE-02)
    try:
        from helix_cdc.echo.tools.memory import MemoryIndex
        idx = MemoryIndex()
        chunks = idx.search(plan_text, k=top_k)
        if chunks:
            return [
                f"[{c.source_path}] {c.text[:200]}... (score={c.relevance_score:.2f})"
                for c in chunks
            ]
    except FileNotFoundError:
        # Index not built yet - fall through to keyword fallback
        pass
    except ImportError:
        # Module not available - fall through to keyword fallback
        pass
    except Exception as e:
        # Log but continue with fallback
        print(f"[Critic] MemoryIndex error: {e}")

    # Fallback: keyword-based retrieval
    evidence = []
    receipts_dir = PROJECT_ROOT / "receipts"
    if not receipts_dir.exists():
        return ["No memory index found. Run: python -m helix_cdc.echo.memory build"]

    # Extract keywords from plan
    keywords = set(plan_text.lower().split())
    keywords = {w for w in keywords if len(w) > 3}  # Filter short words

    scored_receipts = []

    for path in list(receipts_dir.rglob("*.json"))[:100]:  # Limit scan
        try:
            content = json.loads(path.read_text())

            # Compute simple relevance score
            text = json.dumps(content).lower()
            score = sum(1 for kw in keywords if kw in text)

            if score > 0:
                # Extract summary from receipt
                summary = ""
                if "status" in content:
                    summary += f"Status: {content['status']}. "
                if "conclusion" in content and "skeptic_killer" in content["conclusion"]:
                    summary += content["conclusion"]["skeptic_killer"][:200]
                elif "work_order" in content:
                    summary += f"Work order: {content['work_order']}"

                if summary:
                    scored_receipts.append((score, summary))

        except Exception:
            continue

    # Sort by score and take top_k
    scored_receipts.sort(key=lambda x: -x[0])
    evidence = [s[1] for s in scored_receipts[:top_k]]

    # Add default evidence if none found
    if not evidence:
        evidence = ["No matching receipts found for this plan. (fallback mode - build index for better results)"]

    return evidence


def _generate_suggested_actions(status: str, risk_flags: List[str]) -> List[str]:
    """Generate suggested actions based on status and risk flags."""
    actions = []

    if status == "FAIL":
        actions.append("Review and address all failing acceptance gates")

    if "missing_receipt" in risk_flags:
        actions.append("Generate proof receipts for claimed functionality")
    if "missing_prerequisite" in risk_flags:
        actions.append("Complete prerequisite work orders first")
    if "low_cosine_similarity" in risk_flags:
        actions.append("Investigate accuracy degradation")
    if "accuracy_below_threshold" in risk_flags:
        actions.append("Debug model/algorithm to improve accuracy")
    if "gate_failing" in risk_flags:
        actions.append("Fix failing acceptance gate conditions")
    if "gqa_not_faithful" in risk_flags:
        actions.append("Implement faithful GQA head grouping")
    if "regression_risk" in risk_flags:
        actions.append("Add regression tests before proceeding")
    if "vram_exceeded" in risk_flags:
        actions.append("Reduce batch size or model offload more layers to CPU")

    if status == "PASS" and not actions:
        actions.append("Proceed with implementation")

    if status == "AMBER" and not actions:
        actions.append("Review flagged concerns before proceeding")

    return actions


@app.post("/critique_plan", response_model=CritiquePlanResponse)
async def critique_plan(request: CritiquePlanRequest):
    """
    Evaluate a plan and return risk assessment.

    WO-ECHO-MIRROR-TRAIN-01: Echo Critic endpoint

    Returns:
        - status: PASS / AMBER / FAIL
        - confidence: Model confidence in prediction
        - evidence: Retrieved relevant receipts
        - findings: Detected risk flags
        - suggested_actions: Recommended next steps
    """
    import torch

    # Load model
    model, encoder = _load_critic_model()

    # Retrieve evidence
    evidence = _retrieve_evidence(request.plan_text, request.top_k)

    if model is None or encoder is None:
        # Fallback: heuristic-based critique
        findings = []
        status = "AMBER"
        confidence = 0.5

        # Simple heuristics
        plan_lower = request.plan_text.lower()
        if "fail" in plan_lower or "broken" in plan_lower:
            findings.append("regression_risk")
            status = "FAIL"
        if "todo" in plan_lower or "not implemented" in plan_lower:
            findings.append("missing_prerequisite")
        if "test" not in plan_lower and "verify" not in plan_lower:
            findings.append("missing_receipt")

        if not findings:
            status = "PASS"
            confidence = 0.6

        label_probs = {"PASS": 0.0, "AMBER": 0.0, "FAIL": 0.0}
        label_probs[status] = confidence

        risk_probs = {flag: 0.5 if flag in findings else 0.1 for flag in [
            "missing_receipt", "missing_prerequisite", "low_cosine_similarity",
            "accuracy_below_threshold", "gate_failing", "gqa_not_faithful",
            "training_divergence", "hardware_failure", "vram_exceeded", "regression_risk"
        ]}

        return CritiquePlanResponse(
            status=status,
            confidence=confidence,
            evidence=evidence,
            findings=findings,
            risk_probs=risk_probs,
            label_probs=label_probs,
            suggested_actions=_generate_suggested_actions(status, findings),
        )

    # Encode plan
    plan_embed = encoder.encode(request.plan_text)

    # Encode and pool evidence
    if evidence:
        evidence_embeds = encoder.encode_batch(evidence)
        evidence_embed = evidence_embeds.mean(dim=0)
    else:
        evidence_embed = torch.zeros(encoder.embed_dim)

    # Run model
    output = model.predict(
        plan_embed,
        evidence_embed,
        risk_threshold=request.risk_threshold,
    )

    # Generate receipt ID
    receipt_id = hashlib.sha256(
        f"{request.plan_text}:{datetime.now(timezone.utc).isoformat()}".encode()
    ).hexdigest()[:16]

    # Build response
    from helix_cdc.training.plan_critic import RISK_FLAG_NAMES, LABEL_NAMES

    # Handle potential mismatch between model output and LABEL_NAMES
    # Model may have been trained with fewer labels than LABEL_NAMES defines
    num_model_labels = len(output.label_probs)
    label_probs = {
        name: output.label_probs[i] if i < num_model_labels else 0.0
        for i, name in enumerate(LABEL_NAMES)
    }
    risk_probs = {name: output.risk_probs[i] for i, name in enumerate(RISK_FLAG_NAMES)}

    return CritiquePlanResponse(
        status=output.label,
        confidence=output.confidence,
        evidence=evidence,
        findings=output.risk_flags,
        risk_probs=risk_probs,
        label_probs=label_probs,
        suggested_actions=_generate_suggested_actions(output.label, output.risk_flags),
        receipt_id=receipt_id,
    )


# --- CLI Entry Point ---

def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--host", default="0.0.0.0", help="Host to bind to")
    ap.add_argument("--port", type=int, default=8001, help="Port to bind to")
    ap.add_argument("--model", type=str, help="Path to GGUF model file")
    ap.add_argument("--reload", action="store_true", help="Enable auto-reload")
    args = ap.parse_args()

    global _model_path
    if args.model:
        _model_path = args.model

    import uvicorn
    uvicorn.run(
        "api.basin_server:app",
        host=args.host,
        port=args.port,
        reload=args.reload,
    )


if __name__ == "__main__":
    main()
