"""Trust evolution engine: Bayesian domain trust from multi-signal feedback.

Phase 5 (2026-04-17): trust gating — real enforcement via
``max(global_proactivity, domain_trust)``.

Phase 5B (2026-04-17): Bayesian upgrade — continuous Beta(α, β) posterior
per domain with temporal decay, per-subagent tracking, cross-domain
inference, and multi-signal feedback (corrections, overrides, junk
responses, edit-after-write, approval latency).
"""

from __future__ import annotations

import logging
import math
import os
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime

from agntspace.vault.reader import VaultReader
from agntspace.vault.writer import VaultWriter

log = logging.getLogger(__name__)

TRUST_LEVELS = ("Observer", "Advisor", "Assistant", "Partner")  # arch:deterministic
_TRUST_LEVEL_INDEX = {level: idx for idx, level in enumerate(TRUST_LEVELS)}

# ── Default level thresholds on the [0, 1] score ───────────────────
_DEFAULT_LEVEL_THRESHOLDS = (0.25, 0.50, 0.75)  # arch:deterministic — numeric scale boundaries for trust levels (observer/advisor/assistant/partner)


def _level_from_score(score: float, thresholds: tuple[float, ...] = _DEFAULT_LEVEL_THRESHOLDS) -> str:
    """Map a continuous [0, 1] score to a discrete trust level."""
    for i, t in enumerate(thresholds):
        if score < t:
            return TRUST_LEVELS[i]
    return TRUST_LEVELS[len(thresholds)]


# ── BetaTrustScore ──────────────────────────────────────────────────


@dataclass
class BetaTrustScore:
    """Full Bayesian trust posterior for a single domain (or domain×agent pair).

    Uses the Beta distribution conjugate prior for binary observations.
    Unlike a simple weighted ratio, this model uses:

    1. **Posterior uncertainty** — ``variance`` and ``credible_lower``
       (5th percentile) so gating decisions account for how much
       evidence backs the score. A domain with 2 good / 0 bad
       (score 1.0 but wide distribution) is gated more conservatively
       than one with 200 good / 0 bad (score 1.0, tight distribution).

    2. **Credible interval gating** — ``decision_score()`` returns the
       lower bound of the 90% credible interval, not the mean. This
       naturally requires more evidence before granting higher autonomy.

    3. **Effective evidence after decay** — temporal decay reduces the
       *effective* α and β (not just the score), widening the posterior
       and lowering the credible interval. A stale Partner reverts to
       high uncertainty, not just a lower point estimate.
    """

    alpha: float = 3.7      # successes + prior
    beta: float = 8.7       # failures + prior
    last_updated: str = ""  # ISO date of last feedback

    @property
    def mean(self) -> float:
        """Posterior mean: E[θ] = α / (α + β)."""
        total = self.alpha + self.beta
        if total <= 0:
            return 0.5
        return self.alpha / total

    @property
    def evidence(self) -> float:
        """Total evidence weight (α + β). Higher = more confident."""
        return self.alpha + self.beta

    @property
    def variance(self) -> float:
        """Posterior variance: Var[θ] = αβ / ((α+β)²(α+β+1)).

        High variance = uncertain. Low variance = confident.
        """
        a, b = self.alpha, self.beta
        total = a + b
        if total <= 0:
            return 0.25  # maximum uncertainty
        return (a * b) / (total * total * (total + 1))

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

    def credible_lower(self, percentile: float = 0.05) -> float:
        """Lower bound of the credible interval (inverse CDF).

        Default 5th percentile = lower bound of a 90% credible interval.
        Uses the regularized incomplete beta function approximation.
        Falls back to normal approximation when scipy is unavailable.
        """
        return _beta_ppf(percentile, self.alpha, self.beta)

    def credible_upper(self, percentile: float = 0.95) -> float:
        """Upper bound of the credible interval."""
        return _beta_ppf(percentile, self.alpha, self.beta)

    def decision_score(
        self,
        half_life_days: float = 90.0,
        prior_alpha: float = 3.7,
        prior_beta: float = 8.7,
    ) -> float:
        """The score used for gating decisions — credible interval lower bound
        with temporal decay applied to evidence strength.

        This is the key Bayesian property: the system is *conservative*
        when evidence is thin. A domain needs sustained positive evidence
        to earn autonomy, and stale evidence widens uncertainty.

        Temporal decay works on evidence, not score:
        - Fresh α=50, β=5 → tight distribution, high credible_lower
        - After 1 half-life: effective evidence halved → wider distribution,
          lower credible_lower, even though the mean hasn't changed much
        - After 3 half-lives: barely any effective evidence → credible_lower
          close to the prior's lower bound
        """
        eff_a, eff_b = self._decayed_params(half_life_days, prior_alpha, prior_beta)
        return _beta_ppf(0.05, eff_a, eff_b)

    def effective_score(
        self,
        half_life_days: float = 90.0,
        prior_mean: float = 0.298,
    ) -> float:
        """Posterior mean with temporal decay (for display/explanation).

        Kept for backward compat with the explain API and UI.
        Gating uses ``decision_score()`` instead.
        """
        if not self.last_updated:
            return self.mean
        try:
            updated_dt = datetime.fromisoformat(self.last_updated)
        except (ValueError, TypeError):
            return self.mean
        now = datetime.now(UTC)
        if updated_dt.tzinfo is None:
            updated_dt = updated_dt.replace(tzinfo=UTC)
        days_since = max(0, (now - updated_dt).total_seconds() / 86400)
        if days_since <= 0 or half_life_days <= 0:
            return self.mean
        decay = 0.5 ** (days_since / half_life_days)
        return prior_mean + (self.mean - prior_mean) * decay

    def _decayed_params(
        self,
        half_life_days: float,
        prior_alpha: float,
        prior_beta: float,
    ) -> tuple[float, float]:
        """Compute effective α, β after temporal decay.

        Decay reduces evidence toward the prior — equivalent to
        "forgetting" observations over time. The posterior distribution
        widens as evidence decays, which naturally lowers the credible
        interval lower bound and makes gating more conservative.

        Formula: eff_α = prior_α + (α - prior_α) × decay
                 eff_β = prior_β + (β - prior_β) × decay
        """
        if not self.last_updated:
            return (self.alpha, self.beta)
        try:
            updated_dt = datetime.fromisoformat(self.last_updated)
        except (ValueError, TypeError):
            return (self.alpha, self.beta)
        now = datetime.now(UTC)
        if updated_dt.tzinfo is None:
            updated_dt = updated_dt.replace(tzinfo=UTC)
        days_since = max(0, (now - updated_dt).total_seconds() / 86400)
        if days_since <= 0 or half_life_days <= 0:
            return (self.alpha, self.beta)
        decay = 0.5 ** (days_since / half_life_days)
        eff_a = prior_alpha + (self.alpha - prior_alpha) * decay
        eff_b = prior_beta + (self.beta - prior_beta) * decay
        return (max(0.01, eff_a), max(0.01, eff_b))

    def level(self, thresholds: tuple[float, ...] = _DEFAULT_LEVEL_THRESHOLDS) -> str:
        return _level_from_score(self.mean, thresholds)


def _beta_ppf(p: float, a: float, b: float) -> float:
    """Inverse CDF (percent point function) of Beta(a, b).

    Uses Newton's method on the regularized incomplete beta function.
    Pure Python — no scipy dependency. Accurate to ~4 decimal places
    for the ranges we care about (a, b in [0.5, 200]).

    Falls back to the normal approximation when the Newton iteration
    doesn't converge (which happens for very small a or b).
    """
    if a <= 0 or b <= 0:
        return 0.0
    if p <= 0:
        return 0.0
    if p >= 1:
        return 1.0

    # Normal approximation as starting point and fallback
    # For Beta(a, b): mean = a/(a+b), var = ab/((a+b)^2*(a+b+1))
    total = a + b
    mu = a / total
    var = (a * b) / (total * total * (total + 1))
    sigma = math.sqrt(var) if var > 0 else 0.001

    # Inverse normal CDF (Beasley-Springer-Moro approximation)
    normal_approx = mu + sigma * _inv_normal_cdf(p)
    normal_approx = max(0.001, min(0.999, normal_approx))

    # For low evidence (a+b < 4), normal approx is good enough
    if total < 4:
        return normal_approx

    # Newton-Raphson refinement using the regularized incomplete beta
    x = normal_approx
    for _ in range(30):
        cdf = _beta_reg_inc(x, a, b)
        pdf = _beta_pdf(x, a, b)
        if pdf < 1e-15:
            break
        delta = (cdf - p) / pdf
        x = x - delta
        x = max(0.001, min(0.999, x))
        if abs(delta) < 1e-8:
            break

    return x


def _beta_pdf(x: float, a: float, b: float) -> float:
    """Beta distribution PDF at x."""
    if x <= 0 or x >= 1:
        return 0.0
    try:
        log_pdf = (
            math.lgamma(a + b) - math.lgamma(a) - math.lgamma(b)
            + (a - 1) * math.log(x) + (b - 1) * math.log(1 - x)
        )
        return math.exp(log_pdf)
    except (ValueError, OverflowError):
        return 0.0


def _beta_reg_inc(x: float, a: float, b: float) -> float:
    """Regularized incomplete beta function I_x(a, b).

    Uses the continued fraction representation (Lentz's method).
    This is the CDF of Beta(a, b) evaluated at x.
    """
    if x <= 0:
        return 0.0
    if x >= 1:
        return 1.0

    # Use the symmetry relation when x > (a+1)/(a+b+2) for faster convergence
    if x > (a + 1) / (a + b + 2):
        return 1.0 - _beta_reg_inc(1.0 - x, b, a)

    # Compute the prefix: x^a * (1-x)^b / (a * B(a,b))
    try:
        log_prefix = (
            a * math.log(x) + b * math.log(1 - x)
            - math.log(a)
            - (math.lgamma(a) + math.lgamma(b) - math.lgamma(a + b))
        )
        prefix = math.exp(log_prefix)
    except (ValueError, OverflowError):
        return 0.5  # fallback

    # Continued fraction (Lentz's method)
    cf = _beta_cf(x, a, b)
    return max(0.0, min(1.0, prefix * cf))


def _beta_cf(x: float, a: float, b: float, max_iter: int = 200) -> float:
    """Continued fraction for the regularized incomplete beta function."""
    tiny = 1e-30
    f = tiny
    c = tiny
    d = 0.0

    for m in range(max_iter):
        if m == 0:
            numerator = 1.0
        elif m % 2 == 1:
            k = (m - 1) // 2 + 1
            numerator = -(a + k - 1) * (a + b + k - 1) * x / (
                (a + 2 * k - 2) * (a + 2 * k - 1)
            )
        else:
            k = m // 2
            numerator = k * (b - k) * x / (
                (a + 2 * k - 1) * (a + 2 * k)
            )

        d = 1.0 + numerator * d
        if abs(d) < tiny:
            d = tiny
        d = 1.0 / d

        c = 1.0 + numerator / c
        if abs(c) < tiny:
            c = tiny

        f *= c * d
        if abs(c * d - 1.0) < 1e-10:
            break

    return f


def _inv_normal_cdf(p: float) -> float:
    """Inverse standard normal CDF (rational approximation).

    Beasley-Springer-Moro algorithm. Accurate to ~1e-6.
    """
    if p <= 0:
        return -6.0
    if p >= 1:
        return 6.0

    a = [
        -3.969683028665376e+01, 2.209460984245205e+02,
        -2.759285104469687e+02, 1.383577518672690e+02,
        -3.066479806614716e+01, 2.506628277459239e+00,
    ]
    b = [
        -5.447609879822406e+01, 1.615858368580409e+02,
        -1.556989798598866e+02, 6.680131188771972e+01,
        -1.328068155288572e+01,
    ]
    c = [
        -7.784894002430293e-03, -3.223964580411365e-01,
        -2.400758277161838e+00, -2.549732539343734e+00,
        4.374664141464968e+00, 2.938163982698783e+00,
    ]
    d = [
        7.784695709041462e-03, 3.224671290700398e-01,
        2.445134137142996e+00, 3.754408661907416e+00,
    ]

    p_low = 0.02425
    p_high = 1 - p_low

    if p < p_low:
        q = math.sqrt(-2 * math.log(p))
        return (((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) / \
               ((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1)
    if p <= p_high:
        q = p - 0.5
        r = q * q
        return (((((a[0]*r+a[1])*r+a[2])*r+a[3])*r+a[4])*r+a[5])*q / \
               (((((b[0]*r+b[1])*r+b[2])*r+b[3])*r+b[4])*r+1)
    q = math.sqrt(-2 * math.log(1 - p))
    return -(((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) / \
           ((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1)


# ── Bayesian config (loaded from thresholds.yaml) ──────────────────


@dataclass
class BayesianConfig:
    """Hyperparameters for the Beta trust model."""

    default_prior_alpha: float = 3.7
    default_prior_beta: float = 8.7
    level_thresholds: tuple[float, ...] = _DEFAULT_LEVEL_THRESHOLDS
    good_weight: float = 1.0
    needs_improvement_weight: float = 0.3
    redo_weight: float = 3.0
    migration: dict[str, dict[str, float]] = field(default_factory=lambda: {
        "Observer": {"alpha": 1.24, "beta": 8.76},
        "Advisor": {"alpha": 3.7, "beta": 8.7},
        "Assistant": {"alpha": 6.2, "beta": 3.8},
        "Partner": {"alpha": 8.7, "beta": 1.3},
    })
    cross_domain_min_domains: int = 3
    cross_domain_min_evidence: float = 12.0
    # Per-goal Bayesian trust (2026-04-18, gap #1 fix):
    # When creating a new (domain, agent_key, goal_key) row, seed (α, β)
    # from the domain aggregate's mean scaled to this evidence budget.
    # Keeps goals anchored to domain knowledge while still permitting
    # per-goal divergence after ~evidence-count signals.
    # 15 means ~5-7 good signals move a goal from Advisor → Assistant.
    goal_prior_evidence: float = 15.0


_BAYESIAN = BayesianConfig()  # arch:deterministic


# ── Legacy thresholds (kept for backward compat with old config) ────


@dataclass
class TrustThresholds:
    """Legacy count-based thresholds (pre-Phase-5B)."""

    promote_threshold: int = 5
    demote_threshold: int = 2
    min_completed_for_demotion: int = 3


_THRESHOLDS = TrustThresholds()


def set_trust_thresholds(cfg: dict | None) -> None:
    """Install thresholds from trust-evolution skill config."""
    global _THRESHOLDS, _BAYESIAN
    if not cfg or not isinstance(cfg, dict):
        return
    _THRESHOLDS = TrustThresholds(
        promote_threshold=int(cfg.get("promote_threshold", _THRESHOLDS.promote_threshold)),
        demote_threshold=int(cfg.get("demote_threshold", _THRESHOLDS.demote_threshold)),
        min_completed_for_demotion=int(
            cfg.get("min_completed_for_demotion", _THRESHOLDS.min_completed_for_demotion)
        ),
    )
    # Load Bayesian config if present
    b = cfg.get("bayesian")
    if b and isinstance(b, dict):
        thresholds = b.get("level_thresholds", list(_DEFAULT_LEVEL_THRESHOLDS))
        migration = b.get("migration", _BAYESIAN.migration)
        cross = b.get("cross_domain", {})
        _BAYESIAN = BayesianConfig(
            default_prior_alpha=float(b.get("default_prior_alpha", _BAYESIAN.default_prior_alpha)),
            default_prior_beta=float(b.get("default_prior_beta", _BAYESIAN.default_prior_beta)),
            level_thresholds=tuple(float(t) for t in thresholds),
            good_weight=float(b.get("good_weight", _BAYESIAN.good_weight)),
            needs_improvement_weight=float(b.get("needs_improvement_weight", _BAYESIAN.needs_improvement_weight)),
            redo_weight=float(b.get("redo_weight", _BAYESIAN.redo_weight)),
            migration=migration if isinstance(migration, dict) else _BAYESIAN.migration,
            cross_domain_min_domains=int(cross.get("min_domains", _BAYESIAN.cross_domain_min_domains)),
            cross_domain_min_evidence=float(cross.get("min_evidence", _BAYESIAN.cross_domain_min_evidence)),
            goal_prior_evidence=float(b.get("goal_prior_evidence", _BAYESIAN.goal_prior_evidence)),
        )
    log.info(
        "Trust config: promote=%d demote=%d bayesian_prior=Beta(%.1f,%.1f)",
        _THRESHOLDS.promote_threshold,
        _THRESHOLDS.demote_threshold,
        _BAYESIAN.default_prior_alpha,
        _BAYESIAN.default_prior_beta,
    )


def get_trust_thresholds() -> TrustThresholds:
    return _THRESHOLDS


def get_bayesian_config() -> BayesianConfig:
    return _BAYESIAN


# ── Domain-to-tool mapping (Phase 5, Rule 12) ──────────────────────


@dataclass
class TrustDomainMap:
    """Resolved mapping from contract_action → trust domain name."""

    action_to_domain: dict[str, str] = field(default_factory=dict)
    default_domain: str = "general"
    routine_actions: frozenset[str] = field(default_factory=frozenset)
    half_lives: dict[str, float] = field(default_factory=dict)  # domain → days
    default_half_life: float = 90.0


_DOMAIN_MAP = TrustDomainMap()


def set_trust_domain_map(cfg: dict | None) -> None:
    """Install domain mapping from domains.yaml."""
    global _DOMAIN_MAP
    if not cfg or not isinstance(cfg, dict):
        return
    mapping: dict[str, str] = {}
    half_lives: dict[str, float] = {}
    for domain_name, domain_cfg in (cfg.get("domains") or {}).items():
        for action in (domain_cfg.get("contract_actions") or []):
            mapping[action] = domain_name
        hl = domain_cfg.get("trust_half_life_days")
        if hl is not None:
            half_lives[domain_name] = float(hl)
    _DOMAIN_MAP = TrustDomainMap(
        action_to_domain=mapping,
        default_domain=cfg.get("default_domain", "general"),
        routine_actions=frozenset(cfg.get("routine_actions") or []),
        half_lives=half_lives,
        default_half_life=float(cfg.get("default_trust_half_life_days", 90)),
    )
    log.info(
        "Trust domain map: %d actions → %d domains, %d routine, %d half-lives",
        len(mapping),
        len({v for v in mapping.values()}),
        len(_DOMAIN_MAP.routine_actions),
        len(half_lives),
    )


def get_trust_domain_map() -> TrustDomainMap:
    return _DOMAIN_MAP


def domain_for_action(contract_action: str) -> str:
    return _DOMAIN_MAP.action_to_domain.get(
        contract_action, _DOMAIN_MAP.default_domain,
    )


# ── Signal config (correction patterns, override mapping) ──────────


@dataclass
class TrustSignalConfig:
    """Skill-driven signal config loaded from signals.yaml."""

    correction_patterns: list[dict] = field(default_factory=list)
    override_to_trust: dict[str, dict] = field(default_factory=dict)
    junk_response_weight: float = 0.5
    edit_after_write_weight: float = 0.3
    edit_after_write_cooldown_minutes: int = 60
    approval_fast_threshold_hours: float = 1.0
    approval_slow_threshold_hours: float = 168.0
    approval_fast_weight: float = 1.5
    approval_slow_weight: float = 0.3


_SIGNALS = TrustSignalConfig()
_compiled_patterns: list[tuple[re.Pattern, str, float]] = []


def set_trust_signals(cfg: dict | None) -> None:
    """Install signal config from signals.yaml."""
    global _SIGNALS, _compiled_patterns
    if not cfg or not isinstance(cfg, dict):
        return
    patterns = []
    for p in cfg.get("correction_patterns", []):
        try:
            patterns.append((
                re.compile(p["pattern"], re.IGNORECASE),
                p.get("rating", "needs_improvement"),
                float(p.get("weight", 0.5)),
            ))
        except (re.error, KeyError):
            log.debug("Skipping invalid correction pattern: %s", p)
    _compiled_patterns = patterns

    al = cfg.get("approval_latency", {})
    eaw = cfg.get("edit_after_write", {})
    jr = cfg.get("junk_response", {})
    _SIGNALS = TrustSignalConfig(
        correction_patterns=cfg.get("correction_patterns", []),
        override_to_trust=cfg.get("override_to_trust", {}),
        junk_response_weight=float(jr.get("weight", 0.5)),
        edit_after_write_weight=float(eaw.get("weight", 0.3)),
        edit_after_write_cooldown_minutes=int(eaw.get("cooldown_minutes", 60)),
        approval_fast_threshold_hours=float(al.get("fast_threshold_hours", 1)),
        approval_slow_threshold_hours=float(al.get("slow_threshold_hours", 168)),
        approval_fast_weight=float(al.get("fast_weight", 1.5)),
        approval_slow_weight=float(al.get("slow_weight", 0.3)),
    )
    log.info("Trust signals: %d correction patterns, %d override mappings",
             len(patterns), len(_SIGNALS.override_to_trust))


def get_trust_signals() -> TrustSignalConfig:
    return _SIGNALS


def detect_correction(text: str) -> tuple[str, float] | None:
    """Scan user text for correction patterns. Returns (rating, weight) or None."""
    if not _compiled_patterns or not text:
        return None
    for pattern, rating, weight in _compiled_patterns:
        if pattern.search(text):
            return (rating, weight)
    return None


def override_to_trust_signal(action: str) -> dict | None:
    """Look up a user_override action in the signal mapping."""
    return _SIGNALS.override_to_trust.get(action)


def compute_approval_weight(created_ts: str | None, approved_ts: str | None) -> float:
    """Compute weight modifier based on approval latency."""
    if not created_ts or not approved_ts:
        return 1.0
    try:
        created = datetime.fromisoformat(created_ts)
        approved = datetime.fromisoformat(approved_ts)
        hours = max(0, (approved - created).total_seconds() / 3600)
    except (ValueError, TypeError):
        return 1.0
    if hours <= _SIGNALS.approval_fast_threshold_hours:
        return _SIGNALS.approval_fast_weight
    if hours >= _SIGNALS.approval_slow_threshold_hours:
        return _SIGNALS.approval_slow_weight
    # Linear interpolation between fast and slow
    span = _SIGNALS.approval_slow_threshold_hours - _SIGNALS.approval_fast_threshold_hours
    t = (hours - _SIGNALS.approval_fast_threshold_hours) / span if span > 0 else 0.5
    return _SIGNALS.approval_fast_weight + t * (_SIGNALS.approval_slow_weight - _SIGNALS.approval_fast_weight)


# ── Trust check result ──────────────────────────────────────────────


@dataclass
class TrustCheckResult:
    """Result of checking an action against the trust + proactivity gate."""

    effective_level: str
    domain: str
    domain_trust: str             # raw level from TRUST.md
    global_proactivity: str
    action_allowed: bool
    requires_confirmation: bool = False
    reason: str = ""
    effective_score: float = 0.0  # Phase 5B: continuous score after decay


# ── TrustService ────────────────────────────────────────────────────


class TrustService:
    """Manages Bayesian trust scores in vault/TRUST.md.

    The Beta(α, β) posterior per domain replaces the discrete 4-level
    ladder. The 4 levels are derived from score ranges for gating.
    Temporal decay, per-subagent tracking, cross-domain inference,
    and multi-signal feedback are all handled here.
    """

    def __init__(self, vault_reader: VaultReader, vault_writer: VaultWriter) -> None:
        self._reader = vault_reader
        self._writer = vault_writer
        self._cached_data: list[dict] | None = None
        self._cached_mtime: float = 0.0
        # Edit-after-write cooldown: domain → last_signal_ts
        self._edit_cooldowns: dict[str, float] = {}

    def _trust_path(self) -> str:
        return self._reader.resolve_identity_path("TRUST.md")

    def _ensure_fresh(self) -> list[dict]:
        """Return parsed trust data, re-reading only if file changed."""
        path = self._trust_path()
        try:
            physical = self._reader._resolve(path)  # noqa: SLF001
            mtime = os.path.getmtime(physical)
        except (OSError, AttributeError):
            self._cached_data = []
            return []
        if self._cached_data is not None and mtime == self._cached_mtime:
            return self._cached_data
        doc = self._reader.read(path)
        self._cached_data = self._parse_trust_table(doc.content)
        self._cached_mtime = mtime
        return self._cached_data

    def invalidate_cache(self) -> None:
        self._cached_data = None
        self._cached_mtime = 0.0

    def get_trust_data(self) -> list[dict]:
        return list(self._ensure_fresh())

    def get_domain_trust(
        self,
        domain: str,
        agent_key: str = "",
        goal_key: str = "",
    ) -> dict | None:
        """Get trust data with a 3-level fallback chain.

        Lookup order:
          1. (domain, agent_key, goal_key) — exact per-goal row
          2. (domain, agent_key, "")       — agent aggregate for this domain
          3. (domain, "",        "")       — domain aggregate

        When ``goal_key`` is empty, step 1 is skipped. When both
        ``agent_key`` and ``goal_key`` are empty, only step 3 applies.
        This ensures callers that haven't migrated to per-goal tracking
        see identical behavior to before.
        """
        rows = self._ensure_fresh()
        goal_match = None
        agent_match = None
        domain_match = None
        for row in rows:
            if row["domain"].lower() != domain.lower():
                continue
            row_agent = row.get("agent", "")
            row_goal = row.get("goal", "")
            if goal_key and agent_key and row_agent == agent_key and row_goal == goal_key:
                goal_match = row
                break
            if goal_key and not agent_key and not row_agent and row_goal == goal_key:
                goal_match = row
                break
            if agent_key and row_agent == agent_key and not row_goal:
                agent_match = row
            elif not row_agent and not row_goal:
                domain_match = row
        return goal_match or agent_match or domain_match

    def _make_beta_score(self, row: dict | None) -> BetaTrustScore:
        """Build a BetaTrustScore from a parsed row (handles old + new format)."""
        if not row:
            return BetaTrustScore(
                alpha=_BAYESIAN.default_prior_alpha,
                beta=_BAYESIAN.default_prior_beta,
            )
        alpha = row.get("alpha")
        beta = row.get("beta_param")
        if alpha is not None and beta is not None:
            return BetaTrustScore(
                alpha=float(alpha),
                beta=float(beta),
                last_updated=row.get("last_updated", ""),
            )
        # Old format: migrate from discrete level
        level = row.get("level", "Advisor")
        migration = _BAYESIAN.migration.get(level, {"alpha": 3.7, "beta": 8.7})
        return BetaTrustScore(
            alpha=float(migration["alpha"]),
            beta=float(migration["beta"]),
            last_updated=row.get("last_updated", ""),
        )

    def _seed_from_domain_prior(self, domain: str, agent_key: str = "") -> tuple[float, float]:
        """Compute a heavy-prior seed (α, β) for a new per-goal row.

        Looks up the domain aggregate (falling back to agent-level when
        present) and scales its posterior mean to the
        ``goal_prior_evidence`` budget. This gives the new goal-specific
        row the domain's *knowledge* as a prior, without inheriting its
        full evidence weight — so per-goal signals can actually shift the
        posterior over a realistic goal lifetime.

        Falls back to the default prior when no domain row exists OR when
        the domain posterior is degenerate (α + β ≤ 0).
        """
        # Look up without goal_key so we find the aggregate or agent row,
        # not another goal's row.
        ref = self.get_domain_trust(domain, agent_key=agent_key, goal_key="")
        if not ref:
            return (_BAYESIAN.default_prior_alpha, _BAYESIAN.default_prior_beta)
        score = self._make_beta_score(ref)
        if score.alpha + score.beta <= 0:
            return (_BAYESIAN.default_prior_alpha, _BAYESIAN.default_prior_beta)
        mean = score.mean
        ev = max(0.01, float(_BAYESIAN.goal_prior_evidence))
        return (mean * ev, (1.0 - mean) * ev)

    def _half_life_for_domain(self, domain: str) -> float:
        return _DOMAIN_MAP.half_lives.get(domain, _DOMAIN_MAP.default_half_life)

    def _prior_mean(self) -> float:
        return _BAYESIAN.default_prior_alpha / (
            _BAYESIAN.default_prior_alpha + _BAYESIAN.default_prior_beta
        )

    # ── Cross-domain inference (Gap 6) ──────────────────────────────

    def _compute_global_prior(self) -> tuple[float, float]:
        """Compute a global prior from existing domain scores.

        When 3+ domains have sufficient evidence, new/untouched domains
        inherit a higher (or lower) prior based on overall trust.
        """
        rows = self._ensure_fresh()
        qualified = []
        for row in rows:
            if row.get("agent"):
                continue  # skip per-subagent rows
            score = self._make_beta_score(row)
            if score.evidence >= _BAYESIAN.cross_domain_min_evidence:
                qualified.append(score.mean)
        if len(qualified) < _BAYESIAN.cross_domain_min_domains:
            return (_BAYESIAN.default_prior_alpha, _BAYESIAN.default_prior_beta)
        global_mean = sum(qualified) / len(qualified)
        # Convert to Beta params with moderate confidence (evidence=10)
        return (global_mean * 10, (1 - global_mean) * 10)

    # ── Trust gating ────────────────────────────────────────────────

    def check_tool_trust(
        self,
        contract_action: str,
        global_proactivity: str = "advisor",
        *,
        is_autonomous: bool = False,
        agent_key: str = "",
        goal_key: str = "",
    ) -> TrustCheckResult:
        """Gate a tool execution by trust level.

        Uses the **credible interval lower bound** (5th percentile) for
        gating — not the posterior mean. This is the key Bayesian property:
        the system is conservative when evidence is thin. A domain with
        score 0.80 but only 3 observations gates more conservatively than
        one with 0.80 and 50 observations.

        Temporal decay reduces effective evidence (α, β decay toward the
        prior), widening the posterior and lowering the credible bound.

        When ``goal_key`` is set, the per-goal row is preferred with a
        graceful fallback to the agent-level / domain-level aggregate —
        so goals with no direct signals behave identically to the domain
        they belong to.
        """
        domain = domain_for_action(contract_action)
        domain_data = self.get_domain_trust(domain, agent_key=agent_key, goal_key=goal_key)
        score = self._make_beta_score(domain_data)
        hl = self._half_life_for_domain(domain)

        # Gating uses the credible interval lower bound (conservative)
        gate_score = score.decision_score(
            hl, _BAYESIAN.default_prior_alpha, _BAYESIAN.default_prior_beta,
        )
        # Display uses the posterior mean with decay (informative)
        eff_score = score.effective_score(hl, self._prior_mean())

        domain_trust = _level_from_score(gate_score, _BAYESIAN.level_thresholds)

        global_idx = _TRUST_LEVEL_INDEX.get(
            global_proactivity.capitalize(), 1,
        )
        domain_idx = _TRUST_LEVEL_INDEX.get(domain_trust, 0)
        effective_idx = max(global_idx, domain_idx)
        effective_level = TRUST_LEVELS[effective_idx]

        if not is_autonomous:
            return TrustCheckResult(
                effective_level=effective_level,
                domain=domain,
                domain_trust=domain_trust,
                global_proactivity=global_proactivity,
                action_allowed=True,
                effective_score=eff_score,
            )

        is_routine = contract_action in _DOMAIN_MAP.routine_actions

        if effective_idx == 0:
            return TrustCheckResult(
                effective_level=effective_level,
                domain=domain,
                domain_trust=domain_trust,
                global_proactivity=global_proactivity,
                action_allowed=False,
                reason=f"Observer in '{domain}' (score {eff_score:.2f}): cannot act autonomously",
                effective_score=eff_score,
            )

        if effective_idx == 1:
            return TrustCheckResult(
                effective_level=effective_level,
                domain=domain,
                domain_trust=domain_trust,
                global_proactivity=global_proactivity,
                action_allowed=True,
                requires_confirmation=True,
                reason=f"Advisor in '{domain}' (score {eff_score:.2f}): requires confirmation",
                effective_score=eff_score,
            )

        if effective_idx == 2:
            if is_routine:
                return TrustCheckResult(
                    effective_level=effective_level,
                    domain=domain,
                    domain_trust=domain_trust,
                    global_proactivity=global_proactivity,
                    action_allowed=True,
                    effective_score=eff_score,
                )
            return TrustCheckResult(
                effective_level=effective_level,
                domain=domain,
                domain_trust=domain_trust,
                global_proactivity=global_proactivity,
                action_allowed=True,
                requires_confirmation=True,
                reason=f"Assistant in '{domain}' (score {eff_score:.2f}): non-routine requires confirmation",
                effective_score=eff_score,
            )

        return TrustCheckResult(
            effective_level=effective_level,
            domain=domain,
            domain_trust=domain_trust,
            global_proactivity=global_proactivity,
            action_allowed=True,
            effective_score=eff_score,
        )

    def check_goal_trust(
        self,
        goal_domain: str,
        global_proactivity: str = "advisor",
        *,
        is_autonomous: bool = False,
        agent_key: str = "",
        goal_key: str = "",
    ) -> TrustCheckResult:
        """Gate goal pursuit directly against a declared domain.

        Identical semantics to :meth:`check_tool_trust` except the domain
        is passed in explicitly rather than derived from a contract action.
        Goals carry their own ``domain`` field (e.g. a research goal should
        be gated by research trust, not by "create_task" trust) — this is
        the right surface for the heartbeat goal-pursuit loop.

        The ``goal_key`` enables per-goal Bayesian tracking with a heavy
        domain prior (see :meth:`_seed_from_domain_prior`). New goals with
        no direct signals gate identically to the domain aggregate; once
        enough per-goal feedback accumulates, goal-specific divergence
        kicks in.
        """
        domain = goal_domain or _DOMAIN_MAP.default_domain
        domain_data = self.get_domain_trust(
            domain, agent_key=agent_key, goal_key=goal_key,
        )
        score = self._make_beta_score(domain_data)
        hl = self._half_life_for_domain(domain)

        gate_score = score.decision_score(
            hl, _BAYESIAN.default_prior_alpha, _BAYESIAN.default_prior_beta,
        )
        eff_score = score.effective_score(hl, self._prior_mean())

        domain_trust = _level_from_score(gate_score, _BAYESIAN.level_thresholds)

        global_idx = _TRUST_LEVEL_INDEX.get(global_proactivity.capitalize(), 1)
        domain_idx = _TRUST_LEVEL_INDEX.get(domain_trust, 0)
        effective_idx = max(global_idx, domain_idx)
        effective_level = TRUST_LEVELS[effective_idx]

        if not is_autonomous:
            return TrustCheckResult(
                effective_level=effective_level,
                domain=domain,
                domain_trust=domain_trust,
                global_proactivity=global_proactivity,
                action_allowed=True,
                effective_score=eff_score,
            )

        # Autonomous dispatch requires at least Assistant (same rule as
        # check_tool_trust — the goal-pursuit loop only enqueues when the
        # effective level is ≥ Assistant).
        if effective_idx < 2:
            return TrustCheckResult(
                effective_level=effective_level,
                domain=domain,
                domain_trust=domain_trust,
                global_proactivity=global_proactivity,
                action_allowed=False,
                reason=(
                    f"{effective_level} in '{domain}' (score {eff_score:.2f}): "
                    f"cannot pursue goal autonomously"
                ),
                effective_score=eff_score,
            )

        return TrustCheckResult(
            effective_level=effective_level,
            domain=domain,
            domain_trust=domain_trust,
            global_proactivity=global_proactivity,
            action_allowed=True,
            effective_score=eff_score,
        )

    # ── Feedback recording ──────────────────────────────────────────

    def record_feedback(
        self,
        domain: str,
        rating: str,
        *,
        weight: float = 1.0,
        agent_key: str = "",
        goal_key: str = "",
    ) -> dict:
        """Record feedback and update the Bayesian trust score.

        Returns dict with domain, old_level, new_level, changed, score.

        When ``goal_key`` is set, evidence accumulates on a per-goal row.
        New per-goal rows are seeded from the domain aggregate's posterior
        mean scaled to ``BayesianConfig.goal_prior_evidence`` units —
        giving the goal the domain's knowledge as a starting point while
        still letting per-goal signals shift the posterior over a realistic
        lifetime (roughly 5–7 signals to cross a level threshold at the
        default budget of 15).
        """
        rows = self.get_trust_data()
        target = None
        for row in rows:
            if row["domain"].lower() == domain.lower():
                row_agent = row.get("agent", "")
                row_goal = row.get("goal", "")
                if row_agent == agent_key and row_goal == goal_key:
                    target = row
                    break

        now_str = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")

        if not target:
            # Choose the prior:
            # - per-goal rows inherit from the domain/agent aggregate
            # - agent rows and domain rows use the cross-domain global prior
            if goal_key:
                prior_a, prior_b = self._seed_from_domain_prior(domain, agent_key=agent_key)
            else:
                prior_a, prior_b = self._compute_global_prior()
            target = {
                "domain": domain,
                "agent": agent_key,
                "goal": goal_key,
                "alpha": prior_a,
                "beta_param": prior_b,
                "completed": 0,
                "good": 0,
                "needs_improvement": 0,
                "redo": 0,
                "last_updated": now_str,
            }
            rows.append(target)

        old_score = self._make_beta_score(target)
        old_level = old_score.level(_BAYESIAN.level_thresholds)

        # Update counts (kept for legacy compatibility + display)
        target["completed"] = target.get("completed", 0) + 1
        if rating == "good":
            target["good"] = target.get("good", 0) + 1
        elif rating == "needs_improvement":
            target["needs_improvement"] = target.get("needs_improvement", 0) + 1
        elif rating == "redo":
            target["redo"] = target.get("redo", 0) + 1

        # Bayesian update
        alpha = float(target.get("alpha") or _BAYESIAN.default_prior_alpha)
        beta_p = float(target.get("beta_param") or _BAYESIAN.default_prior_beta)

        if rating == "good":
            alpha += _BAYESIAN.good_weight * weight
        elif rating == "needs_improvement":
            beta_p += _BAYESIAN.needs_improvement_weight * weight
        elif rating == "redo":
            beta_p += _BAYESIAN.redo_weight * weight

        target["alpha"] = alpha
        target["beta_param"] = beta_p
        target["last_updated"] = now_str

        new_score = BetaTrustScore(alpha=alpha, beta=beta_p, last_updated=now_str)
        new_level = new_score.level(_BAYESIAN.level_thresholds)
        target["level"] = new_level

        # Write back
        self._write_trust_table(rows)
        self.invalidate_cache()

        # Log trust change events
        if old_level != new_level:
            direction = "promoted" if _TRUST_LEVEL_INDEX[new_level] > _TRUST_LEVEL_INDEX[old_level] else "demoted"
            scope_bits = []
            if agent_key:
                scope_bits.append(agent_key)
            if goal_key:
                scope_bits.append(f"goal:{goal_key}")
            scope_str = f" [{' | '.join(scope_bits)}]" if scope_bits else ""
            change_line = (
                f"- {datetime.now(UTC).strftime('%Y-%m-%d %H:%M')} | "
                f"{domain}{scope_str} "
                f"{direction}: {old_level} → {new_level} "
                f"(score {new_score.mean:.3f})\n"
            )
            try:
                change_path = "system/identity/trust-changes.md"
                if not self._reader.exists(change_path):
                    self._writer.write(change_path, f"# Trust Change Log\n\n{change_line}")
                else:
                    self._writer.append(change_path, change_line)
                log.info("Trust %s: %s %s → %s (%.3f)", direction, domain, old_level, new_level, new_score.mean)
            except Exception:
                log.debug("Failed to log trust change", exc_info=True)

        return {
            "domain": domain,
            "old_level": old_level,
            "new_level": new_level,
            "changed": old_level != new_level,
            "score": new_score.mean,
            "effective_score": new_score.effective_score(
                self._half_life_for_domain(domain), self._prior_mean(),
            ),
        }

    # ── Explain endpoint support ────────────────────────────────────

    def explain_domain(self, domain: str) -> dict:
        """Return detailed trust info for a domain (used by explain API)."""
        data = self.get_domain_trust(domain)
        score = self._make_beta_score(data)
        hl = self._half_life_for_domain(domain)
        prior_mean = self._prior_mean()
        eff = score.effective_score(hl, prior_mean)
        current_level = _level_from_score(eff, _BAYESIAN.level_thresholds)
        current_idx = _TRUST_LEVEL_INDEX[current_level]

        # Path to next level
        path_to_next: dict = {}
        if current_idx < len(TRUST_LEVELS) - 1:
            next_level = TRUST_LEVELS[current_idx + 1]
            next_threshold = _BAYESIAN.level_thresholds[current_idx]
            # How many good signals to reach threshold?
            # score_after_n_good = (alpha + n * good_weight) / (alpha + n * good_weight + beta)
            # Solve for n: n = (threshold * beta - alpha * (1 - threshold)) / (good_weight * (1 - threshold))
            a, b = score.alpha, score.beta
            gw = _BAYESIAN.good_weight
            denom = gw * (1 - next_threshold)
            if denom > 0:
                needed = (next_threshold * b - a * (1 - next_threshold)) / denom
                needed = max(0, math.ceil(needed))
            else:
                needed = 0
            path_to_next = {
                "target_level": next_level,
                "target_score": next_threshold,
                "signals_needed": needed,
            }

        # Days since last signal
        days_since = 0.0
        if score.last_updated:
            try:
                dt = datetime.fromisoformat(score.last_updated)
                if dt.tzinfo is None:
                    dt = dt.replace(tzinfo=UTC)
                days_since = max(0, (datetime.now(UTC) - dt).total_seconds() / 86400)
            except (ValueError, TypeError):
                pass
        decay_factor = 0.5 ** (days_since / hl) if hl > 0 else 1.0

        changes = self.get_recent_changes()
        domain_changes = [c for c in changes if domain.lower() in c.lower()]

        return {
            "domain": domain,
            "score": score.mean,
            "effective_score": eff,
            "alpha": score.alpha,
            "beta": score.beta,
            "evidence": score.evidence,
            "level": current_level,
            "path_to_next_level": path_to_next,
            "decay_info": {
                "half_life_days": hl,
                "days_since_last_signal": round(days_since, 1),
                "decay_factor": round(decay_factor, 4),
            },
            "recent_events": domain_changes[-10:],
            "counters": {
                "completed": (data or {}).get("completed", 0),
                "good": (data or {}).get("good", 0),
                "needs_improvement": (data or {}).get("needs_improvement", 0),
                "redo": (data or {}).get("redo", 0),
            },
        }

    # ── Utility ─────────────────────────────────────────────────────

    def get_recent_changes(self, since_date: str = "") -> list[str]:
        change_path = "system/identity/trust-changes.md"
        if not self._reader.exists(change_path):
            return []
        try:
            doc = self._reader.read(change_path)
            lines = [l.strip() for l in doc.content.split("\n") if l.strip().startswith("- ")]
            if since_date:
                lines = [l for l in lines if l.split("|")[0].strip("- ").strip() >= since_date]
            return lines
        except Exception:
            return []

    def check_edit_cooldown(self, domain: str) -> bool:
        """Return True if edit-after-write signal is allowed (cooldown expired)."""
        import time
        last = self._edit_cooldowns.get(domain, 0)
        now = time.monotonic()
        if now - last < _SIGNALS.edit_after_write_cooldown_minutes * 60:
            return False
        self._edit_cooldowns[domain] = now
        return True

    # ── Parsing and writing ─────────────────────────────────────────

    def _parse_trust_table(self, content: str) -> list[dict]:
        """Parse TRUST.md into structured data.

        Supports three formats (detected by column count):

        - **12 cells** (v3, per-goal): Domain | Agent | Goal | Level | Score |
          Alpha | Beta | Completed | Good | NI | Redo | Updated
        - **11 cells** (v2 Bayesian, no Goal column): Domain | Agent | Level |
          Score | Alpha | Beta | Completed | Good | NI | Redo | Updated
        - **6+ cells** (v1 pre-Bayesian): Domain | Level | Completed | Good |
          NI | Redo

        Older rows are upgraded in place on first write (goal column defaults
        to empty string — domain/agent aggregate).
        """
        rows = []
        table_lines = [
            line.strip()
            for line in content.split("\n")
            if line.strip().startswith("|") and "---" not in line and "| Domain |" not in line
        ]

        for line in table_lines:
            cells = [c.strip() for c in line.split("|")[1:-1]]
            if len(cells) >= 12:
                # v3 per-goal format
                rows.append({
                    "domain": cells[0],
                    "agent": cells[1] if cells[1] not in ("—", "-", "") else "",
                    "goal": cells[2] if cells[2] not in ("—", "-", "") else "",
                    "level": cells[3],
                    "alpha": self._parse_float(cells[5]),
                    "beta_param": self._parse_float(cells[6]),
                    "completed": self._parse_int(cells[7]),
                    "good": self._parse_int(cells[8]),
                    "needs_improvement": self._parse_int(cells[9]),
                    "redo": self._parse_int(cells[10]),
                    "last_updated": cells[11] if cells[11] not in ("—", "-", "") else "",
                })
            elif len(cells) >= 10:
                # v2 Bayesian format (no Goal column)
                rows.append({
                    "domain": cells[0],
                    "agent": cells[1] if cells[1] not in ("—", "-", "") else "",
                    "goal": "",
                    "level": cells[2],
                    "alpha": self._parse_float(cells[4]),
                    "beta_param": self._parse_float(cells[5]),
                    "completed": self._parse_int(cells[6]),
                    "good": self._parse_int(cells[7]),
                    "needs_improvement": self._parse_int(cells[8]),
                    "redo": self._parse_int(cells[9]),
                    "last_updated": cells[10] if len(cells) > 10 and cells[10] not in ("—", "-", "") else "",
                })
            elif len(cells) >= 6:
                # v1 pre-Bayesian format
                level = cells[1]
                migration = _BAYESIAN.migration.get(level, {"alpha": 3.7, "beta": 8.7})
                rows.append({
                    "domain": cells[0],
                    "agent": "",
                    "goal": "",
                    "level": level,
                    "alpha": migration["alpha"],
                    "beta_param": migration["beta"],
                    "completed": self._parse_int(cells[2]),
                    "good": self._parse_int(cells[3]),
                    "needs_improvement": self._parse_int(cells[4]),
                    "redo": self._parse_int(cells[5]),
                    "last_updated": "",
                })
        return rows

    def _write_trust_table(self, rows: list[dict]) -> None:
        """Write trust data in the v3 Bayesian format (with Goal column)."""
        now = datetime.now(UTC).strftime("%Y-%m-%d")
        lines = [
            "---",
            'title: "Trust Scores"',
            "description: Bayesian domain trust — Beta(α,β) posterior with temporal decay; per-goal rows seeded from the domain prior",
            f"updated: {now}",
            "---",
            "",
            "| Domain | Agent | Goal | Level | Score | Alpha | Beta | Completed | Good | NI | Redo | Updated |",
            "|--------|-------|------|-------|-------|-------|------|-----------|------|-----|------|---------|",
        ]

        for row in rows:
            alpha = float(row.get("alpha") or _BAYESIAN.default_prior_alpha)
            beta_p = float(row.get("beta_param") or _BAYESIAN.default_prior_beta)
            total = alpha + beta_p
            score = alpha / total if total > 0 else 0.5
            level = _level_from_score(score, _BAYESIAN.level_thresholds)
            agent = row.get("agent", "") or "-"
            goal = row.get("goal", "") or "-"
            updated = row.get("last_updated", "") or "-"
            lines.append(
                f"| {row['domain']} "
                f"| {agent} "
                f"| {goal} "
                f"| {level} "
                f"| {score:.3f} "
                f"| {alpha:.1f} "
                f"| {beta_p:.1f} "
                f"| {row.get('completed', 0)} "
                f"| {row.get('good', 0)} "
                f"| {row.get('needs_improvement', 0)} "
                f"| {row.get('redo', 0)} "
                f"| {updated} |"
            )

        lines.extend([
            "",
            "## How Trust Changes",
            "",
            "Trust uses a **Beta distribution** posterior per domain.",
            f"- **Good feedback:** α += {_BAYESIAN.good_weight} (trust increases)",
            f"- **Redo feedback:** β += {_BAYESIAN.redo_weight} (trust decreases strongly)",
            f"- **Needs improvement:** β += {_BAYESIAN.needs_improvement_weight} (trust decreases mildly)",
            "- **Temporal decay:** unused domains decay toward the prior over time",
            "- **Cross-domain:** when 3+ domains have evidence, new domains inherit the global mean as prior",
            "",
            "## Trust Levels (derived from score)",
            "",
            f"- **Observer** (score < {_BAYESIAN.level_thresholds[0]:.2f}): Agent will not act without explicit instruction",
            f"- **Advisor** (score < {_BAYESIAN.level_thresholds[1]:.2f}): Agent suggests but does not execute",
            f"- **Assistant** (score < {_BAYESIAN.level_thresholds[2]:.2f}): Agent executes routine tasks, asks for non-routine",
            f"- **Partner** (score >= {_BAYESIAN.level_thresholds[2]:.2f}): Full autonomy within contract boundaries",
        ])

        self._writer.write("system/identity/TRUST.md", "\n".join(lines))

    @staticmethod
    def _parse_int(s: str) -> int:
        try:
            return int(s.strip())
        except (ValueError, AttributeError):
            return 0

    @staticmethod
    def _parse_float(s: str) -> float:
        try:
            return float(s.strip())
        except (ValueError, AttributeError):
            return 0.0
