#!/usr/bin/env python3
"""
run_formula_golden_cases_v2.py
───────────────────────────────────────────────────────────────────────────────
행위기반 커버리지 하네스 — Python 미러 검증기 (BCH-V1 B03 단계)

spec/formula_golden_cases_v2.yaml 의 golden cases 를 Python 미러 함수로 실행해
expected 값과 허용오차 내 일치하는지 검증한다.

출력: Temp/formula_behavioral_coverage_v1.json
  - behavioral_coverage_pct: decision-critical 중 1개 이상 통과 공식 비율
  - per_formula: 공식별 pass/fail/gap 상세
  - python_divergences: GAS 정답(spec_correct)과 다른 Python 결과 목록

사용법:
  python tools/run_formula_golden_cases_v2.py
  python tools/run_formula_golden_cases_v2.py --strict  # 1개라도 실패 시 exit 1
"""

from __future__ import annotations

import json
import math
import sys
from pathlib import Path

import yaml
import importlib, inspect as _inspect

ROOT = Path(__file__).resolve().parents[2]


def _mirror_compute(fn_name: str, inputs: dict) -> dict:
    """compute_formula_outputs.py의 함수를 이름으로 동적 호출."""
    import sys as _sys
    _sys.path.insert(0, str(ROOT))
    mod = importlib.import_module("src.quant_engine.compute_formula_outputs")
    fn = getattr(mod, fn_name, None)
    if fn is None:
        return {"error": f"{fn_name} not found in compute_formula_outputs"}
    sig = _inspect.signature(fn)
    params = list(sig.parameters.keys())
    # inputs dict에서 파라미터명 매핑
    kwargs = {}
    for p in params:
        if p in inputs:
            kwargs[p] = inputs[p]
    try:
        return fn(**kwargs)
    except Exception as e:
        return {"error": str(e)}
GOLDEN_FILE = ROOT / "spec" / "formula_golden_cases_v2.yaml"
OUTPUT_FILE = ROOT / "Temp" / "formula_behavioral_coverage_v1.json"

# Windows cp949 호환
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
    sys.stdout = open(sys.stdout.fileno(), mode="w", encoding="utf-8", buffering=1)


# ─────────────────────────────────────────────────────────────────────────────
# Python 미러 함수 정의
# spec/formula_golden_cases_v2.yaml 의 python_function 필드와 매핑된다.
# 각 함수는 inputs dict → outputs dict 를 반환한다.
# ─────────────────────────────────────────────────────────────────────────────

# === KRX 호가단위 테이블 ===
KRX_TICK_TABLE = [
    (2_000, 1),
    (5_000, 5),
    (20_000, 10),
    (50_000, 50),
    (200_000, 100),
    (500_000, 500),
    (math.inf, 1000),
]


def _krx_tick_unit(price: float) -> int:
    for threshold, tick in KRX_TICK_TABLE:
        if price < threshold:
            return tick
    return 1000


def py_normalize_tick(inputs: dict) -> dict:
    """TICK_NORMALIZER_V1 — Python 미러.
    KRX HTS 입력은 floor(내림) 방식. GAS Math.floor() 와 동일.
    B06 수정: round() → math.floor() 로 정정.
    """
    price = float(inputs["price"])
    tick = _krx_tick_unit(price)
    normalized = int(math.floor(price / tick) * tick)
    return {"normalized": normalized}


def py_velocity(inputs: dict) -> dict:
    """VELOCITY_V1"""
    close = float(inputs["close"])
    prev = float(inputs["prevClose"])
    ret5d = float(inputs.get("ret5d", 0))
    v1d = (close - prev) / prev * 100 if prev else 0.0
    return {"velocity_1d": round(v1d, 4), "velocity_5d": round(ret5d, 4)}


def py_classify_profit_lock(inputs: dict) -> dict:
    """PROFIT_LOCK_STAGE_V1 — spec/AGENTS.md L2 기준."""
    p = float(inputs["profit_pct"])
    if p >= 60:
        stage = "APEX_SUPER"
    elif p >= 40:
        stage = "APEX_TRAILING"
    elif p >= 30:
        stage = "PROFIT_LOCK_30"
    elif p >= 20:
        stage = "PROFIT_LOCK_20"
    elif p >= 10:
        stage = "PROFIT_LOCK_10"
    elif p >= 0:
        stage = "BREAKEVEN_RATCHET"
    else:
        stage = "NORMAL"
    return {"stage": stage}


def py_anti_chasing(inputs: dict) -> dict:
    """ANTI_CHASING_VELOCITY_V1"""
    v = float(inputs["velocity_1d"])
    if v >= 3.0:
        return {"anti_chasing_verdict": "BLOCK_CHASE", "anti_chasing_velocity_status": "BLOCKED"}
    elif v >= 1.5:
        return {"anti_chasing_verdict": "PULLBACK_WAIT", "anti_chasing_velocity_status": "WAIT"}
    else:
        return {"anti_chasing_verdict": "CLEAR", "anti_chasing_velocity_status": "PASS"}


def py_pullback_trigger(inputs: dict) -> dict:
    """PULLBACK_ENTRY_TRIGGER_V1"""
    close = float(inputs["close"])
    ma20 = float(inputs["ma20"])
    atr20 = float(inputs["atr20"])

    def _floor_tick(price: float) -> int:
        tick = _krx_tick_unit(price)
        return int(math.floor(price / tick) * tick)  # B06 수정: floor 방식

    trigger = _floor_tick(ma20 - 0.5 * atr20)
    upper_band = _floor_tick(ma20 * 1.03)

    if close <= ma20 * 1.03:
        verdict = "PULLBACK_ZONE"
        state = "PASS"
    else:
        verdict = "ABOVE_PULLBACK_ZONE"
        state = "BLOCKED"

    return {
        "pullback_entry_verdict": verdict,
        "pullback_state": state,
        "pullback_entry_trigger_price": trigger,
        "pullback_upper_band": upper_band,
    }


def py_sell_price_sanity(inputs: dict) -> dict:
    """SELL_PRICE_SANITY_V1"""
    sell = float(inputs["sell_limit_price"])
    stop = inputs.get("stop_loss_price")
    prev = float(inputs["prev_close"])
    stop = float(stop) if stop is not None else None

    status = "PASS"
    if stop is not None and sell < stop:
        status = "INVALID_PRICE_INVERSION"
    if status == "PASS" and sell > prev * 1.30:
        status = "INVALID_UNREALISTIC_PRICE"
    if status == "PASS":
        tick = _krx_tick_unit(sell)
        if sell % tick != 0:
            status = "INVALID_TICK"

    return {
        "sell_price_sanity_status": status,
        "hts_allowed": status == "PASS",
        "shadow_ledger": status != "PASS",
    }


def py_dynamic_heat_gate(inputs: dict) -> dict:
    """DYNAMIC_HEAT_GATE_V1"""
    r = str(inputs.get("regime", "")).upper()
    if "EVENT_SHOCK" in r:
        return {"hardBlock": 5.0, "halve": 3.5}
    if "RISK_OFF" in r:
        return {"hardBlock": 7.0, "halve": 5.0}
    if "SECULAR_LEADER" in r and "RISK_ON" in r:
        return {"hardBlock": 13.0, "halve": 9.0}
    if "RISK_ON" in r:
        return {"hardBlock": 12.0, "halve": 8.5}
    return {"hardBlock": 10.0, "halve": 7.0}


def py_position_size_regime_scale(inputs: dict) -> dict:
    """POSITION_SIZE_REGIME_SCALE_V1"""
    r = str(inputs.get("regime", "")).upper()
    if "EVENT_SHOCK" in r:
        scale = 0.25
    elif "RISK_OFF" in r:
        scale = 0.50
    elif "SECULAR_LEADER" in r and "RISK_ON" in r:
        scale = 1.2
    elif "RISK_ON" in r:
        scale = 1.1
    else:
        scale = 1.0
    return {"scale": scale}


def py_regime_cash_uplift(inputs: dict) -> dict:
    """REGIME_CASH_UPLIFT_V1"""
    r = str(inputs.get("regime", "")).upper()
    mrs_min = float(inputs.get("mrsCashMinPct", 0))
    if "EVENT_SHOCK" in r:
        regime_min = 20
    elif "RISK_OFF" in r:
        regime_min = 15
    elif "RISK_ON" in r:
        regime_min = 5
    else:
        regime_min = 0
    return {"effective_min_pct": max(mrs_min, regime_min)}


def py_drawdown_guard(inputs: dict) -> dict:
    """DRAWDOWN_GUARD_V1"""
    cons = int(inputs.get("consecutive_losses", 0))
    if cons >= 5:
        return {"state": "NO_BUY", "buy_scale": 0.0}
    elif cons >= 3:
        return {"state": "REDUCE_BUY", "buy_scale": 0.5}
    elif cons >= 2:
        return {"state": "CAUTION_BUY", "buy_scale": 0.75}
    else:
        return {"state": "NORMAL", "buy_scale": 1.0}


def py_position_count_limit(inputs: dict) -> dict:
    """POSITION_COUNT_LIMIT_V1"""
    r = str(inputs.get("marketRegime", "")).upper()
    is_risk_off = "EVENT_SHOCK" in r or "RISK_OFF" in r
    max_count = 6 if is_risk_off else 8
    count = int(inputs.get("holdings_count", 0))
    gate = "POSITION_COUNT_BLOCK" if count > max_count else "PASS"
    return {
        "gate_status": gate,
        "max_count": max_count,
        "excess_count": max(0, count - max_count),
    }


def py_cash_floor(inputs: dict) -> dict:
    """CASH_FLOOR_V1"""
    MRS_TABLE = [
        (3, 7, "normal"),
        (7, 10, "overheated_or_event_week"),
        (10, 15, "risk_off"),
    ]
    mrs = float(inputs.get("mrsScore", 5))
    cash_pct = float(inputs.get("settlementCashPct", 0))
    min_pct = 15
    label = "risk_off"
    for max_mrs, pct, lbl in MRS_TABLE:
        if mrs <= max_mrs:
            min_pct = pct
            label = lbl
            break
    if cash_pct >= min_pct:
        status = "PASS"
    elif cash_pct >= min_pct * 0.7:
        status = "TRIM_REQUIRED"
    else:
        status = "HARD_BLOCK"
    return {"minPct": min_pct, "status": status, "regime": label}


def py_semiconductor_cluster_gate(inputs: dict) -> dict:
    """MARKET_WEIGHT_AWARE_CLUSTER_GATE_V1 — 정책 기반 동적 한도"""
    holdings = inputs.get("holdings", [])
    regime   = str(inputs.get("marketRegime", "")).upper()
    is_event_shock    = "EVENT_SHOCK" in regime
    is_risk_off       = is_event_shock or "RISK_OFF" in regime
    is_risk_on        = "RISK_ON" in regime and not is_risk_off
    is_secular_leader = "SECULAR_LEADER" in regime
    is_cla = "CONCENTRATED_LEADER_ADVANCE" in regime or regime == "CLA"
    semi = ["005930", "000660", "229200"] if is_cla else ["005930", "000660"]
    total = sum(float(h.get("weightPct", 0)) for h in holdings if h.get("ticker") in semi)
    # 정책 기반 한도 (EXPERT_PRIOR — KOSPI 비중 미입력 시 고정값)
    if is_event_shock:       cap = 20.0
    elif is_risk_off:        cap = 25.0
    elif is_secular_leader or is_cla: cap = 65.0
    elif is_risk_on:         cap = 45.0
    else:                    cap = 35.0
    warn_threshold = cap * 0.80
    if total >= cap:
        gate = "CLUSTER_BLOCK" if is_risk_off else "CLUSTER_OVERWEIGHT_TRIM"
    elif total >= warn_threshold:
        # WARN 구간: cap 미만이면 국면 관계없이 WARN (RISK_OFF도 cap 이하면 WARN)
        gate = "CLUSTER_HOLD_ONLY" if (is_secular_leader or is_cla) else "CLUSTER_OVERWEIGHT_WARN"
    else:
        gate = "PASS"
    return {"gate_status": gate, "combined_pct": round(total, 2), "cap_pct": round(cap, 2)}


def py_profit_ratchet_v2(inputs: dict) -> dict:
    """PROFIT_RATCHET_TIERED_V2"""
    profit_pct = float(inputs["profit_pct"])
    highest = float(inputs["highest_close"])
    atr20 = float(inputs["atr20"])
    ratchet_stop = inputs.get("ratchet_stop")
    avg_cost = float(inputs.get("average_cost", 0))
    ratchet_stop = float(ratchet_stop) if ratchet_stop is not None else avg_cost

    # stage
    if profit_pct >= 60:
        stage = "APEX_SUPER"
    elif profit_pct >= 40:
        stage = "APEX_TRAILING"
    elif profit_pct >= 30:
        stage = "PROFIT_LOCK_30"
    elif profit_pct >= 20:
        stage = "PROFIT_LOCK_20"
    elif profit_pct >= 10:
        stage = "PROFIT_LOCK_10"
    elif profit_pct >= 0:
        stage = "BREAKEVEN_RATCHET"
    else:
        stage = "NORMAL"

    def _floor_tick(price: float) -> int:
        tick = _krx_tick_unit(price)
        return int(math.floor(price / tick) * tick)

    if stage == "APEX_SUPER":
        raw = highest - 1.2 * atr20
        trailing = _floor_tick(max(ratchet_stop, raw))
    elif stage == "APEX_TRAILING":
        raw = highest - 1.5 * atr20
        trailing = _floor_tick(max(ratchet_stop, raw))
    elif stage in ("PROFIT_LOCK_30", "PROFIT_LOCK_20"):
        raw = highest - 2.0 * atr20
        trailing = _floor_tick(max(ratchet_stop, raw))
    else:
        trailing = None

    return {"ratchet_stage_v2": stage, "auto_trailing_stop_v2": trailing}


def py_anti_late_entry_gate_v2(inputs: dict) -> dict:
    """ANTI_LATE_ENTRY_GATE_V2 — Python 미러 (GAS calcAntiLateEntryGateV2Impl_ 동일 로직)"""
    holdings = inputs.get("holdings", [])
    df_map = inputs.get("dfMap", {})

    results = []
    for h in holdings:
        ticker = h.get("ticker", "")
        df = df_map.get(ticker, {})
        close = float(h.get("close", df.get("close", 0) or 0))
        prev_close = float(df.get("prevClose", 0) or 0)
        ma20 = float(df.get("ma20", 0) or 0)
        rsi14 = float(df.get("rsi14", 50) if df.get("rsi14") is not None else 50)
        flow_credit = float(df.get("flowCredit", 0) if df.get("flowCredit") is not None else 0)
        volume = float(df.get("volume", 0) if df.get("volume") is not None else 0)
        avg_vol5d = float(df.get("avgVolume5d", 0) if df.get("avgVolume5d") is not None else 0)
        frg5d = float(df.get("frg5d", 0) if df.get("frg5d") is not None else 0)
        inst5d = float(df.get("inst5d", 0) if df.get("inst5d") is not None else 0)
        ret5d = float(df.get("ret5d", 0) if df.get("ret5d") is not None else 0)
        ac_gate = str(df.get("acGate", "") or "")

        v1d = (close - prev_close) / prev_close * 100 if prev_close > 0 else 0.0
        v5d = ret5d

        dist_ws = 0.0
        if frg5d < 0:
            dist_ws += 2.0
        if inst5d < 0:
            dist_ws += 2.0
        if avg_vol5d > 0 and volume > avg_vol5d * 1.3:
            dist_ws += 1.5
        if prev_close > 0 and close < prev_close:
            dist_ws += 1.5
        if rsi14 > 70:
            dist_ws += 1.0
        if ac_gate == "BLOCK":
            dist_ws += 1.0

        if v1d >= 3.0:
            gate1 = "BLOCK_CHASE"
        elif v1d >= 1.5:
            gate1 = "PULLBACK_WAIT"
        else:
            gate1 = "PASS"

        if v5d >= 8.0:
            gate2 = "BLOCK_CHASE_5D"
        elif v5d >= 5.0:
            gate2 = "PULLBACK_WAIT_5D"
        else:
            gate2 = "PASS"

        if dist_ws >= 3.0:
            gate3 = "BLOCK_DISTRIBUTION"
        elif dist_ws >= 2.0:
            gate3 = "PULLBACK_WAIT_DIST"
        else:
            gate3 = "PASS"

        has_block = gate1 == "BLOCK_CHASE" or gate2 == "BLOCK_CHASE_5D" or gate3 == "BLOCK_DISTRIBUTION"
        has_pullback = gate1 == "PULLBACK_WAIT" or gate2 == "PULLBACK_WAIT_5D" or gate3 == "PULLBACK_WAIT_DIST"

        if has_block:
            final_gate = "BLOCK"
        elif has_pullback:
            final_gate = "PULLBACK_WAIT"
        else:
            final_gate = "PASS"

        if final_gate == "BLOCK":
            grade = "F"
        elif v1d < 0.5 and ma20 > 0 and close >= ma20 and close <= ma20 * 1.02 and flow_credit >= 0.55:
            grade = "A"
        elif v1d < 1.5 and ma20 > 0 and abs(close - ma20) / ma20 <= 0.05:
            grade = "B"
        elif final_gate == "PULLBACK_WAIT":
            grade = "C"
        elif v5d > 5.0:
            grade = "D"
        else:
            grade = "B"

        results.append({
            "ticker": ticker,
            "gate1_status": gate1,
            "gate2_status": gate2,
            "gate3_status": gate3,
            "final_gate_status": final_gate,
            "anti_late_entry_status": final_gate,
            "entry_grade": grade,
            "velocity_1d": round(v1d, 2),
            "velocity_5d": round(v5d, 2),
            "dist_weighted_sum": round(dist_ws, 1),
        })

    return results[0] if len(results) == 1 else {"results": results}


# ─────────────────────────────────────────────────────────────────────────────
# milestone_2 Python 미러 함수 (GAS 로직과 동일 규칙 구현)
# ─────────────────────────────────────────────────────────────────────────────

# TICKER_SECTOR_MAP 간략 버전 (GAS gas_data_feed.gs 동일)
_TICKER_SECTOR_MAP = {
    "005930": "반도체", "000660": "반도체", "042700": "반도체",
    "010120": "AI전력", "267260": "AI전력",
    "012450": "방산",   "064350": "방산",
    "329180": "조선",   "494670": "조선",
    "117700": "건설",   "028050": "플랜트/EPC", "454320": "플랜트/EPC",
    "0190C0": "로보틱스",
    "005380": "자동차", "000270": "자동차",
    "091160": "반도체", "0117V0": "AI전력",
}


def py_win_loss_streak_guard(inputs: dict) -> dict:
    """WIN_LOSS_STREAK_GUARD_V1"""
    win_rate = inputs.get("win_rate_30")
    trades   = int(inputs.get("trades_used", 0))
    if win_rate is None or trades < 10:
        return {"state": "INSUFFICIENT_HISTORY", "buy_scale": 1.0, "trades_used": trades}
    wr = float(win_rate)
    if   wr < 0.30: state, scale = "EDGE_CRITICAL",  0.25
    elif wr < 0.40: state, scale = "EDGE_DEGRADED",  0.50
    elif wr < 0.45: state, scale = "EDGE_WEAK",      0.75
    else:           state, scale = "EDGE_OK",         1.0
    return {"state": state, "buy_scale": scale, "win_rate_pct": round(wr * 100, 2), "trades_used": trades}


def py_single_position_weight_cap(inputs: dict) -> dict:
    """LEADER_POSITION_WEIGHT_CAP_V1 — 삼성/하이닉스 차등 한도"""
    holdings = inputs.get("holdings", [])
    r = str(inputs.get("marketRegime", "")).upper()
    is_event_shock    = "EVENT_SHOCK" in r
    is_risk_off       = is_event_shock or "RISK_OFF" in r
    is_risk_on        = "RISK_ON" in r and not is_risk_off
    is_secular_leader = "SECULAR_LEADER" in r
    default_cap = 15.0 if is_risk_off else (22.0 if is_risk_on else 20.0)
    gate = "PASS"
    for h in holdings:
        wpct = float(h.get("weightPct", 0))
        ticker = h.get("ticker", "")
        if ticker == "005930":
            if is_event_shock:       cap = 15.0
            elif is_risk_off:        cap = 18.0
            elif is_secular_leader:  cap = 50.0
            elif is_risk_on:         cap = 40.0
            else:                    cap = 28.0
        elif ticker == "000660":
            if is_event_shock:       cap = 10.0
            elif is_risk_off:        cap = 12.0
            elif is_secular_leader:  cap = 28.0
            elif is_risk_on:         cap = 22.0
            else:                    cap = 15.0
        else:
            cap = default_cap
        if wpct > cap:
            gate = "OVERWEIGHT_TRIM"
            break
    return {"gate_status": gate}


def py_regime_trim_guidance(inputs: dict) -> dict:
    """REGIME_TRIM_GUIDANCE_V1"""
    regime = str(inputs.get("regime", "")).upper()
    if "RISK_ON" in regime:
        return {"phase": "ADVANCE", "satellite_trim_pct_min": 0, "satellite_trim_pct_max": 5,
                "leader_trim_pct_min": 0, "leader_trim_pct_max": 0}
    elif "RISK_OFF" in regime or "EVENT_SHOCK" in regime:
        return {"phase": "BREAKDOWN", "satellite_trim_pct_min": 25, "satellite_trim_pct_max": 50,
                "leader_trim_pct_min": 10, "leader_trim_pct_max": 25}
    else:  # NEUTRAL / LEADER_CONCENTRATION
        return {"phase": "PULLBACK_IN_UPTREND", "satellite_trim_pct_min": 5, "satellite_trim_pct_max": 10,
                "leader_trim_pct_min": 0, "leader_trim_pct_max": 5}


def py_heat_concentration_alert(inputs: dict) -> dict:
    """HEAT_CONCENTRATION_ALERT_V1"""
    holdings     = inputs.get("holdings", [])
    total_heat   = float(inputs.get("totalHeatKrw", 0))
    if not total_heat:
        return {"gate": "INSUFFICIENT_DATA"}
    gate = "PASS"
    for h in holdings:
        avg_cost   = float(h.get("avgCost", 0))
        stop_price = float(h.get("stopPrice", 0))
        qty        = float(h.get("holdingQty", 0))
        heat_i     = (avg_cost - stop_price) * qty if avg_cost > 0 and stop_price > 0 and qty > 0 else 0
        share      = round(heat_i / total_heat * 100, 2)
        if share >= 50:
            gate = "HEAT_CONCENTRATED"
    return {"gate": gate}


def py_sector_concentration_gate(inputs: dict) -> dict:
    """SECTOR_CONCENTRATION_LIMIT_V1"""
    holdings = inputs.get("holdings", [])
    r        = str(inputs.get("marketRegime", "")).upper()
    is_risk_off = "EVENT_SHOCK" in r or "RISK_OFF" in r
    block_thresh = 35 if is_risk_off else 40
    sector_w: dict[str, float] = {}
    for h in holdings:
        sec = _TICKER_SECTOR_MAP.get(h.get("ticker", ""), "UNKNOWN")
        sector_w[sec] = sector_w.get(sec, 0) + float(h.get("weightPct", 0))
    gate = "PASS"
    for sec, w in sector_w.items():
        if w >= block_thresh:
            gate = "BLOCK_SECTOR"
            break
    return {"gate_status": gate}


def py_cash_shortfall(inputs: dict) -> dict:
    """CASH_SHORTFALL_V1 — 핵심 수식 검증"""
    as_result    = inputs.get("asResult", {})
    total_asset  = float(inputs.get("totalAsset", 0))
    cash_floor   = inputs.get("cashFloorInfo", {})
    mrs_score    = float(inputs.get("mrsScore", 0))
    d2_krw       = float(as_result.get("settlementCashD2Krw", 0))
    floor_min    = float(cash_floor.get("minPct", 7))
    target_pct   = max(5 + (mrs_score / 10) * 15, floor_min)
    target_krw   = total_asset * target_pct / 100
    shortfall    = max(0, target_krw - d2_krw)
    return {
        "cash_target_pct": round(target_pct, 2),
        "shortfall_min_krw_gt_0": shortfall > 0,
        "shortfall_krw": round(shortfall),
    }


def py_portfolio_drawdown_gate(inputs: dict) -> dict:
    """PORTFOLIO_DRAWDOWN_GATE_V1 — 핵심 낙폭 로직 (SpreadsheetApp 없이 수식만)"""
    peak    = float(inputs.get("peak_krw", 0))
    current = float(inputs.get("current_krw", 0))
    if peak <= 0:
        return {"gate": "INSUFFICIENT_DATA", "drawdown_pct": 0}
    dd = (peak - current) / peak * 100
    if dd > 20:
        gate = "DRAWDOWN_FORCE_RISK_OFF"
    elif dd > 10:
        gate = "DRAWDOWN_CAUTION"
    else:
        gate = "PASS"
    return {"gate": gate, "drawdown_pct": round(dd, 2)}


def py_stop_breach_alert(inputs: dict) -> dict:
    """STOP_BREACH_ALERT_V1"""
    holdings = inputs.get("holdings", [])
    df_map   = inputs.get("dfMap", {})
    gate = "PASS"
    for h in holdings:
        df      = df_map.get(h.get("ticker", ""), {})
        close   = float(h.get("close", df.get("close", 0)) or 0)
        stop    = float(h.get("stopPrice", 0) or 0)
        if close > 0 and stop > 0:
            if close <= stop:
                gate = "BREACH"
            elif close <= stop * 1.03 and gate == "PASS":
                gate = "APPROACHING"
    return {"gate": gate}


def py_portfolio_health_score(inputs: dict) -> dict:
    """PORTFOLIO_HEALTH_SCORE_V1"""
    CRITICAL = {"BLOCK_NEW_BUY","HARD_BLOCK","NO_BUY","DRAWDOWN_FORCE_RISK_OFF",
                "POSITION_COUNT_BLOCK","CLUSTER_BLOCK","BREACH","OVER_BETA",
                "BLOCK_SECTOR","STOP_CRITICAL"}
    CAUTION  = {"HALVE_NEW_BUY_QUANTITY","TRIM_REQUIRED","REDUCE_BUY","CAUTION_BUY",
                "DRAWDOWN_CAUTION","WARN_BETA","WARN_TOP2","OVERWEIGHT_TRIM",
                "EDGE_DEGRADED","EDGE_WEAK","EDGE_CRITICAL","APPROACHING",
                "TRIGGERED","HEAT_CONCENTRATED","DOWNGRADE"}
    gate_map = inputs.get("gateMap", {})
    crit, warn = 0, 0
    for v in gate_map.values():
        sv = str(v or "").strip()
        if sv in CRITICAL: crit += 1
        elif sv in CAUTION: warn += 1
    label = "CRITICAL" if (crit >= 1 or warn >= 3) else ("CAUTION" if warn >= 1 else "HEALTHY")
    score = max(0, 100 - crit*30 - warn*10)
    return {"label": label, "score": score}


def py_sector_rotation_momentum(inputs: dict) -> dict:
    """SECTOR_ROTATION_MOMENTUM_V1 — 첫 번째 섹터 결과 반환"""
    data = inputs.get("sectorFlowData", {})
    rows = []
    for sname, sf in data.items():
        rank  = sf.get("rank")
        prev1 = sf.get("prevRank")
        prev2 = sf.get("prevRankW2")
        d1 = (rank - prev1) if rank is not None and prev1 is not None else None
        d2 = (rank - prev2) if rank is not None and prev2 is not None else None
        state = "STABLE"
        if d1 is not None and d2 is not None:
            if d1 >= 2 and d2 >= 2:  state = "FADING"
            elif rank <= 3 and d1 >= 1: state = "TOPPING_OUT"
            elif d1 <= -2: state = "RISING"
        elif d1 is not None:
            if d1 >= 3: state = "FADING"
            elif d1 <= -2: state = "RISING"
        rows.append({"sector": sname, "momentum_state": state})
    rows.sort(key=lambda x: data[x["sector"]].get("rank", 999))
    return rows[0] if rows else {}


def py_breakout_quality_gate(inputs: dict) -> dict:
    """BREAKOUT_QUALITY_GATE_V2"""
    h       = inputs.get("h", {})
    df      = inputs.get("df", {})
    alpha   = inputs.get("alphaRow", {}) or {}
    dist    = inputs.get("distRow", {}) or {}
    close    = float(df.get("close", h.get("close", 0)) or 0)
    prev     = float(df.get("prevClose", close) or close)
    ma20     = float(df.get("ma20", 0) or 0)
    rsi14    = df.get("rsi14")
    volume   = df.get("volume")
    avg5d    = df.get("avgVolume5d")
    ret5d    = df.get("ret5d")
    ret1d    = (close - prev) / prev * 100 if prev > 0 and close > 0 else None
    ret3d    = float(ret5d) * 0.6 if ret5d is not None else None
    disp     = (close / ma20 - 1) * 100 if close > 0 and ma20 > 0 else None
    ts_exit  = float(alpha.get("timing_score_exit") or 0)
    dist_sc  = float(dist.get("distribution_risk_score") or 0)
    late_sc  = float(alpha.get("late_chase_risk_score") or 0)
    score = 50
    if ret3d is not None and ret3d >= 7:   score -= 30
    if disp is not None and disp > 10:     score -= 25
    if (ret1d is not None and ret1d >= 4 and volume is not None and avg5d is not None
            and avg5d > 0 and volume < avg5d * 0.9): score -= 40
    if rsi14 is not None and rsi14 > 75:   score -= 20
    if ts_exit >= 50:                       score -= 50
    if dist_sc >= 70:                       score -= 35
    if late_sc >= 70:                       score -= 30
    if (volume is not None and avg5d is not None and avg5d > 0
            and volume >= avg5d * 1.5 and ret1d is not None and ret1d >= 2
            and ret3d is not None and ret3d < 5): score += 25
    if disp is not None and 0 <= disp < 6: score += 15
    if rsi14 is not None and 45 <= rsi14 <= 65: score += 10
    score = max(0, min(100, round(score)))
    gate = "BLOCKED_LATE_CHASE" if score < 10 else ("WATCH_COOLING_OFF" if score < 40 else "PILOT_ALLOWED")
    return {"breakout_quality_gate": gate, "breakout_quality_score": score}


def py_anti_whipsaw_gate(inputs: dict) -> dict:
    """ANTI_WHIPSAW_GATE_V1"""
    h        = inputs.get("h", {})
    df       = inputs.get("df", {})
    kospi5d  = float(inputs.get("kospiRet5d", 0) or 0)
    inst5d   = df.get("inst5d")
    frg5d    = df.get("frg5d")
    vol_surge = df.get("valSurgePct")
    consec   = int(df.get("consecutiveSellSignals5d", 0) or 0)
    ret5d    = df.get("ret5d")
    ma20     = float(df.get("ma20", 0) or 0)
    close_h  = float(h.get("close", df.get("close", 0)) or 0)
    score = 0
    if consec >= 5: score += 20
    if inst5d is not None and inst5d > 0: score += 30
    if frg5d  is not None and frg5d  > 0: score += 20
    if ret5d is not None and kospi5d != 0:
        sr = (1 + float(ret5d)/100) / (1 + kospi5d/100) * 100 if (1 + kospi5d/100) != 0 else None
        if sr is not None and sr > 100: score += 15
    if vol_surge is not None and vol_surge >= 50:  score -= 25
    if vol_surge is not None and vol_surge >= 100: score -= 20
    score = max(-50, min(100, round(score)))
    clear = sum([
        1 if (inst5d is not None and inst5d > 0) else 0,
        1 if (frg5d  is not None and frg5d  > 0) else 0,
        1 if (ma20 > 0 and close_h > 0 and close_h > ma20) else 0,
    ])
    if score >= 30:
        if clear >= 3:   gate, hold = "WHIPSAW_AUTO_RELEASED", 0
        elif clear >= 2: gate, hold = "WHIPSAW_WEAKENING",     1
        else:            gate, hold = "WHIPSAW_CONFIRMED",      3
    elif score >= 10:    gate, hold = "INCONCLUSIVE",           0
    else:                gate, hold = "CONFIRMED_SELL",         0
    return {"anti_whipsaw_gate": gate, "anti_whipsaw_hold_days": hold}


def py_breakeven_ratchet(inputs: dict) -> dict:
    """BREAKEVEN_RATCHET_V1 — calcProfitPreservationRow_ 핵심 로직"""
    h    = inputs.get("h", {})
    df   = inputs.get("df", {})
    avg  = float(h.get("avgCost", 0) or 0)
    close = float(df.get("close", h.get("close", 0)) or 0)
    atr20 = float(df.get("atr20", 0) or 0)
    profit_pct = (close - avg) / avg * 100 if avg > 0 else 0
    if profit_pct >= 30:  state = "PROFIT_LOCK_30"
    elif profit_pct >= 8 or (atr20 > 0 and close >= avg + atr20):
        state = "BREAKEVEN_RATCHET"
    else:
        state = "NORMAL"
    return {"profit_preservation_state": state}


def py_market_weight_aware_cluster(inputs: dict) -> dict:
    """MARKET_WEIGHT_AWARE_CLUSTER_GATE_V1"""
    holdings = inputs.get("holdings", [])
    regime   = str(inputs.get("marketRegime", "")).upper()
    kospi_wt = float(inputs.get("kospiSemiWeightPct", 0) or 0)
    is_event_shock    = "EVENT_SHOCK" in regime
    is_risk_off       = is_event_shock or "RISK_OFF" in regime
    is_risk_on        = "RISK_ON" in regime and not is_risk_off
    is_secular_leader = "SECULAR_LEADER" in regime
    is_cla = "CONCENTRATED_LEADER_ADVANCE" in regime or regime == "CLA"
    mkt_provided = kospi_wt > 0
    SEMI = ["005930", "000660", "229200"] if is_cla else ["005930", "000660"]
    total = sum(float(h.get("weightPct", 0)) for h in holdings if h.get("ticker") in SEMI)
    if is_event_shock:       cap = max(20.0, kospi_wt*0.60) if mkt_provided else 20.0
    elif is_risk_off:        cap = max(25.0, kospi_wt*0.80) if mkt_provided else 25.0
    elif is_secular_leader or is_cla: cap = 65.0
    elif is_risk_on:         cap = max(45.0, kospi_wt*1.30) if mkt_provided else 45.0
    else:                    cap = max(35.0, kospi_wt*1.00) if mkt_provided else 35.0
    warn = (kospi_wt * 0.90 if mkt_provided else cap * 0.80)
    if total >= cap:
        gate = "CLUSTER_BLOCK" if is_risk_off else "CLUSTER_OVERWEIGHT_TRIM"
    elif total >= warn:
        gate = "CLUSTER_HOLD_ONLY" if (is_secular_leader or is_cla) else "CLUSTER_OVERWEIGHT_WARN"
    else:
        gate = "PASS"
    return {"gate_status": gate, "combined_pct": round(total, 2), "cap_pct": round(cap, 2)}


def py_leader_position_weight_cap(inputs: dict) -> dict:
    """LEADER_POSITION_WEIGHT_CAP_V1"""
    holdings = inputs.get("holdings", [])
    regime   = str(inputs.get("marketRegime", "")).upper()
    sm_wt    = float(inputs.get("kospiSamsungWeightPct", 0) or 0)
    hx_wt    = float(inputs.get("kospiHynixWeightPct", 0) or 0)
    is_event_shock    = "EVENT_SHOCK" in regime
    is_risk_off       = is_event_shock or "RISK_OFF" in regime
    is_risk_on        = "RISK_ON" in regime and not is_risk_off
    is_secular_leader = "SECULAR_LEADER" in regime
    default_cap = 15.0 if is_risk_off else (22.0 if is_risk_on else 20.0)
    gate = "PASS"
    for h in holdings:
        wpct = float(h.get("weightPct", 0))
        t    = h.get("ticker", "")
        if t == "005930":
            if is_event_shock:       cap = 15.0
            elif is_risk_off:        cap = 18.0
            elif is_secular_leader:  cap = max(50.0, sm_wt*2.20) if sm_wt else 50.0
            elif is_risk_on:         cap = max(40.0, sm_wt*1.70) if sm_wt else 40.0
            else:                    cap = max(28.0, sm_wt*1.20) if sm_wt else 28.0
        elif t == "000660":
            if is_event_shock:       cap = 10.0
            elif is_risk_off:        cap = 12.0
            elif is_secular_leader:  cap = max(28.0, hx_wt*2.50) if hx_wt else 28.0
            elif is_risk_on:         cap = max(22.0, hx_wt*1.80) if hx_wt else 22.0
            else:                    cap = max(15.0, hx_wt*1.20) if hx_wt else 15.0
        else:
            cap = default_cap
        if wpct > cap: gate = "OVERWEIGHT_TRIM"
    return {"gate_status": gate}


def py_capital_style_allocation(inputs: dict) -> dict:
    """CAPITAL_STYLE_ALLOCATION_V1 — 투자성향별 가중 conviction 산출."""
    W_STYLE = {
        "SCALP":    {"technical": 0.50, "smartmoney": 0.30, "fundamental": 0.05, "macro_event": 0.15},
        "SWING":    {"technical": 0.30, "smartmoney": 0.35, "fundamental": 0.15, "macro_event": 0.20},
        "MOMENTUM": {"technical": 0.15, "smartmoney": 0.25, "fundamental": 0.40, "macro_event": 0.20},
        "POSITION": {"technical": 0.10, "smartmoney": 0.20, "fundamental": 0.55, "macro_event": 0.15},
    }
    LIQUIDITY_MOD = {"DEEP": 1.00, "MODERATE": 0.90, "THIN": 0.75, "FROZEN": 0.00}

    def _clamp(v: float) -> float:
        return max(0.0, min(100.0, v))

    def _f(v, d=0.0):
        try: return float(v)
        except: return d

    style = str(inputs.get("style", "SCALP"))
    rsi14     = _f(inputs.get("rsi14", 50))
    disparity = _f(inputs.get("disparity", 0))
    ret5d     = _f(inputs.get("ret5d", 0))
    volume    = _f(inputs.get("volume", 0))
    avg_vol5d = _f(inputs.get("avg_vol5d", 0))
    smart     = _clamp(_f(inputs.get("smart_money_score", 50)))
    fund      = _clamp(_f(inputs.get("fundamental_score", 50)))
    liq_label = str(inputs.get("liquidity_label", "MODERATE"))
    macro_gate = str(inputs.get("macro_gate", "NEUTRAL"))
    macro_impact = _f(inputs.get("macro_impact_score", inputs.get("macro_event_score", 0)))

    # technical_score
    tech = 50.0
    if rsi14 < 35:   tech += 20.0
    elif rsi14 > 70: tech -= 25.0
    if disparity < 3.0:   tech += 15.0
    elif disparity > 10.0: tech -= 20.0
    if ret5d < -5.0: tech += 10.0
    if avg_vol5d > 0 and volume >= avg_vol5d * 1.2 and ret5d > 0:
        tech += 10.0
    tech = _clamp(tech)

    # macro_event_score
    if macro_gate == "AVOID_NEW_BUY":
        macro_score = 0.0
    else:
        macro_score = _clamp((macro_impact + 100.0) / 2.0)

    # liquidity
    if liq_label == "FROZEN":
        liq_mod = 0.0
    else:
        liq_mod = LIQUIDITY_MOD.get(liq_label, 0.90)

    w = W_STYLE.get(style, W_STYLE["SWING"])
    raw = (w["technical"] * tech + w["smartmoney"] * smart
         + w["fundamental"] * fund + w["macro_event"] * macro_score)
    conviction = round(_clamp(raw * liq_mod), 2)

    pct = 0.0
    if conviction >= 80: pct = 7.0
    elif conviction >= 65: pct = 5.0
    elif conviction >= 50: pct = 3.0
    elif conviction >= 35: pct = 1.5

    return {"conviction_score": conviction, "recommended_pct": pct}


def py_k2_staged_rebound(inputs: dict) -> dict:
    """K2_STAGED_REBOUND_SELL_V1 — 수량·트리거 수식"""
    base_qty    = int(inputs.get("base_qty", 0))
    prev_close  = float(inputs.get("prevClose", 0))
    atr20       = float(inputs.get("atr20", 0))
    immediate   = math.floor(base_qty / 2)
    rebound     = base_qty - immediate
    trigger_raw = prev_close + 0.5 * atr20
    tick        = _krx_tick_unit(trigger_raw)
    trigger     = int(math.floor(trigger_raw / tick) * tick)
    return {"immediate_qty": immediate, "rebound_wait_qty": rebound, "rebound_trigger_price": trigger}


# ─────────────────────────────────────────────────────────────────────────────
# formula_id → Python 미러 함수 매핑
# ─────────────────────────────────────────────────────────────────────────────
PYTHON_MIRRORS: dict[str, callable] = {
    "TICK_NORMALIZER_V1":              py_normalize_tick,
    "VELOCITY_V1":                     py_velocity,
    "PROFIT_LOCK_STAGE_V1":            py_classify_profit_lock,
    "ANTI_CHASING_VELOCITY_V1":        py_anti_chasing,
    "PULLBACK_ENTRY_TRIGGER_V1":       py_pullback_trigger,
    "SELL_PRICE_SANITY_V1":            py_sell_price_sanity,
    "ANTI_LATE_ENTRY_GATE_V2":         py_anti_late_entry_gate_v2,
    "DYNAMIC_HEAT_GATE_V1":            py_dynamic_heat_gate,
    "POSITION_SIZE_REGIME_SCALE_V1":   py_position_size_regime_scale,
    "REGIME_CASH_UPLIFT_V1":           py_regime_cash_uplift,
    "DRAWDOWN_GUARD_V1":               py_drawdown_guard,
    "POSITION_COUNT_LIMIT_V1":         py_position_count_limit,
    "CASH_FLOOR_V1":                   py_cash_floor,
    "SEMICONDUCTOR_CLUSTER_GATE_V1":   py_semiconductor_cluster_gate,
    "PROFIT_RATCHET_TIERED_V2":        py_profit_ratchet_v2,
    # milestone_2
    "WIN_LOSS_STREAK_GUARD_V1":        py_win_loss_streak_guard,
    "SINGLE_POSITION_WEIGHT_CAP_V1":   py_single_position_weight_cap,
    "REGIME_TRIM_GUIDANCE_V1":         py_regime_trim_guidance,
    "HEAT_CONCENTRATION_ALERT_V1":     py_heat_concentration_alert,
    "SECTOR_CONCENTRATION_LIMIT_V1":   py_sector_concentration_gate,
    "CASH_SHORTFALL_V1":               py_cash_shortfall,
    "K2_STAGED_REBOUND_SELL_V1":       py_k2_staged_rebound,
    "PORTFOLIO_DRAWDOWN_GATE_V1":      py_portfolio_drawdown_gate,
    # milestone_2 (2차) — GAS 구현 기반
    "STOP_BREACH_ALERT_V1":            py_stop_breach_alert,
    "PORTFOLIO_HEALTH_SCORE_V1":       py_portfolio_health_score,
    "SECTOR_ROTATION_MOMENTUM_V1":     py_sector_rotation_momentum,
    "BREAKOUT_QUALITY_GATE_V2":        py_breakout_quality_gate,
    "ANTI_WHIPSAW_GATE_V1":            py_anti_whipsaw_gate,
    "BREAKEVEN_RATCHET_V1":            py_breakeven_ratchet,
    "MARKET_WEIGHT_AWARE_CLUSTER_GATE_V1": py_market_weight_aware_cluster,
    "LEADER_POSITION_WEIGHT_CAP_V1":   py_leader_position_weight_cap,
    "CAPITAL_STYLE_ALLOCATION_V1":     py_capital_style_allocation,
    # 추가 공식 미러 (2026-05-31 확장)
    "MEAN_REVERSION_GATE_V1":         lambda i: _mirror_compute("compute_mean_reversion_gate", i),
    "T1_FORCED_SELL_RISK_V1":         lambda i: _mirror_compute("compute_t1_forced_sell_risk", i),
    "SELL_CONFLICT_AWARE_RECOMMENDATION_V1": lambda i: _mirror_compute("compute_sell_conflict_recommendation", i),
    "DIVERGENCE_SCORE_V1":            lambda i: _mirror_compute("compute_divergence_score", i),
    "SEMICONDUCTOR_CLUSTER_SYNC_V1":  lambda i: _mirror_compute("compute_semiconductor_cluster_sync", i),
    "GOAL_RETIREMENT_V1":             lambda i: _mirror_compute("compute_goal_retirement", i),
    "POSITION_SIZE_V1":               lambda i: _mirror_compute("compute_position_size", i),
    # ENGINE_AUDIT_V1 + spec/28~30 (compute_formula_outputs.py 미러)
    "IMPUTED_DATA_EXPOSURE_GATE_V1":  lambda i: _mirror_compute("compute_imputed_data_exposure", i),
    "TRAILING_STOP_PRICE_V1":         lambda i: _mirror_compute("compute_trailing_stop_price", i),
    "EXPECTED_EDGE_V1":               lambda i: _mirror_compute("compute_expected_edge", i),
    "TP_VALIDITY_CHECK_V1":           lambda i: _mirror_compute("compute_tp_validity", i),
    "RS_RATIO_V1":                    lambda i: _mirror_compute("compute_rs_ratio", i),
    "RATCHET_TRAILING_AUTO_V1":       lambda i: _mirror_compute("compute_ratchet_trailing_auto", i),
    "STOP_PRICE_CORE_V1":             lambda i: _mirror_compute("compute_stop_price_core", i),
    "TARGET_CASH_PCT_V1":             lambda i: _mirror_compute("compute_target_cash_pct", i),
    "FLOW_CREDIT_V1":                 lambda i: _mirror_compute("compute_flow_credit", i),
    "MARKET_RISK_SCORE_V1":           lambda i: _mirror_compute("compute_market_risk_score", i),
    "PORTFOLIO_BETA_V1":              lambda i: _mirror_compute("compute_portfolio_beta", i),
    "RISK_BUDGET_CASCADE_V1":         lambda i: _mirror_compute("compute_risk_budget_cascade", i),
}


# ─────────────────────────────────────────────────────────────────────────────
# 비교 헬퍼
# ─────────────────────────────────────────────────────────────────────────────
def _compare_values(actual, expected, tolerance_map: dict) -> tuple[bool, list[str]]:
    """expected dict의 각 키가 actual dict에서 허용오차 내 일치하는지 확인."""
    if not isinstance(expected, dict):
        ok = actual == expected
        return ok, [] if ok else [f"expected={expected!r}, got={actual!r}"]
    if not isinstance(actual, dict):
        return False, [f"actual is not dict: {type(actual).__name__}"]
    errors = []
    for key, exp_val in expected.items():
        act_val = actual.get(key, "__MISSING__")
        if act_val == "__MISSING__":
            errors.append(f"'{key}' missing in actual output")
            continue
        tol = tolerance_map.get(key, 0)
        if isinstance(exp_val, (int, float)) and isinstance(act_val, (int, float)):
            if abs(float(act_val) - float(exp_val)) > tol:
                errors.append(f"'{key}': expected={exp_val}, got={act_val}, tol={tol}")
        elif exp_val is None and act_val is None:
            pass
        elif act_val != exp_val:
            errors.append(f"'{key}': expected={exp_val!r}, got={act_val!r}")
    return len(errors) == 0, errors


# ─────────────────────────────────────────────────────────────────────────────
# 메인 실행
# ─────────────────────────────────────────────────────────────────────────────
def main() -> int:
    strict = "--strict" in sys.argv

    if not GOLDEN_FILE.exists():
        print(f"BCH_PY_MIRROR_FAIL: golden file not found: {GOLDEN_FILE}")
        return 1

    payload = yaml.safe_load(GOLDEN_FILE.read_text(encoding="utf-8"))
    golden_list = payload.get("golden_cases_v2", [])

    total_formulas = 0
    passed_formulas = 0
    total_cases = 0
    passed_cases = 0
    python_divergences = []  # Python ≠ spec_correct 목록
    per_formula = []
    missing_mirrors = []

    for entry in golden_list:
        fid = entry.get("formula_id", "")
        cases = entry.get("cases", [])
        mirror_fn = PYTHON_MIRRORS.get(fid)

        if fid.endswith("_note") or not cases:
            continue

        total_formulas += 1
        formula_passed = False
        case_results = []

        if mirror_fn is None:
            # GAS_REFERENCE 케이스만 있으면 Python mirror 불필요 — missing_mirrors 제외
            all_gas_ref = all(c.get("provenance") == "GAS_REFERENCE" for c in cases)
            if all_gas_ref:
                # GAS_REFERENCE: 문서화 목적 케이스. Python mirror 없어도 허용.
                passed_formulas += 1  # behavioral coverage에 기여
                per_formula.append({
                    "formula_id": fid,
                    "status": "GAS_REFERENCE",
                    "case_count": len(cases),
                    "pass_count": len(cases),
                })
                continue
            missing_mirrors.append(fid)
            per_formula.append({
                "formula_id": fid,
                "status": "NO_PYTHON_MIRROR",
                "case_count": len(cases),
                "pass_count": 0,
            })
            continue

        for case in cases:
            # 메타 케이스(설명용) 건너뜀
            if "description" in case and "inputs" not in case:
                continue
            if "gas_diverge_note" in case.get("id", ""):
                continue

            case_id = case.get("id", "?")
            inputs_ = case.get("inputs", {})
            expected_ = case.get("expected", {})
            tolerance_ = case.get("tolerance", {})
            spec_correct_ = case.get("spec_correct")
            python_expected_ = case.get("python_expected")

            total_cases += 1

            try:
                actual_ = mirror_fn(inputs_)
            except Exception as exc:
                case_results.append({
                    "case_id": case_id,
                    "status": "ERROR",
                    "error": str(exc),
                })
                continue

            ok, errors_ = _compare_values(actual_, expected_, tolerance_)

            # spec_correct vs Python: divergence 기록
            if spec_correct_ is not None and python_expected_ is not None:
                # 이 경우 expected_ = spec_correct (golden), python_expected_ = 별도 기대
                act_key = list(expected_.keys())[0] if isinstance(expected_, dict) else None
                if act_key:
                    act_val = actual_.get(act_key) if isinstance(actual_, dict) else actual_
                    if act_val != expected_.get(act_key):
                        # Python 출력이 spec_correct와 다름 → divergence
                        python_divergences.append({
                            "formula_id": fid,
                            "case_id": case_id,
                            "spec_correct": spec_correct_,
                            "python_output": act_val,
                            "note": case.get("note", ""),
                        })

            status_ = "PASS" if ok else "FAIL"
            case_results.append({
                "case_id": case_id,
                "status": status_,
                "errors": errors_,
            })
            if ok:
                passed_cases += 1
                formula_passed = True

        if formula_passed:
            passed_formulas += 1

        per_formula.append({
            "formula_id": fid,
            "status": "PASS" if formula_passed else "FAIL",
            "case_count": len(case_results),
            "pass_count": sum(1 for c in case_results if c["status"] == "PASS"),
            "cases": case_results,
        })

    behavioral_coverage_pct = (
        round(passed_formulas / total_formulas * 100, 2)
        if total_formulas > 0 else 0.0
    )
    overall_ok = (
        behavioral_coverage_pct >= 100.0
        and len(python_divergences) == 0
    )

    result = {
        "status": "BEHAVIORAL_COVERAGE_PY_OK" if overall_ok else "BEHAVIORAL_COVERAGE_PY_FAIL",
        "behavioral_coverage_pct": behavioral_coverage_pct,
        "total_formulas": total_formulas,
        "passed_formulas": passed_formulas,
        "total_cases": total_cases,
        "passed_cases": passed_cases,
        "failed_cases": total_cases - passed_cases,
        "python_divergences": python_divergences,
        "missing_python_mirrors": missing_mirrors,
        "per_formula": per_formula,
    }

    OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
    OUTPUT_FILE.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")

    # 콘솔 출력
    sep = "=" * 70
    print(sep)
    print("  행위기반 커버리지 — Python 미러 검증기 (BCH-V1 B03)")
    print(sep)
    print(f"  공식 수: {total_formulas}  통과: {passed_formulas}  "
          f"behavioral_coverage_pct: {behavioral_coverage_pct}%")
    print(f"  케이스 수: {total_cases}  통과: {passed_cases}  실패: {total_cases - passed_cases}")

    if missing_mirrors:
        print(f"\n[!] Python 미러 없는 공식 ({len(missing_mirrors)}개):")
        for m in missing_mirrors:
            print(f"    - {m}  → PYTHON_MIRROR 추가 필요")

    if python_divergences:
        print(f"\n[!] Python ≠ spec_correct 분기 ({len(python_divergences)}건) — B06 정정 필요:")
        for d in python_divergences:
            print(f"    [{d['formula_id']}:{d['case_id']}] "
                  f"spec_correct={d['spec_correct']!r} Python출력={d['python_output']!r}")
            if d.get("note"):
                print(f"    → {d['note'][:120]}")

    for pf in per_formula:
        if pf.get("status") == "FAIL":
            print(f"\n[FAIL] {pf['formula_id']} ({pf['pass_count']}/{pf['case_count']} 통과):")
            for c in pf.get("cases", []):
                if c.get("status") == "FAIL":
                    print(f"    case={c['case_id']}: {c.get('errors', [])}")
                elif c.get("status") == "ERROR":
                    print(f"    case={c['case_id']}: ERROR {c.get('error')}")

    print()
    print(f"  → 결과 저장: {OUTPUT_FILE}")
    if overall_ok:
        print("  BEHAVIORAL_COVERAGE_PY_OK\n")
    else:
        print("  BEHAVIORAL_COVERAGE_PY_FAIL\n")

    if strict and not overall_ok:
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
