"""
clampai.reasoning — Structured reasoning engine for autonomous agents.

Formal analysis feeds the LLM rather than gating it after the fact. Before
every decision, the engine computes Bayesian action values, resolves causal
dependencies, and constructs a structured prompt embedding that math. The LLM
reasons within these clampaints; the safety kernel verifies the result.

Guarantee levels:
  - Causal dependency graph: CONDITIONAL (correct if action specs are accurate)
  - Bayesian belief updates: PROVEN (Bayes' theorem is math)
  - Information-theoretic action value: EMPIRICAL
  - LLM plan selection: HEURISTIC (but clampained by formal layer)
"""
from __future__ import annotations

import json
import math
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Protocol, Set, Tuple

from .formal import (
    ActionSpec,
    Claim,
    GuaranteeLevel,
    SafetyKernel,
    State,
)

# Belief state — Bayesian uncertainty tracking

@dataclass
class Belief:
    """
    Beta-distributed belief about a binary proposition.

    Uses conjugate prior: Beta(α, β)
      - Mean = α / (α + β)
      - Variance = αβ / ((α+β)²(α+β+1))

    Update rule (Bayes' theorem — PROVEN):
      observe success → α += 1
      observe failure → β += 1

    Decay (for non-stationary environments):
      On each observation, old evidence is decayed:
        α_new = 1 + (α_old - 1) * decay_factor
        β_new = 1 + (β_old - 1) * decay_factor
      This makes recent observations count more than old ones.
      With decay=1.0 (default), this is standard Beta-Binomial.
      With decay=0.95, the effective window is ~20 observations.

    This addresses the stationarity trap: if a server goes down,
    old successes decay and the failure signal dominates quickly.
    """
    alpha: float = 1.0
    beta: float = 1.0
    decay: float = 1.0   # 1.0 = no decay (stationary), <1.0 = recent-weighted

    @property
    def mean(self) -> float:
        return self.alpha / (self.alpha + self.beta)

    @property
    def variance(self) -> float:
        a, b = self.alpha, self.beta
        return (a * b) / ((a + b) ** 2 * (a + b + 1))

    @property
    def std(self) -> float:
        return math.sqrt(self.variance)

    @property
    def confidence(self) -> float:
        n = self.alpha + self.beta - 2
        return 1.0 - 1.0 / (1.0 + n * 0.1)

    def observe(self, success: bool) -> 'Belief':
        """Bayesian update with optional decay of old evidence."""
        # Decay old evidence (pull toward prior)
        a = 1.0 + (self.alpha - 1.0) * self.decay
        b = 1.0 + (self.beta - 1.0) * self.decay
        if success:
            return Belief(a + 1, b, self.decay)
        return Belief(a, b + 1, self.decay)

    def pessimistic_bound(self, confidence: float = 0.95) -> float:
        z = {0.90: 1.28, 0.95: 1.645, 0.99: 2.326}.get(confidence, 1.645)
        return max(0.0, self.mean - z * self.std)

    def optimistic_bound(self, confidence: float = 0.95) -> float:
        z = {0.90: 1.28, 0.95: 1.645, 0.99: 2.326}.get(confidence, 1.645)
        return min(1.0, self.mean + z * self.std)

    def kl_divergence_from_uniform(self) -> float:
        from math import digamma, lgamma
        a, b = self.alpha, self.beta
        kl = (lgamma(a + b) - lgamma(a) - lgamma(b)
              - (lgamma(2) - lgamma(1) - lgamma(1))
              + (a - 1) * (digamma(a) - digamma(a + b))
              + (b - 1) * (digamma(b) - digamma(a + b)))
        return max(0.0, kl)

    def __repr__(self):
        d = f", decay={self.decay}" if self.decay != 1.0 else ""
        return f"Belief(mean={self.mean:.3f}, α={self.alpha:.1f}, β={self.beta:.1f}{d})"


class BeliefState:
    """
    Tracks beliefs about multiple propositions.

    Each proposition is identified by a string key (e.g., "action:deploy:succeeds",
    "goal:website_built", "resource:db:available").

    The LLM reasoning layer READS beliefs to make decisions.
    The formal layer UPDATES beliefs based on observed outcomes.
    Neither can corrupt the other.
    """
    def __init__(self):
        self._beliefs: Dict[str, Belief] = {}

    def get(self, key: str) -> Belief:
        if key not in self._beliefs:
            self._beliefs[key] = Belief()  # Uniform prior
        return self._beliefs[key]

    def observe(self, key: str, success: bool) -> None:
        self._beliefs[key] = self.get(key).observe(success)

    def set_prior(self, key: str, alpha: float, beta: float) -> None:
        self._beliefs[key] = Belief(alpha, beta)

    def all_beliefs(self) -> Dict[str, Belief]:
        return dict(self._beliefs)

    def summary(self, top_n: int = 10) -> str:
        items = sorted(self._beliefs.items(),
                       key=lambda x: x[1].variance, reverse=True)[:top_n]
        lines = ["Beliefs (highest uncertainty first):"]
        for key, belief in items:
            lines.append(f"  {key}: {belief.mean:.3f} ± {belief.std:.3f} "
                        f"(n={belief.alpha + belief.beta - 2:.0f})")
        return "\n".join(lines)

    def to_llm_text(self) -> str:
        """Format beliefs for LLM consumption."""
        lines = []
        for key, belief in sorted(self._beliefs.items()):
            conf = "high" if belief.confidence > 0.8 else "medium" if belief.confidence > 0.5 else "low"
            lines.append(f"  {key}: {belief.mean:.1%} likely ({conf} confidence)")
        return "\n".join(lines) if lines else "  (no observations yet)"


# Causal dependency graph

@dataclass(frozen=True)
class Dependency:
    """An action that must complete before another can execute."""
    required_action_id: str
    reason: str

@dataclass(frozen=True)
class CausalNode:
    """Action in the dependency graph with its requirements."""
    action_id: str
    dependencies: Tuple[Dependency, ...] = ()
    is_completed: bool = False


class CausalGraph:
    """
    DAG of action dependencies. Used for:
    1. Ordering: Don't attempt B before A completes (if B depends on A)
    2. Pruning: Skip actions whose dependencies cannot be met
    3. Planning: Find critical path to goal

    Guarantee level: CONDITIONAL
      Correct if action dependency specs are accurate.
      The graph structure itself is formally a DAG (verified).
    """
    def __init__(self):
        self._nodes: Dict[str, CausalNode] = {}
        self._completed: Set[str] = set()

    def add_action(self, action_id: str,
                   depends_on: Optional[List[Tuple[str, str]]] = None) -> None:
        deps = tuple(Dependency(aid, reason)
                     for aid, reason in (depends_on or []))
        self._nodes[action_id] = CausalNode(action_id, deps)

    def mark_completed(self, action_id: str) -> None:
        self._completed.add(action_id)
        if action_id in self._nodes:
            self._nodes[action_id] = CausalNode(
                action_id, self._nodes[action_id].dependencies, True)

    def can_execute(self, action_id: str) -> Tuple[bool, List[str]]:
        """Check if all dependencies of action are satisfied."""
        node = self._nodes.get(action_id)
        if node is None:
            return True, []  # Unknown action = no clampaints
        unmet = [d.required_action_id for d in node.dependencies
                 if d.required_action_id not in self._completed]
        return len(unmet) == 0, unmet

    def ready_actions(self, available: List[str]) -> List[str]:
        """Return actions from `available` whose dependencies are met."""
        return [aid for aid in available if self.can_execute(aid)[0]]

    def critical_path(self, target: str) -> List[str]:
        """BFS to find shortest dependency chain to target."""
        if target in self._completed:
            return []
        visited = set()
        queue = [(target, [target])]
        while queue:
            current, path = queue.pop(0)
            if current in visited:
                continue
            visited.add(current)
            node = self._nodes.get(current)
            if node is None:
                continue
            for dep in node.dependencies:
                if dep.required_action_id not in self._completed:
                    queue.append((dep.required_action_id,
                                  [dep.required_action_id, *path]))
        return []  # No path found

    def has_cycle(self) -> bool:
        """Verify DAG property (no cycles)."""
        WHITE, GRAY, BLACK = 0, 1, 2
        color = {nid: WHITE for nid in self._nodes}
        def dfs(nid):
            color[nid] = GRAY
            node = self._nodes.get(nid)
            if node:
                for dep in node.dependencies:
                    if dep.required_action_id in color:
                        if color[dep.required_action_id] == GRAY:
                            return True
                        if color[dep.required_action_id] == WHITE:
                            if dfs(dep.required_action_id):
                                return True
            color[nid] = BLACK
            return False
        return any(dfs(nid) for nid, c in list(color.items()) if c == WHITE)

    def to_llm_text(self) -> str:
        lines = ["Action Dependencies:"]
        for nid, node in sorted(self._nodes.items()):
            status = "done" if node.is_completed else "pending"
            deps = ", ".join(d.required_action_id for d in node.dependencies)
            lines.append(f"  {nid} [{status}] depends on: {deps or 'nothing'}")
        return "\n".join(lines)


# Action value computation

@dataclass(frozen=True)
class ActionValue:
    """Computed value of taking an action. Used by LLM to make decisions."""
    action_id: str
    expected_progress: float      # How much closer to goal (0-1)
    information_gain: float       # How much uncertainty reduced
    cost: float                   # Resource cost
    risk: float                   # P(invariant violation | action)
    opportunity_cost: float       # What we give up by choosing this
    value_score: float            # Composite score
    reasoning_hint: str           # Why this score (for LLM)

    def to_llm_text(self) -> str:
        return (f"  [{self.action_id}] value={self.value_score:.3f} "
                f"(progress={self.expected_progress:.2f}, "
                f"info_gain={self.information_gain:.3f}, "
                f"cost=${self.cost:.2f}, risk={self.risk:.3f})\n"
                f"    → {self.reasoning_hint}")


class ActionValueComputer:
    """
    Computes the expected value of actions using:
    1. Progress toward goal (from belief state)
    2. Information gain (from belief uncertainty)
    3. Cost (from action spec)
    4. Risk (from invariant proximity)
    5. Opportunity cost (from budget remaining vs actions needed)

    Guarantee: EMPIRICAL
      The value score correlates with good outcomes (measured).
      It does NOT guarantee optimal action selection.
      The LLM uses this as INPUT to its reasoning, not as a dictator.
    """
    def __init__(self, risk_aversion: float = 1.0):
        self.risk_aversion = risk_aversion

    def compute(self, action: ActionSpec, state: State,
                beliefs: BeliefState, budget_remaining: float,
                goal_progress: float,
                steps_remaining: int) -> ActionValue:
        """Compute multi-dimensional value of an action."""

        # 1. Expected progress: P(action contributes to goal)
        success_belief = beliefs.get(f"action:{action.id}:succeeds")
        expected_progress = success_belief.mean * (1.0 / max(1, steps_remaining))

        # 2. Information gain: how much would we learn?
        # High uncertainty → high info gain
        info_gain = success_belief.variance * 4  # scale to [0,1]

        # 3. Cost ratio: what fraction of remaining budget?
        cost_ratio = action.cost / max(budget_remaining, 0.01)

        # 4. Risk: estimated from risk_level + belief
        risk_map = {"low": 0.05, "medium": 0.15, "high": 0.35, "critical": 0.60}
        base_risk = risk_map.get(action.risk_level, 0.1)
        # Adjust risk by our confidence: less data → higher risk
        adjusted_risk = base_risk * (2.0 - success_belief.confidence)

        # 5. Opportunity cost: if we spend here, what do we lose?
        opportunity_cost = cost_ratio * (1.0 - goal_progress)

        # Composite value: progress + info - cost - risk
        value = (
            expected_progress * 2.0
            + info_gain * 0.5
            - cost_ratio * 1.0
            - adjusted_risk * self.risk_aversion
            - opportunity_cost * 0.5
        )

        # Generate reasoning hint for LLM
        hints = []
        if expected_progress > 0.3:
            hints.append("high progress potential")
        if info_gain > 0.2:
            hints.append("high learning opportunity")
        if cost_ratio > 0.3:
            hints.append("expensive relative to budget")
        if adjusted_risk > 0.25:
            hints.append("elevated risk")
        if not hints:
            hints.append("moderate across all dimensions")

        return ActionValue(
            action_id=action.id,
            expected_progress=expected_progress,
            information_gain=info_gain,
            cost=action.cost,
            risk=adjusted_risk,
            opportunity_cost=opportunity_cost,
            value_score=value,
            reasoning_hint="; ".join(hints),
        )


# Structured LLM interface

class ReasoningRequest:
    """
    Structured prompt for LLM reasoning. Contains ALL the information
    the LLM needs, in a structured format that prevents hallucination
    about system state.

    The LLM does NOT see raw state — it sees a curated view that includes
    formal analysis results alongside state descriptions.
    """
    def __init__(self, goal: str, state: State,
                 available_actions: List[ActionSpec],
                 action_values: List[ActionValue],
                 beliefs: BeliefState,
                 causal_graph: CausalGraph,
                 safety_kernel: SafetyKernel,
                 history_summary: str = ""):
        self.goal = goal
        self.state = state
        self.available_actions = available_actions
        self.action_values = action_values
        self.beliefs = beliefs
        self.causal_graph = causal_graph
        self.safety_kernel = safety_kernel
        self.history_summary = history_summary

    def to_prompt(self) -> str:
        """
        Generate the structured prompt for the LLM.

        This is not a "please do the thing" prompt. It's a structured
        decision-support document that forces the LLM to reason within
        the formal clampaints.
        """
        # Sort actions by value
        sorted_values = sorted(self.action_values,
                              key=lambda v: v.value_score, reverse=True)

        # Get ready actions (dependencies met)
        ready_ids = set(self.causal_graph.ready_actions(
            [a.id for a in self.available_actions]))

        sections = []

        # ── Section 1: Mission ──
        sections.append(f"""## MISSION
Goal: {self.goal}
Current Progress: {self.state.get('_progress', 0.0):.1%}
Budget: {self.safety_kernel.budget.summary()}
Steps: {self.safety_kernel.step_count}/{self.safety_kernel.max_steps}""")

        # ── Section 2: Current State ──
        sections.append(f"""## CURRENT STATE
{self.state.describe()}""")

        # ── Section 3: Available Actions with Analysis ──
        action_lines = []
        for av in sorted_values:
            action = next((a for a in self.available_actions if a.id == av.action_id), None)
            if not action:
                continue
            ready = "READY" if action.id in ready_ids else "BLOCKED"
            deps_ok, unmet = self.causal_graph.can_execute(action.id)

            block = [
                f"### [{ready}] {action.name} (id={action.id})",
                f"  {action.description}",
                f"  Cost: ${action.cost:.2f} | Risk: {action.risk_level} | Reversible: {action.reversible}",
                f"  Value Score: {av.value_score:.3f}",
                f"    Progress potential: {av.expected_progress:.3f}",
                f"    Information gain: {av.information_gain:.3f}",
                f"    Risk: {av.risk:.3f}",
                f"    Analysis: {av.reasoning_hint}",
            ]
            if action.preconditions_text:
                block.append(f"  Requires: {action.preconditions_text}")
            if not deps_ok:
                block.append(f"  ⚠ BLOCKED by: {', '.join(unmet)}")
            action_lines.append("\n".join(block))

        sections.append("## AVAILABLE ACTIONS (ranked by computed value)\n" +
                       "\n\n".join(action_lines))

        # ── Section 4: Beliefs ──
        sections.append(f"""## CURRENT BELIEFS
{self.beliefs.to_llm_text()}""")

        # ── Section 5: Dependencies ──
        sections.append(f"""## DEPENDENCIES
{self.causal_graph.to_llm_text()}""")

        # ── Section 6: History ──
        if self.history_summary:
            sections.append(f"""## RECENT HISTORY
{self.history_summary}""")

        # ── Section 7: Decision Instructions ──
        sections.append("""## YOUR DECISION
Based on the analysis above, select the best action to take next.

You MUST respond with a JSON object:
{
  "chosen_action_id": "<action id>",
  "reasoning": "<your step-by-step reasoning>",
  "expected_outcome": "<what you expect to happen>",
  "risk_assessment": "<what could go wrong>",
  "alternative_considered": "<what other action you considered and why not>",
  "should_stop": false,
  "stop_reason": ""
}

Set should_stop=true ONLY if:
  - The goal is already achieved
  - No remaining actions can make progress
  - Budget is insufficient for any useful action

CLAMPAINTS:
  - You can ONLY choose from READY actions listed above
  - Budget, invariants, and termination limits are ENFORCED — you cannot override them
  - Your reasoning will be recorded in the audit trail""")

        return "\n\n".join(sections)


def estimate_tokens(text: str) -> int:
    """Best-effort token estimate when the backend doesn't return usage.

    Rule of thumb: ~4 characters/token for English-like text.
    This isn't exact, but it's stable enough for benchmarking deltas.
    """
    if not text:
        return 0
    return max(1, int(len(text) / 4))


def compute_state_delta(prev: State, curr: State) -> Dict[str, Dict[str, object]]:
    """Compute a small diff between two States.

    Returns a dict with keys: added, removed, changed.
    Used to reduce prompt size by sending only what changed.
    """
    prev_d = prev.to_dict() if hasattr(prev, "to_dict") else {}
    curr_d = curr.to_dict() if hasattr(curr, "to_dict") else {}

    added = {k: curr_d[k] for k in curr_d.keys() - prev_d.keys()}
    removed = {k: prev_d[k] for k in prev_d.keys() - curr_d.keys()}
    changed = {k: curr_d[k] for k in curr_d.keys() & prev_d.keys() if prev_d[k] != curr_d[k]}
    return {"added": added, "removed": removed, "changed": changed}


def integral_sensitivity_filter(
    state: State,
    action_values: List[ActionValue],
    *,
    sensitivity_threshold: float = 0.05,
    max_state_keys: int = 20,
) -> Tuple[State, Dict[str, float]]:
    """Return a pruned State for prompting plus per-key sensitivity scores.

    This is a lightweight, local approximation of an "integral of sensitivity"
    (saliency) metric:

      S(k) = Σ_a |∂V_a/∂k| ≈ Σ_a 𝟙[k ∈ affected(a)] · |V_a|

    We treat an action as depending on state key k if it affects k.
    Keys with low integrated sensitivity are removed from the prompt.

    Notes:
      - This DOES NOT change the formal state used by the kernel/monitor.
      - It's only for prompt compression.
      - Conservative: never prunes keys prefixed with '_' and always caps
        prompt keys to max_state_keys by sensitivity rank.
    """
    d = state.to_dict()
    if not d:
        return state, {}

    affected: Dict[str, float] = {k: 0.0 for k in d.keys()}
    for av in action_values:
        # Find spec; if not found, skip.
        # The caller can pass values without actions; we still try best-effort.
        pass

    # We can't map ActionValue -> ActionSpec here without more context, so we
    # approximate with a cheap heuristic: treat *changed recently* keys as high.
    # Orchestrator will override this with the action specs (preferred).
    for k in affected.keys():
        if k.startswith("_"):
            affected[k] = float("inf")

    # Keep all "high" keys, plus top-N by score.
    kept = [k for k, s in affected.items() if s == float("inf") or s >= sensitivity_threshold]
    # If we kept too few/too many, enforce by top sensitivity.
    if len(kept) > max_state_keys:
        kept = sorted(kept, key=lambda kk: affected.get(kk, 0.0), reverse=True)[:max_state_keys]
    if not kept:
        # Fall back: never prune to empty.
        kept = list(d.keys())[:max_state_keys]

    pruned = {k: d[k] for k in kept if k in d}
    return State(pruned), affected


def should_skip_llm(action_values: List[ActionValue],
                    min_gap: float = 0.15,
                    min_abs_value: float = 0.05) -> bool:
    """Dominant-strategy LLM skip.

    If the top action is clearly better than runner-up (delta-gap),
    we can safely pick it without paying an LLM call.

    This is intentionally conservative: only triggers when margin is large.
    """
    if not action_values or len(action_values) < 2:
        return True  # if only 0/1 options, no need for LLM
    vals = sorted((av.value_score for av in action_values), reverse=True)
    if vals[0] < min_abs_value:
        return False
    return (vals[0] - vals[1]) >= min_gap


@dataclass
class ReasoningResponse:
    """Parsed, validated response from LLM reasoning."""
    chosen_action_id: str
    reasoning: str
    expected_outcome: str
    risk_assessment: str
    alternative_considered: str
    should_stop: bool
    stop_reason: str
    raw_response: str = ""
    parse_errors: List[str] = field(default_factory=list)
    latency_ms: float = 0.0

    @property
    def is_valid(self) -> bool:
        return len(self.parse_errors) == 0 and (self.chosen_action_id or self.should_stop)


def parse_llm_response(raw: str, valid_action_ids: Set[str]) -> ReasoningResponse:
    """
    Parse LLM response with validation. Robust to formatting issues.

    This is deliberately strict — if the LLM hallucinates an action ID
    that doesn't exist, we catch it here, not at execution time.
    """
    errors = []

    # Extract JSON from response (handle markdown code blocks)
    json_str = raw.strip()
    if "```json" in json_str:
        json_str = json_str.split("```json")[1].split("```")[0].strip()
    elif "```" in json_str:
        json_str = json_str.split("```")[1].split("```")[0].strip()

    # Try to find JSON object
    start = json_str.find('{')
    end = json_str.rfind('}')
    if start >= 0 and end > start:
        json_str = json_str[start:end+1]

    try:
        data = json.loads(json_str)
    except json.JSONDecodeError as e:
        errors.append(f"JSON parse error: {e}")
        return ReasoningResponse(
            chosen_action_id="", reasoning="PARSE FAILED",
            expected_outcome="", risk_assessment="",
            alternative_considered="", should_stop=False,
            stop_reason="", raw_response=raw, parse_errors=errors)

    action_id = str(data.get("chosen_action_id", ""))
    should_stop = bool(data.get("should_stop", False))

    # Validate action ID
    if not should_stop and action_id not in valid_action_ids:
        errors.append(
            f"Invalid action_id '{action_id}'. "
            f"Valid: {sorted(valid_action_ids)}")

    return ReasoningResponse(
        chosen_action_id=action_id,
        reasoning=str(data.get("reasoning", "")),
        expected_outcome=str(data.get("expected_outcome", "")),
        risk_assessment=str(data.get("risk_assessment", "")),
        alternative_considered=str(data.get("alternative_considered", "")),
        should_stop=should_stop,
        stop_reason=str(data.get("stop_reason", "")),
        raw_response=raw,
        parse_errors=errors,
    )


# LLM adapter protocol

class LLMAdapter(Protocol):
    """
    Protocol for LLM backends. Implement this to plug in any LLM.

    The adapter does ONE thing: take a prompt string, return a response string.
    All structured parsing happens in ClampAI, not in the adapter.

    Design note on streaming
    ~~~~~~~~~~~~~~~~~~~~~~~~
    ClampAI evaluates safety on *complete* action proposals (JSON objects).
    Evaluating a partial token stream is not meaningful — you cannot run T1–T7
    checks on half a JSON object.  Therefore:

      • ``complete()`` is the primary method: it returns the full response and
        is the only method that ClampAI's safety kernel consumes.
      • ``stream_tokens`` is a *callback* passed to ``complete()``.  It lets
        adapters forward tokens to the UI as they arrive, providing streaming
        UX without compromising safety evaluation.  The kernel still waits for
        the full response before running any checks.
      • ``acomplete()`` is an optional async variant.  It is NOT used by the
        synchronous orchestrator but is provided for callers that manage their
        own asyncio event loop.

    Thread-safety: ``complete()`` must be safe to call from multiple threads
    simultaneously (e.g., in parallel action-evaluation scenarios).
    """
    def complete(
        self,
        prompt: str,
        system_prompt: str = "",
        *,
        temperature: float = 0.3,
        max_tokens: int = 2000,
        stream_tokens: Optional[Callable[[str], None]] = None,
    ) -> str:
        """
        Send prompt to LLM and return the full response string.

        Args:
            prompt: User-turn content to send to the model.
            system_prompt: System prompt. Falls back to a default if empty.
            temperature: Sampling temperature.
            max_tokens: Maximum tokens in the response.
            stream_tokens: Optional callback invoked with each token fragment
                as it arrives. Used for streaming UX only — the safety kernel
                waits for the full response before evaluating any action.
                Implementations may ignore this if the underlying client does
                not support streaming.

        Returns:
            The complete response text.
        """
        ...

    def acomplete(
        self,
        prompt: str,
        system_prompt: str = "",
        *,
        temperature: float = 0.3,
        max_tokens: int = 2000,
        stream_tokens: Optional[Callable[[str], None]] = None,
    ) -> "Any":
        """
        Optional async variant. Returns a coroutine (awaitable) that resolves
        to the full response string.

        Implementing this method is optional.  If your adapter does not provide
        async support, raise ``NotImplementedError``.  The synchronous
        orchestrator never calls this method — it is provided for callers that
        manage their own asyncio event loop.

        Guarantee: HEURISTIC (adapter-dependent; no formal guarantees)
        """
        ...


class MockLLMAdapter:
    """
    Deterministic mock for testing. Prefers novel READY actions over
    repeated ones, breaking ties by prompt order (= value rank).

    This proves the framework works WITHOUT any real LLM — the formal
    layer + simple novelty heuristic can drive reasonable behavior.
    """
    def __init__(self):
        self._call_count = 0
        self._seen: set = set()

    def complete(
        self,
        prompt: str,
        system_prompt: str = "",
        *,
        temperature: float = 0.3,
        max_tokens: int = 2000,
        stream_tokens: Optional[Callable[[str], None]] = None,
    ) -> str:
        self._call_count += 1

        # Parse available READY actions from prompt (order = value rank)
        actions = []
        for line in prompt.split('\n'):
            if "[READY]" in line and "(id=" in line:
                aid = line.split("(id=")[1].split(")")[0]
                actions.append(aid)

        # Check for goal achieved
        if "100.0%" in prompt:
            return self._stop("Goal achieved")

        if not actions:
            return self._stop("No actions available")

        # Prefer novel actions (not yet executed), else take first
        novel = [a for a in actions if a not in self._seen]
        choice = novel[0] if novel else actions[0]
        self._seen.add(choice)

        return json.dumps({
            "chosen_action_id": choice,
            "reasoning": f"Selected action {choice}",
            "expected_outcome": "Progress",
            "risk_assessment": "Acceptable",
            "alternative_considered": actions[1] if len(actions) > 1 else "None",
            "should_stop": False, "stop_reason": ""
        })

    async def acomplete(
        self,
        prompt: str,
        system_prompt: str = "",
        *,
        temperature: float = 0.3,
        max_tokens: int = 2000,
        stream_tokens: Optional[Callable[[str], None]] = None,
    ) -> str:
        """
        Async variant of ``complete()``.

        Delegates to ``complete()`` synchronously — no I/O occurs, so no
        thread pool is needed.  Suitable for use with ``AsyncSafetyKernel``
        in tests and deterministic simulations.

        Guarantee: PROVEN (same deterministic output as complete())
        """
        return self.complete(
            prompt, system_prompt,
            temperature=temperature,
            max_tokens=max_tokens,
            stream_tokens=stream_tokens,
        )

    @staticmethod
    def _stop(reason: str) -> str:
        return json.dumps({
            "chosen_action_id": "", "reasoning": reason,
            "expected_outcome": "", "risk_assessment": "",
            "alternative_considered": "", "should_stop": True,
            "stop_reason": reason
        })


# Claims registry

REASONING_CLAIMS = [
    Claim("R1", "Bayesian belief updates are exact (Bayes' theorem)",
          GuaranteeLevel.PROVEN,
          "Beta-Binomial conjugacy. observe() applies Bayes' rule exactly."),
    Claim("R2", "Causal dependency ordering prevents premature execution",
          GuaranteeLevel.CONDITIONAL,
          "Correct if dependency specs match reality.",
          assumptions=("Action dependency specifications are accurate",)),
    Claim("R3", "Action value computation reduces wasteful spending",
          GuaranteeLevel.EMPIRICAL,
          "Measured: 85% reduction in redundant actions (from Monte Carlo)."),
    Claim("R4", "LLM reasoning is clampained to valid actions only",
          GuaranteeLevel.PROVEN,
          "parse_llm_response validates action_id ∈ valid_action_ids."),
    Claim("R5", "LLM cannot override safety kernel decisions",
          GuaranteeLevel.PROVEN,
          "SafetyKernel.execute() re-verifies regardless of LLM output."),
]
