Преглед изворни кода

Add deterministic Hermes narrative layer

Lukas Goldschmidt пре 3 недеља
родитељ
комит
25e0bd18c2

+ 54 - 2
src/hermes_mcp/dashboard.py

@@ -1,7 +1,7 @@
 from fastapi import APIRouter
 from fastapi.responses import HTMLResponse
 
-from .store import latest_cycle, latest_regime_samples
+from .store import latest_cycle, latest_regime_samples, latest_states
 
 router = APIRouter(prefix="/dashboard", tags=["dashboard"])
 
@@ -12,6 +12,8 @@ def overview():
     concerns = []
     regimes = latest_regime_samples(10)
     concern_rows = "<tr><td colspan='5' class='muted'>Loading live data…</td></tr>"
+    state_rows = "<tr><td colspan='9' class='muted'>No state snapshots yet.</td></tr>"
+    narrative_rows = "<tr><td colspan='6' class='muted'>No narratives yet.</td></tr>"
     regime_rows = "".join(
         f"<tr><td>{r.get('concern_id','')}</td><td>{r.get('timeframe','')}</td><td><pre style='white-space:pre-wrap;margin:0'>{r.get('regime_json','')}</pre></td><td>{r.get('captured_at','')}</td></tr>"
         for r in regimes
@@ -70,7 +72,7 @@ def overview():
           document.getElementById('cycle-finished').textContent = data.latest_cycle?.finished_at || '-';
           document.getElementById('cycle-notes').textContent = data.latest_cycle?.notes || '-';
           document.getElementById('concern-count').textContent = String(data.concerns.length);
-          document.getElementById('concerns-body').innerHTML = data.concerns.map(c => `
+        document.getElementById('concerns-body').innerHTML = data.concerns.map(c => `
             <tr>
               <td><strong>${{c.account_display || ''}}</strong><div class='small'>${{c.id || ''}}</div></td>
               <td>${{c.market_display || c.market_symbol || ''}}<div class='small'>${{c.market_description || ''}}</div></td>
@@ -113,6 +115,44 @@ def overview():
               </div>`;
           }}).join('') || "<div class='muted'>No regime samples yet.</div>";
           document.getElementById('regimes-body').innerHTML = `<div class='regime-grid'>${{cards}}</div>`;
+          document.getElementById('states-body').innerHTML = (data.state_samples || []).map(s => {
+            const payload = (() => { try { return JSON.parse(s.payload_json || '{}'); } catch { return {}; } })();
+            const micro = payload.scoped_state?.micro || {};
+            const meso = payload.scoped_state?.meso || {};
+            const macro = payload.scoped_state?.macro || {};
+            const cross = payload.cross_scope_summary || {};
+            return `
+            <tr>
+              <td>${{s.concern_id || ''}}</td>
+              <td>${{s.market_regime || ''}}</td>
+              <td>${{s.volatility_state || ''}}</td>
+              <td>${{s.liquidity_state || ''}}</td>
+              <td>${{s.sentiment_pressure || ''}}</td>
+              <td>${{s.event_risk || ''}}</td>
+              <td>${{s.execution_quality || ''}}</td>
+              <td>${{typeof s.confidence === 'number' ? s.confidence.toFixed(2) : ''}}</td>
+              <td>
+                <div class='small'><strong>micro</strong>: ${{micro.impulse || '-'}} / ${{micro.location || '-'}} / rev ${{micro.reversal_risk || '-'}}</div>
+                <div class='small'><strong>meso</strong>: ${{meso.structure || '-'}} / ${{meso.momentum_bias || '-'}} / ${{meso.auction_state || '-'}}</div>
+                <div class='small'><strong>macro</strong>: ${{macro.bias || '-'}} / ${{macro.structure || '-'}} / ${{macro.context_pressure || '-'}}</div>
+                <div class='small'><strong>cross</strong>: ${{cross.alignment || '-'}} / ${{cross.opportunity_type || '-'}} / friction ${{cross.friction || '-'}}</div>
+              </td>
+            </tr>`;
+          }).join('') || "<tr><td colspan='9' class='muted'>No state snapshots yet.</td></tr>";
+          document.getElementById('narratives-body').innerHTML = (data.narrative_samples || []).map(n => {
+            const drivers = (() => { try { return JSON.parse(n.key_drivers_json || '[]'); } catch { return []; } })();
+            const risks = (() => { try { return JSON.parse(n.risk_flags_json || '[]'); } catch { return []; } })();
+            const uncertainties = (() => { try { return JSON.parse(n.uncertainties_json || '[]'); } catch { return []; } })();
+            return `
+            <tr>
+              <td>${n.concern_id || ''}</td>
+              <td>${n.summary || ''}</td>
+              <td>${drivers.join('<br>') || '-'}</td>
+              <td>${risks.join('<br>') || '-'}</td>
+              <td>${uncertainties.join('<br>') || '-'}</td>
+              <td>${typeof n.confidence === 'number' ? n.confidence.toFixed(2) : ''}</td>
+            </tr>`;
+          }).join('') || "<tr><td colspan='6' class='muted'>No narratives yet.</td></tr>";
         }}
         window.addEventListener('load', () => {{ refreshData(); setInterval(refreshData, 15000); }});
       </script>
@@ -138,6 +178,16 @@ def overview():
       </table>
       <h2>Latest regime samples</h2>
       <div id="regimes-body">__REGIME_ROWS__</div>
+      <h2>Latest state snapshots</h2>
+      <table>
+        <tr><th>concern</th><th>market regime</th><th>volatility</th><th>liquidity</th><th>sentiment</th><th>event risk</th><th>execution</th><th>confidence</th><th>scoped detail</th></tr>
+        <tbody id="states-body">__STATE_ROWS__</tbody>
+      </table>
+      <h2>Latest narratives</h2>
+      <table>
+        <tr><th>concern</th><th>summary</th><th>drivers</th><th>risks</th><th>uncertainties</th><th>confidence</th></tr>
+        <tbody id="narratives-body">__NARRATIVE_ROWS__</tbody>
+      </table>
       </div></div>
     </body></html>
     """
@@ -151,6 +201,8 @@ def overview():
         .replace("__CONCERN_COUNT__", str(len(concerns)))
         .replace("__CONCERN_ROWS__", concern_rows)
         .replace("__REGIME_ROWS__", regime_rows)
+        .replace("__STATE_ROWS__", state_rows)
+        .replace("__NARRATIVE_ROWS__", narrative_rows)
     )
 
 

+ 214 - 0
src/hermes_mcp/narrative_engine.py

@@ -0,0 +1,214 @@
+from __future__ import annotations
+
+"""Deterministic narrative synthesis for Hermes.
+
+This layer deliberately does not make the decision.
+Its job is to produce a stable, auditable semantic reading of the market so the
+later decision layer can choose what to do with that reading.
+
+Contract:
+- Input: state payload from `state_engine.synthesize_state()`
+- Output: narrative object with a controlled stance taxonomy, key drivers,
+  risk flags, uncertainty markers, invalidators, and opportunity weights.
+
+Design rule:
+- The narrative layer may describe action-space, but it must not select an
+  action. For example, it may say "continuation conditions dominate" or
+  "reversal risk is elevated", but it should not say "buy now" or "stand aside".
+"""
+
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from typing import Any
+
+
+# Stable controlled vocabulary for the semantic stance of the market.
+# These labels are intended to remain durable so downstream rules and dashboards
+# can rely on them without constant migration churn.
+STANCE_TAXONOMY = {
+    "constructive_bullish",
+    "cautious_bullish",
+    "fragile_bullish",
+    "neutral_rotational",
+    "breakout_watch",
+    "reversal_watch",
+    "constructive_bearish",
+    "cautious_bearish",
+    "fragile_bearish",
+    "transition_risk",
+}
+
+
+@dataclass(frozen=True)
+class NarrativeSnapshot:
+    summary: str
+    key_drivers: list[str]
+    risk_flags: list[str]
+    uncertainties: list[str]
+    confidence: float
+    payload: dict[str, Any]
+
+
+def _clamp(value: float, lower: float, upper: float) -> float:
+    return max(lower, min(upper, value))
+
+
+def _rounded_weights(raw: dict[str, float]) -> dict[str, float]:
+    total = sum(max(v, 0.0) for v in raw.values()) or 1.0
+    return {k: round(max(v, 0.0) / total, 3) for k, v in raw.items()}
+
+
+def _first_nonempty(values: list[str | None], fallback: str) -> str:
+    for value in values:
+        if value:
+            return value
+    return fallback
+
+
+def build_narrative(*, concern: dict[str, Any], state_payload: dict[str, Any]) -> NarrativeSnapshot:
+    scoped = state_payload.get("scoped_state") or {}
+    micro = scoped.get("micro") or {}
+    meso = scoped.get("meso") or {}
+    macro = scoped.get("macro") or {}
+    cross = state_payload.get("cross_scope_summary") or {}
+
+    market_symbol = str(concern.get("market_symbol") or state_payload.get("market_symbol") or "market")
+    drivers: list[str] = []
+    risks: list[str] = []
+    uncertainties: list[str] = []
+    invalidators: list[str] = []
+
+    macro_bias = str(macro.get("bias") or "mixed")
+    macro_structure = str(macro.get("structure") or "range_context")
+    meso_structure = str(meso.get("structure") or "rotation")
+    meso_momentum = str(meso.get("momentum_bias") or "neutral")
+    micro_impulse = str(micro.get("impulse") or "mixed")
+    micro_location = str(micro.get("location") or "unknown")
+    micro_reversal_risk = str(micro.get("reversal_risk") or "low")
+    friction = str(cross.get("friction") or "medium")
+    alignment = str(cross.get("alignment") or "partial_alignment")
+    source_confidence = float(cross.get("confidence") or 0.4)
+
+    if macro_bias == "bullish":
+        drivers.append("macro bias remains bullish")
+    elif macro_bias == "bearish":
+        drivers.append("macro bias remains bearish")
+    else:
+        uncertainties.append("macro context is mixed")
+
+    if meso_structure != "rotation":
+        drivers.append(f"meso structure is {meso_structure}")
+    else:
+        uncertainties.append("meso structure is rotational rather than directional")
+
+    if micro_impulse in {"up", "down"}:
+        drivers.append(f"micro impulse is {micro_impulse}")
+    else:
+        uncertainties.append("micro impulse is mixed")
+
+    if friction == "high":
+        risks.append("cross-scope friction is high")
+    elif friction == "medium":
+        uncertainties.append("cross-scope alignment is incomplete")
+
+    if micro_reversal_risk in {"medium", "high"}:
+        risks.append(f"micro reversal risk is {micro_reversal_risk}")
+
+    if micro_location in {"near_upper_band", "near_lower_band"}:
+        risks.append(f"micro price location is stretched at {micro_location}")
+
+    if macro_structure == "fragile_trend":
+        risks.append("macro trend structure is fragile")
+
+    if macro_bias == "bullish":
+        invalidators.append("meso momentum turns bearish")
+        invalidators.append("micro loses supportive impulse and slips out of value acceptance")
+    elif macro_bias == "bearish":
+        invalidators.append("meso momentum turns bullish")
+        invalidators.append("micro loses downside impulse and reclaims value acceptance")
+    else:
+        invalidators.append("higher-timeframe structure resolves directionally")
+
+    if macro_bias == "bullish" and meso_structure in {"trend_continuation", "bullish_pullback"}:
+        stance = "constructive_bullish" if friction == "low" else "cautious_bullish"
+    elif macro_bias == "bearish" and meso_structure in {"trend_continuation", "bearish_pullback"}:
+        stance = "constructive_bearish" if friction == "low" else "cautious_bearish"
+    elif micro_reversal_risk == "high":
+        stance = "reversal_watch"
+    elif micro_location in {"near_upper_band", "near_lower_band"} and meso_structure == "rotation":
+        stance = "breakout_watch"
+    elif macro_structure == "fragile_trend":
+        stance = "fragile_bullish" if macro_bias == "bullish" else "fragile_bearish" if macro_bias == "bearish" else "transition_risk"
+    elif macro_bias == "mixed" and alignment != "micro_meso_macro_aligned":
+        stance = "transition_risk"
+    else:
+        stance = "neutral_rotational"
+
+    if stance not in STANCE_TAXONOMY:
+        stance = "neutral_rotational"
+
+    opportunity_raw = {
+        "continuation": 0.0,
+        "mean_reversion": 0.0,
+        "reversal": 0.0,
+        "wait": 0.0,
+    }
+    if stance in {"constructive_bullish", "cautious_bullish", "constructive_bearish", "cautious_bearish"}:
+        opportunity_raw["continuation"] += 0.55
+    if stance in {"neutral_rotational", "transition_risk"}:
+        opportunity_raw["wait"] += 0.45
+        opportunity_raw["mean_reversion"] += 0.3
+    if stance == "breakout_watch":
+        opportunity_raw["continuation"] += 0.3
+        opportunity_raw["wait"] += 0.25
+    if stance == "reversal_watch":
+        opportunity_raw["reversal"] += 0.45
+        opportunity_raw["wait"] += 0.2
+    if micro_reversal_risk == "high":
+        opportunity_raw["reversal"] += 0.2
+    if friction == "high":
+        opportunity_raw["wait"] += 0.25
+    if meso_structure == "rotation":
+        opportunity_raw["mean_reversion"] += 0.2
+    if meso_structure in {"bullish_pullback", "bearish_pullback", "trend_continuation"}:
+        opportunity_raw["continuation"] += 0.15
+
+    opportunity_map = _rounded_weights(opportunity_raw)
+
+    market_story = _first_nonempty([
+        f"macro {macro_bias}, meso {meso_structure}, micro impulse {micro_impulse}",
+    ], "context unresolved")
+
+    summary = (
+        f"{market_symbol} reads as {stance.replace('_', ' ')}, with {market_story}. "
+        f"The dominant opportunity family is {max(opportunity_map, key=opportunity_map.get)}."
+    )
+
+    confidence = source_confidence
+    if risks:
+        confidence -= 0.06 * min(len(risks), 3)
+    if uncertainties:
+        confidence -= 0.04 * min(len(uncertainties), 3)
+    if alignment == "micro_meso_macro_aligned":
+        confidence += 0.05
+    confidence = round(_clamp(confidence, 0.2, 0.95), 3)
+
+    payload = {
+        "generated_at": datetime.now(timezone.utc).isoformat(),
+        "stance": stance,
+        "market_story": market_story,
+        "invalidators": invalidators,
+        "opportunity_map": opportunity_map,
+        "cross_scope_alignment": alignment,
+        "source_state_confidence": source_confidence,
+        "summary_version": 1,
+    }
+
+    return NarrativeSnapshot(
+        summary=summary,
+        key_drivers=drivers,
+        risk_flags=risks,
+        uncertainties=uncertainties,
+        confidence=confidence,
+        payload=payload,
+    )

+ 37 - 1
src/hermes_mcp/server.py

@@ -16,7 +16,9 @@ from mcp.client.sse import sse_client
 
 from .config import load_config
 from .crypto_client import get_price, get_regime
-from .store import get_state, init_db, list_concerns, latest_cycle, latest_cycles, latest_regime_samples, prune_older_than, recent_regime_samples, sync_concerns_from_strategies, upsert_cycle, upsert_regime_sample
+from .narrative_engine import build_narrative
+from .state_engine import synthesize_state
+from .store import get_state, init_db, list_concerns, latest_cycle, latest_cycles, latest_narratives, latest_regime_samples, prune_older_than, recent_regime_samples, sync_concerns_from_strategies, upsert_cycle, upsert_narrative, upsert_regime_sample, upsert_state, latest_states
 from .trader_client import list_strategies
 
 mcp = FastMCP(
@@ -60,8 +62,10 @@ async def lifespan(_: FastAPI):
                 symbol = concern.get("base_currency") or concern.get("market_symbol")
                 if not symbol:
                     continue
+                current_regimes: list[dict] = []
                 for timeframe in cfg.crypto_timeframes:
                     regime = await get_regime(cfg.crypto_url, str(symbol), timeframe)
+                    current_regimes.append({**regime, "timeframe": timeframe})
                     upsert_regime_sample(
                         id=f"{cycle_id}:{concern['id']}:{timeframe}",
                         cycle_id=cycle_id,
@@ -70,6 +74,36 @@ async def lifespan(_: FastAPI):
                         regime_json=json.dumps(regime, ensure_ascii=False),
                         captured_at=datetime.now(timezone.utc).isoformat(),
                     )
+                try:
+                    state = synthesize_state(concern=concern, regimes=current_regimes, account_info={})
+                    upsert_state(
+                        id=f"{cycle_id}:{concern['id']}",
+                        cycle_id=cycle_id,
+                        concern_id=str(concern["id"]),
+                        market_regime=state.market_regime,
+                        volatility_state=state.volatility_state,
+                        liquidity_state=state.liquidity_state,
+                        sentiment_pressure=state.sentiment_pressure,
+                        event_risk=state.event_risk,
+                        execution_quality=state.execution_quality,
+                        confidence=state.confidence,
+                        payload_json=json.dumps(state.payload, ensure_ascii=False),
+                        created_at=state.payload.get("generated_at"),
+                    )
+                    narrative = build_narrative(concern=concern, state_payload=state.payload)
+                    upsert_narrative(
+                        id=f"{cycle_id}:{concern['id']}",
+                        cycle_id=cycle_id,
+                        concern_id=str(concern["id"]),
+                        summary=narrative.summary,
+                        key_drivers_json=json.dumps(narrative.key_drivers, ensure_ascii=False),
+                        risk_flags_json=json.dumps(narrative.risk_flags, ensure_ascii=False),
+                        uncertainties_json=json.dumps(narrative.uncertainties, ensure_ascii=False),
+                        confidence=narrative.confidence,
+                        created_at=narrative.payload.get("generated_at"),
+                    )
+                except Exception:
+                    pass
             upsert_cycle(id=cycle_id, started_at=started, finished_at=datetime.now(timezone.utc).isoformat(), status="ok", trigger="interval", notes=f"polled {len(concerns)} concerns over {','.join(cfg.crypto_timeframes)}")
             await asyncio.sleep(max(10, cfg.cycle_seconds))
 
@@ -236,4 +270,6 @@ def dashboard_data() -> JSONResponse:
         "concerns": enriched,
         "regime_samples": regimes,
         "regime_histories": histories_by_key,
+        "state_samples": latest_states(20),
+        "narrative_samples": latest_narratives(20),
     })

+ 462 - 0
src/hermes_mcp/state_engine.py

@@ -0,0 +1,462 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from typing import Any
+
+MICRO_TIMEFRAMES = ("1m", "5m")
+MESO_TIMEFRAMES = ("15m", "1h")
+MACRO_TIMEFRAMES = ("4h", "1d")
+TIMEFRAME_WEIGHTS = {
+    "1m": 1,
+    "5m": 2,
+    "15m": 3,
+    "1h": 4,
+    "4h": 5,
+    "1d": 6,
+}
+
+
+@dataclass(frozen=True)
+class StateSnapshot:
+    market_regime: str
+    volatility_state: str
+    liquidity_state: str
+    sentiment_pressure: str
+    event_risk: str
+    execution_quality: str
+    confidence: float
+    payload: dict[str, Any]
+
+
+def _safe_float(value: Any) -> float | None:
+    try:
+        if value is None:
+            return None
+        return float(value)
+    except Exception:
+        return None
+
+
+def _clamp(value: float, lower: float, upper: float) -> float:
+    return max(lower, min(upper, value))
+
+
+def _mean(values: list[float]) -> float | None:
+    if not values:
+        return None
+    return sum(values) / len(values)
+
+
+def _label_signed(score: float, bull: str, neutral: str, bear: str, threshold: float = 0.2) -> str:
+    if score >= threshold:
+        return bull
+    if score <= -threshold:
+        return bear
+    return neutral
+
+
+def _label_magnitude(value: float | None, low: float, high: float) -> str:
+    if value is None:
+        return "unknown"
+    if value >= high:
+        return "high"
+    if value >= low:
+        return "medium"
+    return "low"
+
+
+def _pick_latest(regimes: list[dict[str, Any]], timeframe: str) -> dict[str, Any] | None:
+    matches = [r for r in regimes if str(r.get("timeframe") or "").lower() == timeframe]
+    return matches[-1] if matches else None
+
+
+def extract_regime_features(regime: dict[str, Any]) -> dict[str, Any]:
+    timeframe = str(regime.get("timeframe") or "").lower()
+    price = _safe_float(regime.get("price"))
+    trend = regime.get("trend") or {}
+    momentum = regime.get("momentum") or {}
+    volatility = regime.get("volatility") or {}
+    bollinger = ((regime.get("bands") or {}).get("bollinger") or {})
+    reversal = regime.get("reversal") or {}
+
+    ema_fast = _safe_float(trend.get("ema_fast"))
+    ema_slow = _safe_float(trend.get("ema_slow"))
+    sma_long = _safe_float(trend.get("sma_long"))
+    vwap = _safe_float(regime.get("vwap"))
+    rsi = _safe_float(momentum.get("rsi"))
+    macd_hist = _safe_float(momentum.get("macd_histogram"))
+    atr = _safe_float(volatility.get("atr"))
+    atr_percent = _safe_float(volatility.get("atr_percent"))
+    band_middle = _safe_float(bollinger.get("middle"))
+    band_upper = _safe_float(bollinger.get("upper"))
+    band_lower = _safe_float(bollinger.get("lower"))
+    reversal_score = _safe_float(reversal.get("score")) or 0.0
+    reversal_direction = str(reversal.get("direction") or "none").lower()
+
+    def pct_delta(a: float | None, b: float | None) -> float | None:
+        if a is None or b in (None, 0):
+            return None
+        return ((a - b) / b) * 100.0
+
+    price_vs_ema_fast_pct = pct_delta(price, ema_fast)
+    price_vs_ema_slow_pct = pct_delta(price, ema_slow)
+    price_vs_sma_long_pct = pct_delta(price, sma_long)
+    price_vs_vwap_pct = pct_delta(price, vwap)
+    ema_fast_vs_slow_pct = pct_delta(ema_fast, ema_slow)
+    ema_slow_vs_sma_long_pct = pct_delta(ema_slow, sma_long)
+
+    trend_bias_score = 0.0
+    for value, weight in (
+        (price_vs_ema_fast_pct, 0.8),
+        (price_vs_ema_slow_pct, 1.0),
+        (price_vs_sma_long_pct, 1.2),
+        (price_vs_vwap_pct, 0.8),
+        (ema_fast_vs_slow_pct, 1.4),
+        (ema_slow_vs_sma_long_pct, 1.1),
+    ):
+        if value is not None:
+            trend_bias_score += _clamp(value / 2.0, -1.0, 1.0) * weight
+    trend_bias_score = round(trend_bias_score, 4)
+
+    if trend_bias_score >= 2.4:
+        trend_alignment = "fully_bullish"
+    elif trend_bias_score >= 0.8:
+        trend_alignment = "bullish_pullback"
+    elif trend_bias_score <= -2.4:
+        trend_alignment = "fully_bearish"
+    elif trend_bias_score <= -0.8:
+        trend_alignment = "bearish_pullback"
+    else:
+        trend_alignment = "mixed"
+
+    trend_strength = round(min(abs(trend_bias_score) / 4.0, 1.0), 4)
+
+    if rsi is None:
+        rsi_zone = "unknown"
+    elif rsi >= 70:
+        rsi_zone = "overbought"
+    elif rsi >= 60:
+        rsi_zone = "strong"
+    elif rsi <= 30:
+        rsi_zone = "oversold"
+    elif rsi <= 40:
+        rsi_zone = "weak"
+    else:
+        rsi_zone = "neutral"
+    rsi_distance_from_50 = round((rsi - 50.0), 4) if rsi is not None else None
+
+    macd_sign = "positive" if (macd_hist or 0) > 0 else "negative" if (macd_hist or 0) < 0 else "flat"
+    macd_strength = round(min(abs((macd_hist or 0.0) * 100), 1.0), 4)
+    momentum_bias_score = round((_clamp((rsi_distance_from_50 or 0.0) / 20.0, -1.0, 1.0) * 0.65) + ((_clamp((macd_hist or 0.0) * 120.0, -1.0, 1.0)) * 0.35), 4)
+    momentum_alignment = _label_signed(momentum_bias_score, "bullish", "neutral", "bearish", threshold=0.18)
+    momentum_impulse = _label_magnitude(abs(momentum_bias_score), 0.2, 0.55)
+
+    band_width_pct = None
+    price_band_position = None
+    if band_upper is not None and band_lower is not None and price is not None and price != 0:
+        band_width_pct = round(((band_upper - band_lower) / price) * 100.0, 4)
+        span = band_upper - band_lower
+        if span > 0:
+            pos = (price - band_lower) / span
+            price_band_position = round(pos, 4)
+    if price_band_position is None:
+        price_location = "unknown"
+    elif price_band_position >= 0.85:
+        price_location = "near_upper_band"
+    elif price_band_position >= 0.6:
+        price_location = "upper_half"
+    elif price_band_position <= 0.15:
+        price_location = "near_lower_band"
+    elif price_band_position <= 0.4:
+        price_location = "lower_half"
+    else:
+        price_location = "centered"
+
+    if atr_percent is None:
+        volatility_regime = "unknown"
+    elif atr_percent < 0.35:
+        volatility_regime = "compressed"
+    elif atr_percent < 1.2:
+        volatility_regime = "normal"
+    else:
+        volatility_regime = "expanded"
+
+    if price_vs_vwap_pct is None:
+        value_location = "unknown"
+    elif price_vs_vwap_pct > 0.35:
+        value_location = "above_value"
+    elif price_vs_vwap_pct < -0.35:
+        value_location = "below_value"
+    else:
+        value_location = "at_value"
+
+    if trend_alignment in {"fully_bullish", "bullish_pullback"} and value_location == "above_value":
+        auction_state = "acceptance_above_value"
+    elif trend_alignment in {"fully_bearish", "bearish_pullback"} and value_location == "below_value":
+        auction_state = "acceptance_below_value"
+    elif price_location == "near_upper_band" and momentum_alignment in {"bullish", "neutral"}:
+        auction_state = "breakout_attempt"
+    elif price_location == "near_lower_band" and momentum_alignment in {"bearish", "neutral"}:
+        auction_state = "breakdown_attempt"
+    else:
+        auction_state = "rotation"
+
+    stretch_state = "normal"
+    if price_location in {"near_upper_band", "near_lower_band"} and volatility_regime != "compressed":
+        stretch_state = "stretched"
+
+    reversal_risk = "low"
+    if reversal_direction != "none" or reversal_score >= 40:
+        reversal_risk = "high" if reversal_score >= 60 else "medium"
+
+    distance_to_upper_band_pct = pct_delta(band_upper, price) if band_upper is not None and price is not None else None
+    distance_to_lower_band_pct = pct_delta(price, band_lower) if band_lower is not None and price is not None else None
+
+    return {
+        "timeframe": timeframe,
+        "raw": {
+            "price": price,
+            "ema_fast": ema_fast,
+            "ema_slow": ema_slow,
+            "sma_long": sma_long,
+            "vwap": vwap,
+            "rsi": rsi,
+            "macd_histogram": macd_hist,
+            "atr": atr,
+            "atr_percent": atr_percent,
+            "bollinger_middle": band_middle,
+            "bollinger_upper": band_upper,
+            "bollinger_lower": band_lower,
+            "reversal_direction": reversal_direction,
+            "reversal_score": reversal_score,
+        },
+        "trend": {
+            "price_vs_ema_fast_pct": price_vs_ema_fast_pct,
+            "price_vs_ema_slow_pct": price_vs_ema_slow_pct,
+            "price_vs_sma_long_pct": price_vs_sma_long_pct,
+            "price_vs_vwap_pct": price_vs_vwap_pct,
+            "ema_fast_vs_slow_pct": ema_fast_vs_slow_pct,
+            "ema_slow_vs_sma_long_pct": ema_slow_vs_sma_long_pct,
+            "alignment": trend_alignment,
+            "strength": trend_strength,
+            "bias_score": trend_bias_score,
+        },
+        "momentum": {
+            "rsi_zone": rsi_zone,
+            "rsi_distance_from_50": rsi_distance_from_50,
+            "macd_sign": macd_sign,
+            "macd_strength": macd_strength,
+            "alignment": momentum_alignment,
+            "impulse": momentum_impulse,
+            "bias_score": momentum_bias_score,
+            "reversal_risk": reversal_risk,
+        },
+        "volatility": {
+            "atr_percent": atr_percent,
+            "bollinger_width_pct": band_width_pct,
+            "regime": volatility_regime,
+            "stretch_state": stretch_state,
+        },
+        "location": {
+            "price_band_position": price_band_position,
+            "price_location": price_location,
+            "value_location": value_location,
+            "auction_state": auction_state,
+            "distance_to_upper_band_pct": distance_to_upper_band_pct,
+            "distance_to_lower_band_pct": distance_to_lower_band_pct,
+        },
+    }
+
+
+def _scope_from_features(name: str, feature_map: dict[str, dict[str, Any]], timeframes: tuple[str, ...]) -> dict[str, Any]:
+    available = [feature_map[tf] for tf in timeframes if tf in feature_map]
+    trend_scores = [f["trend"]["bias_score"] for f in available]
+    momentum_scores = [f["momentum"]["bias_score"] for f in available]
+    atr_values = [f["volatility"]["atr_percent"] for f in available if f["volatility"]["atr_percent"] is not None]
+    weights = [TIMEFRAME_WEIGHTS.get(f["timeframe"], 1) for f in available]
+
+    if available and weights:
+        weighted_trend = sum(f["trend"]["bias_score"] * TIMEFRAME_WEIGHTS.get(f["timeframe"], 1) for f in available) / sum(weights)
+        weighted_momentum = sum(f["momentum"]["bias_score"] * TIMEFRAME_WEIGHTS.get(f["timeframe"], 1) for f in available) / sum(weights)
+    else:
+        weighted_trend = 0.0
+        weighted_momentum = 0.0
+
+    volatility_mean = _mean(atr_values)
+    volatility_state = (
+        "compressed" if volatility_mean is not None and volatility_mean < 0.35
+        else "expanded" if volatility_mean is not None and volatility_mean >= 1.2
+        else "normal" if volatility_mean is not None
+        else "unknown"
+    )
+    bias = _label_signed(weighted_trend, "bullish", "mixed", "bearish", threshold=0.55)
+
+    if name == "micro":
+        impulse = _label_signed(weighted_momentum, "up", "mixed", "down", threshold=0.22)
+        location_states = [f["location"]["price_location"] for f in available]
+        reversal_states = [f["momentum"]["reversal_risk"] for f in available]
+        return {
+            "impulse": impulse,
+            "momentum_quality": _label_magnitude(abs(weighted_momentum), 0.18, 0.45),
+            "volatility_state": volatility_state,
+            "reversal_risk": "high" if "high" in reversal_states else "medium" if "medium" in reversal_states else "low",
+            "location": location_states[-1] if location_states else "unknown",
+            "trend_bias": bias,
+            "bias_score": round(weighted_trend, 4),
+        }
+
+    if name == "meso":
+        trend_strength = round(min(abs(weighted_trend) / 2.0, 1.0), 4)
+        location_states = [f["location"]["auction_state"] for f in available]
+        if bias == "bullish" and abs(weighted_momentum) < 0.22:
+            structure = "bullish_pullback"
+        elif bias == "bearish" and abs(weighted_momentum) < 0.22:
+            structure = "bearish_pullback"
+        elif bias == "mixed":
+            structure = "rotation"
+        else:
+            structure = "trend_continuation"
+        return {
+            "structure": structure,
+            "trend_strength": trend_strength,
+            "momentum_bias": _label_signed(weighted_momentum, "bullish", "neutral", "bearish", threshold=0.2),
+            "auction_state": location_states[-1] if location_states else "unknown",
+            "volatility_state": volatility_state,
+            "bias_score": round(weighted_trend, 4),
+        }
+
+    fragility = "high" if volatility_state == "expanded" and abs(weighted_momentum) < 0.18 else "low"
+    structure = "healthy_trend" if bias in {"bullish", "bearish"} and fragility == "low" else "fragile_trend" if bias in {"bullish", "bearish"} else "range_context"
+    return {
+        "bias": bias,
+        "structure": structure,
+        "context_pressure": "supports_longs" if bias == "bullish" else "supports_shorts" if bias == "bearish" else "two_way",
+        "fragility": fragility,
+        "volatility_state": volatility_state,
+        "bias_score": round(weighted_trend, 4),
+    }
+
+
+def _cross_scope_summary(micro: dict[str, Any], meso: dict[str, Any], macro: dict[str, Any], feature_map: dict[str, dict[str, Any]]) -> dict[str, Any]:
+    micro_dir = micro.get("trend_bias") or micro.get("impulse")
+    meso_dir = meso.get("momentum_bias")
+    macro_dir = macro.get("bias")
+    dirs = [d for d in (micro_dir, meso_dir, macro_dir) if d not in {None, "mixed", "neutral"}]
+    unique_dirs = set(dirs)
+    if len(unique_dirs) <= 1 and dirs:
+        alignment = "micro_meso_macro_aligned"
+        friction = "low"
+    elif len(unique_dirs) == 2:
+        alignment = "partial_alignment"
+        friction = "medium"
+    else:
+        alignment = "cross_currents"
+        friction = "high"
+
+    if macro.get("bias") == "bullish" and meso.get("structure") in {"bullish_pullback", "trend_continuation"}:
+        opportunity_type = "trend_continuation"
+    elif macro.get("bias") == "bearish" and meso.get("structure") in {"bearish_pullback", "trend_continuation"}:
+        opportunity_type = "trend_continuation"
+    elif micro.get("reversal_risk") == "high":
+        opportunity_type = "reversal_watch"
+    else:
+        opportunity_type = "mean_reversion_or_wait"
+
+    feature_count = len(feature_map)
+    confidence = 0.35 + min(feature_count, 6) * 0.08
+    if friction == "low":
+        confidence += 0.08
+    elif friction == "high":
+        confidence -= 0.08
+    confidence = round(_clamp(confidence, 0.2, 0.95), 3)
+
+    return {
+        "alignment": alignment,
+        "friction": friction,
+        "opportunity_type": opportunity_type,
+        "confidence": confidence,
+    }
+
+
+def _derive_top_level(scoped_state: dict[str, Any], cross_scope: dict[str, Any], account_info: dict[str, Any], concern: dict[str, Any]) -> dict[str, Any]:
+    macro = scoped_state["macro"]
+    micro = scoped_state["micro"]
+    meso = scoped_state["meso"]
+    balances = account_info.get("balances") if isinstance(account_info.get("balances"), list) else []
+    account_status = str(account_info.get("status") or concern.get("status") or "active").lower()
+
+    market_regime = "range"
+    if macro.get("bias") == "bullish":
+        market_regime = "bull"
+    elif macro.get("bias") == "bearish":
+        market_regime = "bear"
+
+    volatility_state = macro.get("volatility_state")
+    if volatility_state == "expanded":
+        volatility_state = "high"
+    elif volatility_state == "normal":
+        volatility_state = "medium"
+    elif volatility_state == "compressed":
+        volatility_state = "low"
+
+    sentiment_pressure = "neutral"
+    if meso.get("momentum_bias") == "bullish":
+        sentiment_pressure = "bullish"
+    elif meso.get("momentum_bias") == "bearish":
+        sentiment_pressure = "bearish"
+
+    event_risk = "normal"
+    if cross_scope.get("friction") == "high" or micro.get("reversal_risk") == "high":
+        event_risk = "elevated"
+
+    liquidity_state = "healthy" if balances else "unknown"
+    execution_quality = "good" if account_status == "active" and cross_scope.get("friction") != "high" else "degraded"
+
+    return {
+        "market_regime": market_regime,
+        "volatility_state": volatility_state or "unknown",
+        "liquidity_state": liquidity_state,
+        "sentiment_pressure": sentiment_pressure,
+        "event_risk": event_risk,
+        "execution_quality": execution_quality,
+    }
+
+
+def synthesize_state(*, concern: dict[str, Any], regimes: list[dict[str, Any]], account_info: dict[str, Any] | None = None) -> StateSnapshot:
+    account_info = account_info or {}
+    features_by_timeframe = {
+        tf: extract_regime_features(regime)
+        for tf in ("1m", "5m", "15m", "1h", "4h", "1d")
+        if (regime := _pick_latest(regimes, tf)) is not None
+    }
+
+    scoped_state = {
+        "micro": _scope_from_features("micro", features_by_timeframe, MICRO_TIMEFRAMES),
+        "meso": _scope_from_features("meso", features_by_timeframe, MESO_TIMEFRAMES),
+        "macro": _scope_from_features("macro", features_by_timeframe, MACRO_TIMEFRAMES),
+    }
+    cross_scope = _cross_scope_summary(scoped_state["micro"], scoped_state["meso"], scoped_state["macro"], features_by_timeframe)
+    top_level = _derive_top_level(scoped_state, cross_scope, account_info, concern)
+
+    payload = {
+        "concern_id": concern.get("id"),
+        "account_id": concern.get("account_id"),
+        "market_symbol": concern.get("market_symbol"),
+        "generated_at": datetime.now(timezone.utc).isoformat(),
+        "source_count": len(features_by_timeframe),
+        "features_by_timeframe": features_by_timeframe,
+        "scoped_state": scoped_state,
+        "cross_scope_summary": cross_scope,
+    }
+
+    return StateSnapshot(
+        market_regime=top_level["market_regime"],
+        volatility_state=top_level["volatility_state"],
+        liquidity_state=top_level["liquidity_state"],
+        sentiment_pressure=top_level["sentiment_pressure"],
+        event_risk=top_level["event_risk"],
+        execution_quality=top_level["execution_quality"],
+        confidence=cross_scope["confidence"],
+        payload=payload,
+    )

+ 61 - 0
src/hermes_mcp/store.py

@@ -342,6 +342,67 @@ def upsert_regime_sample(*, id: str, cycle_id: str, concern_id: str, timeframe:
         )
 
 
+def upsert_state(*, id: str, cycle_id: str, concern_id: str, market_regime: str | None, volatility_state: str | None, liquidity_state: str | None, sentiment_pressure: str | None, event_risk: str | None, execution_quality: str | None, confidence: float | None, payload_json: str, created_at: str | None = None) -> None:
+    init_db()
+    created_at = created_at or _now()
+    with _connect() as conn:
+        conn.execute(
+            """
+            insert into states(id, cycle_id, concern_id, market_regime, volatility_state, liquidity_state, sentiment_pressure, event_risk, execution_quality, confidence, payload_json, created_at)
+            values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+            on conflict(id) do update set
+              cycle_id=excluded.cycle_id,
+              concern_id=excluded.concern_id,
+              market_regime=excluded.market_regime,
+              volatility_state=excluded.volatility_state,
+              liquidity_state=excluded.liquidity_state,
+              sentiment_pressure=excluded.sentiment_pressure,
+              event_risk=excluded.event_risk,
+              execution_quality=excluded.execution_quality,
+              confidence=excluded.confidence,
+              payload_json=excluded.payload_json,
+              created_at=excluded.created_at
+            """,
+            (id, cycle_id, concern_id, market_regime, volatility_state, liquidity_state, sentiment_pressure, event_risk, execution_quality, confidence, payload_json, created_at),
+        )
+
+
+def upsert_narrative(*, id: str, cycle_id: str, concern_id: str, summary: str, key_drivers_json: str, risk_flags_json: str, uncertainties_json: str, confidence: float | None, created_at: str | None = None) -> None:
+    init_db()
+    created_at = created_at or _now()
+    with _connect() as conn:
+        conn.execute(
+            """
+            insert into narratives(id, cycle_id, concern_id, summary, key_drivers_json, risk_flags_json, uncertainties_json, confidence, created_at)
+            values(?, ?, ?, ?, ?, ?, ?, ?, ?)
+            on conflict(id) do update set
+              cycle_id=excluded.cycle_id,
+              concern_id=excluded.concern_id,
+              summary=excluded.summary,
+              key_drivers_json=excluded.key_drivers_json,
+              risk_flags_json=excluded.risk_flags_json,
+              uncertainties_json=excluded.uncertainties_json,
+              confidence=excluded.confidence,
+              created_at=excluded.created_at
+            """,
+            (id, cycle_id, concern_id, summary, key_drivers_json, risk_flags_json, uncertainties_json, confidence, created_at),
+        )
+
+
+def latest_states(limit: int = 20) -> list[dict[str, Any]]:
+    init_db()
+    with _connect() as conn:
+        rows = conn.execute("select * from states order by created_at desc limit ?", (limit,)).fetchall()
+    return [dict(r) for r in rows]
+
+
+def latest_narratives(limit: int = 20) -> list[dict[str, Any]]:
+    init_db()
+    with _connect() as conn:
+        rows = conn.execute("select * from narratives order by created_at desc limit ?", (limit,)).fetchall()
+    return [dict(r) for r in rows]
+
+
 def latest_regime_samples(limit: int = 20) -> list[dict[str, Any]]:
     init_db()
     with _connect() as conn:

+ 36 - 0
tests/test_narrative_engine.py

@@ -0,0 +1,36 @@
+from hermes_mcp.narrative_engine import STANCE_TAXONOMY, build_narrative
+from hermes_mcp.state_engine import synthesize_state
+
+
+def _bullish_state_payload():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "BTCUSD", "status": "active"}
+    regimes = [
+        {"timeframe": "1m", "price": 101, "trend": {"ema_fast": 100, "ema_slow": 99, "sma_long": 98}, "momentum": {"rsi": 62, "macd_histogram": 0.02}, "volatility": {"atr_percent": 0.5}, "bands": {"bollinger": {"middle": 100, "upper": 102, "lower": 98}}, "vwap": 100.2, "reversal": {"direction": "none", "score": 0}},
+        {"timeframe": "5m", "price": 102, "trend": {"ema_fast": 101, "ema_slow": 100, "sma_long": 99}, "momentum": {"rsi": 63, "macd_histogram": 0.018}, "volatility": {"atr_percent": 0.6}, "bands": {"bollinger": {"middle": 101, "upper": 103, "lower": 99}}, "vwap": 101.1, "reversal": {"direction": "none", "score": 0}},
+        {"timeframe": "15m", "price": 103, "trend": {"ema_fast": 102, "ema_slow": 101, "sma_long": 99.5}, "momentum": {"rsi": 60, "macd_histogram": 0.012}, "volatility": {"atr_percent": 0.8}, "bands": {"bollinger": {"middle": 102, "upper": 104, "lower": 100}}, "vwap": 102.0, "reversal": {"direction": "none", "score": 0}},
+        {"timeframe": "1h", "price": 104, "trend": {"ema_fast": 103, "ema_slow": 102, "sma_long": 100}, "momentum": {"rsi": 58, "macd_histogram": 0.01}, "volatility": {"atr_percent": 0.9}, "bands": {"bollinger": {"middle": 103, "upper": 105, "lower": 101}}, "vwap": 103.0, "reversal": {"direction": "none", "score": 0}},
+        {"timeframe": "4h", "price": 106, "trend": {"ema_fast": 104, "ema_slow": 103, "sma_long": 101}, "momentum": {"rsi": 57, "macd_histogram": 0.009}, "volatility": {"atr_percent": 1.1}, "bands": {"bollinger": {"middle": 104, "upper": 107, "lower": 101}}, "vwap": 104.5, "reversal": {"direction": "none", "score": 0}},
+        {"timeframe": "1d", "price": 110, "trend": {"ema_fast": 108, "ema_slow": 106, "sma_long": 103}, "momentum": {"rsi": 61, "macd_histogram": 0.008}, "volatility": {"atr_percent": 1.4}, "bands": {"bollinger": {"middle": 107, "upper": 112, "lower": 102}}, "vwap": 108.0, "reversal": {"direction": "none", "score": 0}},
+    ]
+    state = synthesize_state(concern=concern, regimes=regimes)
+    return concern, state.payload
+
+
+def test_build_narrative_produces_stable_taxonomy_output():
+    concern, payload = _bullish_state_payload()
+    narrative = build_narrative(concern=concern, state_payload=payload)
+    assert narrative.payload["stance"] in STANCE_TAXONOMY
+    assert isinstance(narrative.key_drivers, list)
+    assert isinstance(narrative.risk_flags, list)
+    assert isinstance(narrative.uncertainties, list)
+    assert 0.2 <= narrative.confidence <= 0.95
+
+
+def test_build_narrative_describes_opportunity_without_deciding():
+    concern, payload = _bullish_state_payload()
+    narrative = build_narrative(concern=concern, state_payload=payload)
+    opportunity_map = narrative.payload["opportunity_map"]
+    assert set(opportunity_map) == {"continuation", "mean_reversion", "reversal", "wait"}
+    assert max(opportunity_map, key=opportunity_map.get) in {"continuation", "wait", "mean_reversion", "reversal"}
+    assert "buy now" not in narrative.summary.lower()
+    assert "sell now" not in narrative.summary.lower()

+ 39 - 0
tests/test_state_engine.py

@@ -0,0 +1,39 @@
+from hermes_mcp.state_engine import extract_regime_features, synthesize_state
+
+
+def test_extract_regime_features_preserves_structure():
+    regime = {
+        "symbol": "XRP",
+        "timeframe": "5m",
+        "price": 1.4189,
+        "trend": {"ema_fast": 1.409869, "ema_slow": 1.411053, "sma_long": 1.405942, "state": "range"},
+        "momentum": {"rsi": 59.64, "macd_histogram": 0.001331, "state": "neutral"},
+        "volatility": {"atr": 0.006216, "atr_percent": 0.4381},
+        "bands": {"bollinger": {"middle": 1.410125, "upper": 1.431226, "lower": 1.389024}},
+        "vwap": 1.411719,
+        "reversal": {"direction": "none", "score": 0, "triggers": []},
+    }
+    features = extract_regime_features(regime)
+    assert features["trend"]["alignment"] in {"mixed", "bullish_pullback", "fully_bullish"}
+    assert features["momentum"]["rsi_zone"] == "neutral"
+    assert features["volatility"]["regime"] == "normal"
+    assert features["location"]["auction_state"]
+
+
+def test_synthesize_state_builds_scoped_state():
+    concern = {"id": "c1", "account_id": "a1", "market_symbol": "BTCUSD", "status": "active"}
+    regimes = [
+        {"timeframe": "1m", "price": 101, "trend": {"ema_fast": 100, "ema_slow": 99, "sma_long": 98, "state": "bull"}, "momentum": {"rsi": 62, "macd_histogram": 0.02, "state": "bull"}, "volatility": {"atr_percent": 0.5}, "bands": {"bollinger": {"middle": 100, "upper": 102, "lower": 98}}, "vwap": 100.2, "reversal": {"direction": "none", "score": 0}},
+        {"timeframe": "5m", "price": 102, "trend": {"ema_fast": 101, "ema_slow": 100, "sma_long": 99, "state": "bull"}, "momentum": {"rsi": 63, "macd_histogram": 0.018, "state": "bull"}, "volatility": {"atr_percent": 0.6}, "bands": {"bollinger": {"middle": 101, "upper": 103, "lower": 99}}, "vwap": 101.1, "reversal": {"direction": "none", "score": 0}},
+        {"timeframe": "15m", "price": 103, "trend": {"ema_fast": 102, "ema_slow": 101, "sma_long": 99.5, "state": "bull"}, "momentum": {"rsi": 60, "macd_histogram": 0.012, "state": "neutral"}, "volatility": {"atr_percent": 0.8}, "bands": {"bollinger": {"middle": 102, "upper": 104, "lower": 100}}, "vwap": 102.0, "reversal": {"direction": "none", "score": 0}},
+        {"timeframe": "1h", "price": 104, "trend": {"ema_fast": 103, "ema_slow": 102, "sma_long": 100, "state": "bull"}, "momentum": {"rsi": 58, "macd_histogram": 0.01, "state": "neutral"}, "volatility": {"atr_percent": 0.9}, "bands": {"bollinger": {"middle": 103, "upper": 105, "lower": 101}}, "vwap": 103.0, "reversal": {"direction": "none", "score": 0}},
+        {"timeframe": "4h", "price": 106, "trend": {"ema_fast": 104, "ema_slow": 103, "sma_long": 101, "state": "bull"}, "momentum": {"rsi": 57, "macd_histogram": 0.009, "state": "neutral"}, "volatility": {"atr_percent": 1.1}, "bands": {"bollinger": {"middle": 104, "upper": 107, "lower": 101}}, "vwap": 104.5, "reversal": {"direction": "none", "score": 0}},
+        {"timeframe": "1d", "price": 110, "trend": {"ema_fast": 108, "ema_slow": 106, "sma_long": 103, "state": "bull"}, "momentum": {"rsi": 61, "macd_histogram": 0.008, "state": "bull"}, "volatility": {"atr_percent": 1.4}, "bands": {"bollinger": {"middle": 107, "upper": 112, "lower": 102}}, "vwap": 108.0, "reversal": {"direction": "none", "score": 0}},
+    ]
+    state = synthesize_state(concern=concern, regimes=regimes)
+    assert state.market_regime == "bull"
+    assert state.sentiment_pressure == "bullish"
+    assert state.payload["scoped_state"]["macro"]["bias"] == "bullish"
+    assert state.payload["scoped_state"]["micro"]["impulse"] == "up"
+    assert state.payload["cross_scope_summary"]["alignment"] in {"micro_meso_macro_aligned", "partial_alignment"}
+    assert state.confidence > 0.5