# Implementation Plan: Math Core v3 Engine

## Overview

Incremental upgrade of the Stonks Oracle signal processing engine from dual-mode heuristic/probabilistic to the v3 Calibrated Evidence Engine. Implementation follows the v3 math doc section 20 order: EvidenceUnit → reliability → LLR → clustering → posterior → contradiction → confidence → macro/competitive layers → EV gate → Kelly sizing → stop-defined heat → retire heuristic scoring. All v3 code operates behind the `v3_engine_enabled` feature flag with heuristic fallback.

## Tasks

- [x] 1. EvidenceUnit and LLR conversion behind feature flag
  - [x] 1.1 Implement EvidenceUnit dataclass and normalization functions in `services/aggregation/scoring.py`
    - Add frozen dataclass `EvidenceUnit` with all 16 fields (symbol, layer, event_type, source_id, source_group, timestamp, horizon, direction, sentiment_strength, impact, extraction_conf, source_cred, novelty, event_base_rate, cluster_id)
    - Implement `normalize_company_signal()`, `normalize_macro_signal()`, `normalize_competitive_signal()`
    - Validate required fields (symbol, timestamp, source_id) — reject with warning on missing
    - Substitute 0.5 for missing optional numeric fields
    - Map direction from sentiment/impact_direction strings (+1/-1/0)
    - Assign event_base_rate from EVENT_TYPE_BASE_RATES lookup (default 0.10)
    - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8_

  - [x] 1.2 Implement calibrated reliability pipeline (`compute_v3_reliability`) in `services/aggregation/scoring.py`
    - Add `SourceStats` dataclass (source_id, hits, misses, alpha_0=3, beta_0=3)
    - Add `ReliabilityComponents` dataclass (q_ext, q_source, q_recency, q_uniqueness, q_i)
    - Implement q_ext = sigmoid(8.0 × (extraction_conf - 0.55))
    - Implement q_source via Bayesian shrinkage: E[theta_s] = (alpha_0 + hits) / (alpha_0 + beta_0 + hits + misses), then clamp((E - 0.50) / 0.35, 0, 1)
    - Implement q_recency = 2^(-age_hours / tau_adaptive) with adaptive half-life formula
    - Implement q_uniqueness = clamp(0.5 + 0.5 × novelty, 0.5, 1.0) × (1 / sqrt(1 + dup_count))
    - Implement q_i = clamp(q_ext × q_source × source_cred × q_recency × q_uniqueness, 0, 1)
    - Apply floor of 0.01 on q_recency only for explainability display
    - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 2.9_

  - [x] 1.3 Implement LLR conversion (`compute_llr`) in `services/aggregation/scoring.py`
    - Compute p_correct = clamp(0.50 + 0.35 × q_i × impact × sentiment_strength, 0.501, 0.85)
    - Compute LLR_i = direction × ln(p_correct / (1 - p_correct))
    - Return 0.0 for neutral signals (direction == 0)
    - Ensure LLR sign always matches direction for directional signals
    - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6_

  - [x] 1.4 Add feature flag routing in `services/aggregation/worker.py`
    - Read `v3_engine_enabled` from risk_configs table at start of each aggregation cycle
    - Route to v3 pipeline when True, heuristic when False
    - Default to heuristic mode if DB read fails (log warning)
    - Wrap v3 pipeline in try/except — fall back to heuristic on unhandled error (log ERROR with traceback)
    - Store `pipeline_mode` field ("v3" or "heuristic") in output metadata
    - _Requirements: 19.1, 19.2, 19.3, 19.4, 19.5, 19.6_

  - [x] 1.5 Write property tests for EvidenceUnit, reliability, and LLR (`tests/test_pbt_v3_reliability.py`)
    - **Property 1: Reliability q_i is bounded in [0, 1]**
    - **Property 2: p_correct is bounded in [0.501, 0.85]**
    - **Property 3: LLR sign matches direction and magnitude is bounded**
    - **Property 4: Neutral signals produce zero LLR**
    - **Property 21: EvidenceUnit normalization preserves field ranges**
    - **Validates: Requirements 1.1–1.8, 2.1–2.9, 3.1–3.6, 21.1–21.3**
    - Use Hypothesis with `@settings(max_examples=100)`

  - [x] 1.6 Write unit tests for EvidenceUnit normalization and LLR conversion (`tests/test_v3_evidence_unit.py`)
    - Test company signal → EvidenceUnit with correct field mapping
    - Test macro signal → EvidenceUnit with correct horizon mapping (short_term→7d, medium_term→30d, long_term→90d)
    - Test competitive signal → EvidenceUnit with correct direction mapping
    - Test missing required fields → rejection with warning
    - Test missing optional fields → default 0.5 substitution
    - Test known inputs through full q_i pipeline → expected outputs
    - Test LLR boundary cases: p_correct at clamp boundaries
    - _Requirements: 1.1–1.8, 2.1–2.9, 3.1–3.6_

- [x] 2. Evidence clustering and n_eff

  - [x] 2.1 Implement correlation-aware clustering in `services/aggregation/worker.py`
    - Add `EvidenceCluster` dataclass (cluster_id, units, llrs, n_eff, cluster_llr)
    - Implement `cluster_evidence()` — group EvidenceUnits by (symbol, horizon, event_type, source_group, time_bucket)
    - Compute cluster_id as hash of grouping key
    - Define time_bucket resolution per horizon (intraday=1h, 1d=4h, 7d=24h, 30d=72h, 90d=168h)
    - _Requirements: 4.1_

  - [x] 2.2 Implement n_eff computation in `services/aggregation/worker.py`
    - Implement `compute_n_eff(llrs, correlations)` using formula: (sum w_i)² / (sum w_i² + 2 × sum_{i<j} rho_ij × w_i × w_j)
    - Use default pairwise correlations: same wire=0.80, same event diff publisher=0.50, same theme diff event=0.25, independent=0.00
    - Guard against division by zero (denominator → return n_eff=1.0)
    - _Requirements: 4.2, 4.3_

  - [x] 2.3 Implement cluster LLR computation in `services/aggregation/worker.py`
    - Implement `compute_cluster_llr(llrs, n_eff)` = clamp(weighted_mean(LLR_i, |LLR_i|) × sqrt(n_eff), -2.5, 2.5)
    - Handle single-signal clusters (LLR_c = LLR_i clamped)
    - Handle all-zero LLR clusters (cluster_llr = 0.0)
    - _Requirements: 4.4, 4.5_

  - [x] 2.4 Write property tests for clustering (`tests/test_pbt_v3_clustering.py`)
    - **Property 5: Effective evidence count n_eff is bounded by cluster size**
    - **Property 6: Cluster LLR is clamped to [-2.5, 2.5]**
    - **Validates: Requirements 4.2, 4.3, 4.4, 4.5, 21.4**
    - Use Hypothesis with `@settings(max_examples=100)`

  - [x] 2.5 Write unit tests for clustering (`tests/test_v3_clustering.py`)
    - Test 3 identical articles from same source → n_eff < 3
    - Test 3 independent articles → n_eff ≈ 3
    - Test single signal cluster → n_eff = 1.0
    - Test cluster LLR clamp at ±2.5
    - Test grouping by correct key dimensions
    - _Requirements: 4.1–4.5_

- [x] 3. Checkpoint - Verify foundation layer
  - Ensure all tests pass for EvidenceUnit, reliability, LLR, and clustering.
  - Ensure all tests pass, ask the user if questions arise.

- [x] 4. Replace trend assembly with posterior P_up

  - [x] 4.1 Implement regime detection v3 in `services/aggregation/regime.py`
    - Add `V3RegimeClassification` dataclass (regime, trend_z, vol_ratio, evidence_multiplier, confidence_multiplier, phi_decay, atr_multiplier)
    - Implement `classify_regime_v3(closing_prices, daily_returns, atr_20)` using ATR-normalized trend_z = (EMA_20 - EMA_100) / ATR_20
    - Compute vol_ratio = sigma_20 / sigma_100
    - Classification rules in priority: panic (vol_ratio > 1.5 OR |trend_z| > 2.5), trend_following (|trend_z| >= 0.75 AND vol_ratio < 1.3), mean_reversion (|trend_z| < 0.50 AND vol_ratio < 1.0), uncertainty (default)
    - Assign regime parameters: gamma, confidence_mult, phi, ATR_mult, min_edge
    - Default to uncertainty when data insufficient
    - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8_

  - [x] 4.2 Implement posterior assembly via log-odds in `services/aggregation/bayesian.py`
    - Add `V3Posterior` dataclass (p_up, p_down, log_odds, strength, direction, n_eff_total, regime)
    - Implement `compute_v3_posterior(clusters, regime, p_prior=0.50)`
    - Compute logit(P_up) = logit(P_prior) + sum(gamma_regime × LLR_c)
    - Compute P_up = sigmoid(log_odds), clamp to [1e-10, 1 - 1e-10]
    - Compute strength = abs(2 × P_up - 1)
    - Classify direction using regime-specific thresholds (panic: 0.68/0.32, trend_following: 0.60/0.40, mean_reversion: 0.63/0.37, uncertainty: 0.65/0.35)
    - Load calibrated prior from risk_configs if available (clamp to [0.40, 0.60]), fall back to 0.50 on error
    - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7_

  - [x] 4.3 Write property tests for posterior and regime (`tests/test_pbt_v3_posterior.py`)
    - **Property 7: Posterior P_up is in open interval (0, 1)**
    - **Property 20: Regime classification is exhaustive and deterministic**
    - **Validates: Requirements 5.3, 6.2–6.5, 21.5**
    - Use Hypothesis with `@settings(max_examples=100)`

  - [x] 4.4 Write unit tests for posterior assembly (`tests/test_v3_posterior.py`)
    - Test empty clusters → P_up = 0.50 (neutral prior)
    - Test all bullish clusters → P_up > 0.50
    - Test regime direction thresholds at boundary values
    - Test prior clamp [0.40, 0.60]
    - Test regime classification with known inputs
    - _Requirements: 5.1–5.7, 6.1–6.8_

- [x] 5. Replace contradiction with LLR entropy

  - [x] 5.1 Implement LLR entropy contradiction in `services/aggregation/contradiction.py`
    - Add `compute_v3_contradiction(clusters: list[EvidenceCluster]) -> float`
    - Compute E_pos = sum(max(LLR_c, 0)), E_neg = sum(max(-LLR_c, 0)), E_total = E_pos + E_neg
    - When E_total == 0 → return 0.0
    - When only one direction exists (E_pos == 0 or E_neg == 0) → return 0.0
    - Compute f_pos = E_pos / E_total, f_neg = E_neg / E_total
    - Compute H_conflict = -f_pos × log2(f_pos) - f_neg × log2(f_neg), treating 0×log2(0) = 0
    - Compute volume_factor = 1 - exp(-E_total / 3.0)
    - Return H_conflict × volume_factor, bounded in [0.0, 1.0]
    - _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7_

  - [x] 5.2 Write property tests for contradiction (`tests/test_pbt_v3_posterior.py`)
    - **Property 8: Contradiction is zero when evidence is unidirectional**
    - **Property 9: Contradiction score is bounded in [0, 1]**
    - **Validates: Requirements 7.6, 7.7, 21.7**
    - Use Hypothesis with `@settings(max_examples=100)`

  - [x] 5.3 Write unit tests for LLR entropy contradiction (`tests/test_v3_contradiction.py`)
    - Test all bullish clusters → contradiction = 0.0
    - Test equal split of evidence → high contradiction near 1.0
    - Test E_total = 0 → contradiction = 0.0
    - Test volume_factor growth: small evidence mass → suppressed score
    - _Requirements: 7.1–7.7_

- [x] 6. Replace confidence formula

  - [x] 6.1 Implement multiplicative confidence v3 in `services/aggregation/worker.py`
    - Add `compute_v3_confidence(n_eff_total, q_values, llrs, strength, regime_confidence_mult, contradiction, data_quality) -> float`
    - Compute C_evidence = 1 - exp(-n_eff_total / 5.0)
    - Compute C_quality = weighted_mean(q_i, weight=|LLR_i|)
    - Compute confidence = clamp(C_evidence × sqrt(C_quality) × sqrt(max(strength, 0.05)) × regime_confidence_mult × (1 - contradiction) × data_quality, 0, 1)
    - _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5_

  - [x] 6.2 Implement data quality v3 in `services/aggregation/worker.py`
    - Add `compute_v3_data_quality(units, extraction_failure_rate, age_newest_hours, n_source_types) -> float`
    - Q_parse = 1 - extraction_failure_rate
    - Q_conf = weighted_mean(extraction_conf_i, weight=impact_i)
    - Q_fresh = exp(-age_newest_hours / 168)
    - Q_coverage = 1 - exp(-N_valid / 5)
    - Q_diversity = min(1, log2(1 + N_source_types) / log2(4))
    - data_quality_score = clamp(Q_parse × sqrt(Q_conf) × Q_fresh × Q_coverage × Q_diversity, 0, 1)
    - Force informational mode when data_quality < 0.50 OR N_valid < 2 OR Q_parse < 0.50
    - Force informational when only macro/competitive evidence unless macro_only_enabled
    - _Requirements: 17.1, 17.2, 17.3, 17.4, 17.5, 17.6, 17.7, 17.8_

  - [x] 6.3 Write property tests for confidence and data quality (`tests/test_pbt_v3_posterior.py`)
    - **Property 10: Multiplicative confidence is bounded in [0, 1] and suppressed by weak dimensions**
    - **Property 17: Data quality score is bounded in [0, 1]**
    - **Validates: Requirements 8.3, 8.5, 17.6, 21.6**
    - Use Hypothesis with `@settings(max_examples=100)`

  - [x] 6.4 Write unit tests for confidence and data quality (`tests/test_v3_confidence.py`)
    - Test zero data quality → near-zero confidence
    - Test full contradiction (1.0) → zero confidence
    - Test low n_eff → suppressed C_evidence
    - Test data quality boundary cases (Q_parse < 0.50 → informational)
    - _Requirements: 8.1–8.5, 17.1–17.8_

- [x] 7. Checkpoint - Verify core pipeline
  - Ensure all tests pass for posterior, contradiction, confidence, and data quality.
  - Ensure all tests pass, ask the user if questions arise.

- [x] 8. Convert macro and competitive layers to emit LLR

  - [x] 8.1 Implement noisy-OR macro exposure and LLR emission in `services/aggregation/interpolation.py`
    - Add `compute_normalized_macro_exposure(overlaps: dict[str, float]) -> float`
    - E_raw = 1 - product(1 - w_k × O_k) with weights: w_geo=0.35, w_supply=0.25, w_commodity=0.25, w_sector=0.15
    - E_max = 1 - product(1 - w_k)
    - E_macro = E_raw / E_max (normalized to [0, 1])
    - Apply resilience dampener per tier: global_leader=0.70, multinational=0.85, regional=1.00, domestic=1.20
    - Add `compute_macro_llr(macro_impact, event_confidence, q_recency, macro_direction) -> float`
    - p_macro = clamp(0.50 + 0.30 × macro_impact × event_confidence × q_recency, 0.501, 0.80)
    - LLR_macro = macro_direction × ln(p_macro / (1 - p_macro))
    - Feed macro LLR into shared posterior without separate post-hoc modifier
    - _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5_

  - [x] 8.2 Implement correlation-shrunk competitive propagation in `services/aggregation/signal_propagation.py`
    - Add `compute_shrunk_correlation(rho_rolling, n_observations, same_sector) -> float`
    - rho_prior = 0.30 (same_sector) or 0.10 (cross_sector)
    - rho_shrunk = (n/(n+30)) × rho_rolling + (30/(n+30)) × rho_prior
    - rho_effective = max(rho_shrunk, 0)
    - Add `compute_competitive_llr(llr_source, rho_effective, d_network, pattern_confidence) -> float`
    - attenuation = rho_effective × exp(-0.85 × d_network), max_distance = 3
    - LLR_competitive = clamp(llr_source × attenuation × pattern_confidence, -1.25, 1.25)
    - _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5_

  - [x] 8.3 Write property tests for macro and competitive layers (`tests/test_pbt_v3_layers.py`)
    - **Property 13: Noisy-OR normalized exposure is bounded in [0, 1]**
    - **Property 14: Competitive LLR is clamped to [-1.25, 1.25]**
    - **Property 15: Graph attenuation is zero beyond max distance**
    - **Validates: Requirements 9.1, 9.2, 10.3, 10.4, 10.5**
    - Use Hypothesis with `@settings(max_examples=100)`

  - [x] 8.4 Write unit tests for macro and competitive layers (`tests/test_v3_layers.py`)
    - Test noisy-OR: all O_k = 1.0 → E_macro = 1.0; all O_k = 0 → E_macro = 0
    - Test resilience dampener per tier
    - Test macro LLR at boundary values
    - Test shrunk correlation convergence (n → ∞ approaches rho_rolling)
    - Test competitive LLR clamp at ±1.25
    - Test distance > 3 → zero attenuation
    - _Requirements: 9.1–9.5, 10.1–10.5_

- [x] 9. Replace EV gate with expected-return distribution

  - [x] 9.1 Implement posterior state projection in `services/aggregation/projection.py`
    - Add `V3ProjectionState` dataclass (a_t, p_up_projected, projected_strength, diverges, phi_regime)
    - Implement `compute_v3_projection(a_prev, cluster_llrs, regime, p_prior, projection_horizon, known_catalyst_llr=0.0)`
    - Evidence state: A_t = phi_regime × A_{t-1} + sum(LLR_c), init A_0 = 0.0
    - Regime decay: panic=0.35, trend_following=0.80, mean_reversion=0.55, uncertainty=0.50
    - Projected alpha: A_projected = phi^h × A_t + known_catalyst_LLR
    - P_up_projected = sigmoid(logit(P_prior) + A_projected)
    - Projected strength = abs(2 × P_up_projected - 1)
    - Flag divergence when sign(P_up_projected - 0.5) ≠ sign(P_up_t - 0.5)
    - _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6, 11.7_

  - [x] 9.2 Implement return distribution and EV gate in `services/recommendation/eligibility.py`
    - Add `ReturnDistribution` dataclass (sigma_h, mu_h, ev_long, min_edge, eligible)
    - Add `compute_return_distribution(a_projected, confidence, realized_vol_20d, horizon_days, costs, regime)`
    - sigma_h = realized_vol_20d × sqrt(horizon_days / 252); default vol = 0.25 if unavailable
    - mu_h = tanh(A_projected / 3.0) × confidence × sigma_h
    - CVaR_5 = sigma_h × 1.645 × 1.4
    - EV_long = mu_h - costs - 0.10 × CVaR_5
    - Regime min_edge: panic=0.0100, trend_following=0.0035, mean_reversion=0.0050, uncertainty=0.0075
    - Eligibility: EV_long > min_edge AND EV_long > max(0.0025, 0.25 × costs)
    - Also require: confidence >= regime_confidence_min, contradiction <= regime_contradiction_max, n_eff_total >= 2.0, data_quality >= 0.50
    - _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7_

  - [x] 9.3 Implement regime-aware eligibility and mode escalation in `services/recommendation/eligibility.py`
    - Add regime-specific eligibility thresholds: panic (conf≥0.70, contra≤0.25, str≥0.36), trend_following (conf≥0.55, contra≤0.40, str≥0.20), mean_reversion (conf≥0.60, contra≤0.35, str≥0.26), uncertainty (conf≥0.65, contra≤0.30, str≥0.30)
    - Action mapping: BUY when P_up >= bullish_threshold and EV > min_edge; SELL when existing position and EV_exit > EV_hold; HOLD when existing; WATCH otherwise
    - live_eligible: BUY/SELL + conf >= 0.75 + contra <= 0.20 + n_eff >= 5 + EV > 2×min_edge + risk_passed
    - paper_eligible: BUY/SELL + conf >= 0.60 + EV > min_edge + risk_passed
    - Otherwise: informational
    - _Requirements: 13.1, 13.2, 13.3, 13.4, 13.5_

  - [x] 9.4 Write property tests for projection (`tests/test_pbt_v3_projection.py`)
    - **Property 16: Projection evidence state decays toward zero**
    - **Validates: Requirements 11.1, 11.3**
    - Use Hypothesis with `@settings(max_examples=100)`

  - [x] 9.5 Write unit tests for EV gate and eligibility (`tests/test_v3_eligibility.py`)
    - Test EV_long positive → eligible
    - Test EV_long negative → ineligible
    - Test regime-specific min_edge thresholds
    - Test mode escalation: live vs paper vs informational
    - Test projection decay convergence
    - Test divergence flag behavior
    - _Requirements: 11.1–11.7, 12.1–12.7, 13.1–13.5_

- [x] 10. Checkpoint - Verify decision layer
  - Ensure all tests pass for projection, EV gate, eligibility, and layer integrations.
  - Ensure all tests pass, ask the user if questions arise.

- [x] 11. Replace sizing with fractional Kelly under existing risk caps

  - [x] 11.1 Implement fractional Kelly position sizing in `services/trading/position_sizer.py`
    - Add `compute_kelly_sizing(p_win, b, confidence, data_quality, contradiction, max_position_pct, available_caps) -> float`
    - Compute reward ratio b = clamp(1.2 + 2.0 × confidence + 1.0 × strength - contradiction, 1.2, 3.0)
    - Compute f_kelly = (p_win × b - (1 - p_win)) / b
    - portfolio_pct = clamp(max(0, f_kelly) × 0.25 × confidence × data_quality × (1 - contradiction), 0, max_position_pct)
    - Apply min of: max_position_pct, sector_capacity, correlation_capacity (0 if avg corr > 0.80), heat_capacity
    - If portfolio_pct < 0.005 → downgrade to WATCH (reason: position_below_minimum)
    - If f_kelly <= 0 → portfolio_pct = 0, downgrade to WATCH (reason: negative_edge)
    - _Requirements: 14.1, 14.2, 14.3, 14.4, 14.5, 14.6, 14.7_

  - [x] 11.2 Implement regime-aware stop loss and take profit in `services/trading/stop_loss_manager.py`
    - Add v3 stop computation: stop_distance_pct = max(ATR_pct × regime_ATR_mult, sigma_h × 1.25, 0.005)
    - stop_loss = entry_price × (1 - stop_distance_pct)
    - take_profit = entry_price × (1 + b × stop_distance_pct) where b = reward ratio
    - Activate trailing stop when unrealized_gain >= 0.50 × TP distance
    - trailing_stop = max(existing_stop, current_price × (1 - trailing_distance_pct))
    - trailing_distance_pct = max(ATR_pct × trailing_ATR_mult, sigma_h × 0.75)
    - Trailing stop must be monotonically non-decreasing
    - _Requirements: 16.1, 16.2, 16.3, 16.4, 16.5_

  - [x] 11.3 Write property tests for Kelly sizing and stops (`tests/test_pbt_v3_decision.py`)
    - **Property 11: Fractional Kelly sizing is bounded and respects negative edge**
    - **Property 18: Stop loss is below entry price and take profit is above**
    - **Property 19: Trailing stop never decreases**
    - **Validates: Requirements 14.4, 14.7, 16.2, 16.3, 16.5, 21.8, 21.9**
    - Use Hypothesis with `@settings(max_examples=100)`

  - [x] 11.4 Write unit tests for Kelly sizing and stops (`tests/test_v3_sizing.py`)
    - Test p_win=0.3, b=2 → f_kelly < 0 → size = 0
    - Test p_win=0.7, b=2 → positive size within caps
    - Test cap enforcement (sector, correlation, heat)
    - Test position_below_minimum downgrade
    - Test stop/TP computation with known inputs
    - Test trailing stop monotonicity over a price sequence
    - _Requirements: 14.1–14.7, 16.1–16.5_

- [x] 12. Replace portfolio heat with stop-defined risk dollars

  - [x] 12.1 Implement stop-defined portfolio heat in `services/risk/engine.py`
    - Add `compute_portfolio_heat(positions, stop_distances) -> float`
    - risk_dollars = position_value × stop_distance_pct for each position
    - portfolio_heat = sum(risk_dollars)
    - Add `check_heat_capacity(current_heat, new_risk_dollars, max_heat_pct, portfolio_value) -> bool`
    - Reject new entry when current_heat + new_risk_dollars > max_heat_pct × portfolio_value
    - Integrate available_heat_capacity into Kelly sizing pipeline
    - _Requirements: 15.1, 15.2, 15.3, 15.4, 15.5_

  - [x] 12.2 Implement risk tier auto-adjustment v3 in `services/risk/engine.py`
    - Add `TierMetrics` dataclass (profit_factor_30d, max_drawdown_30d, calibration_error, realized_sharpe_30d, n_trades_30d, reserve_pool_pct)
    - Add `evaluate_tier_adjustment(metrics) -> str` returning 'upgrade'|'downgrade'|'hold'
    - Downgrade if ANY: profit_factor < 1.0 OR drawdown > 0.12 OR calibration_error > 0.20 OR sharpe < 0
    - Upgrade only if ALL: profit_factor > 1.35 AND drawdown < 0.05 AND calibration_error < 0.12 AND reserve > 0.20 AND N_trades >= 20
    - Apply downgrade immediately; enforce 7-day upgrade cooldown
    - Evaluate once per calendar day after session close
    - _Requirements: 18.1, 18.2, 18.3, 18.4, 18.5, 18.6_

  - [x] 12.3 Write property tests for heat and tier adjustment (`tests/test_pbt_v3_decision.py`)
    - **Property 22: Portfolio heat rejection is correct**
    - **Property 23: Tier auto-adjustment obeys downgrade-any, upgrade-all logic**
    - **Validates: Requirements 15.3, 15.5, 18.3, 18.4**
    - Use Hypothesis with `@settings(max_examples=100)`

  - [x] 12.4 Write unit tests for heat and tier (`tests/test_v3_risk.py`)
    - Test heat computation: 3 positions with known stops → expected heat
    - Test heat rejection: heat at limit → new entry blocked
    - Test tier downgrade: single bad metric triggers downgrade
    - Test tier upgrade: all metrics good → upgrade
    - Test tier upgrade: one metric bad → hold (not upgrade)
    - Test 7-day cooldown enforcement
    - _Requirements: 15.1–15.5, 18.1–18.6_

- [x] 13. Checkpoint - Verify sizing and risk layer
  - Ensure all tests pass for Kelly sizing, stops, heat, and tier adjustment.
  - Ensure all tests pass, ask the user if questions arise.

- [x] 14. Retire heuristic scoring to explainability-only mode

  - [x] 14.1 Wire v3 pipeline end-to-end in `services/aggregation/worker.py`
    - Orchestrate full pipeline: EvidenceUnit → q_i → LLR → cluster → posterior → contradiction → confidence → data_quality
    - Store v3 posterior in TrendSummary JSONB metadata (p_up, log_odds, strength, confidence, contradiction, n_eff, data_quality, regime)
    - Store return model in Recommendation JSONB metadata (mu_h, sigma_h, ev_long, min_edge)
    - Store explainability payload (top_positive_clusters, top_negative_clusters, suppression_reasons, risk_adjustments)
    - Preserve WeightedSignal as intermediate representation before LLR conversion
    - _Requirements: 20.1, 20.2, 20.3, 20.4, 20.5_

  - [x] 14.2 Retain heuristic pipeline as fallback with explainability overlay
    - Keep existing heuristic scoring path fully functional (no removal)
    - Mark heuristic outputs with `pipeline_mode: "heuristic"` in metadata
    - Ensure heuristic mode still produces valid TrendSummary and Recommendation objects
    - Test feature flag toggle: v3 → heuristic → v3 round-trip
    - _Requirements: 19.1, 19.2, 19.5, 20.4_

  - [x] 14.3 Write property test for JSON round-trip (`tests/test_pbt_v3_posterior.py`)
    - **Property 12: Posterior state JSON round-trip**
    - **Validates: Requirements 20.1, 21.10**
    - Serialize V3Posterior to JSON and deserialize, verify equivalence within 1e-10
    - Use Hypothesis with `@settings(max_examples=100)`

  - [x] 14.4 Write integration tests for full pipeline (`tests/test_v3_integration.py`)
    - Test full path: raw signals → EvidenceUnit → q_i → LLR → cluster → posterior → recommendation
    - Test feature flag false → heuristic path, flag true → v3 path
    - Test v3 exception → heuristic fallback + error logged
    - Test output JSONB contains expected v3 fields
    - _Requirements: 19.1–19.6, 20.1–20.5_

- [x] 15. Final checkpoint - All tests green
  - Ensure all tests pass, ask the user if questions arise.
  - Run full test suite: `.venv/bin/python -m pytest tests/test_pbt_v3_*.py tests/test_v3_*.py -x --tb=short -q`
  - Verify no regressions in existing heuristic pipeline tests

## Notes

- Tasks marked with `*` are optional and can be skipped for faster MVP
- Each task references specific requirements for traceability
- Checkpoints ensure incremental validation after each logical phase
- Property tests validate the 23 correctness properties defined in the design
- Unit tests validate specific examples, edge cases, and error handling
- The implementation preserves the existing heuristic pipeline as a fully functional fallback
- All v3 code is gated behind `v3_engine_enabled` — no changes to production behavior until flag is flipped
- Run tests with: `.venv/bin/python -m pytest tests/ -x --tb=short -q`
- Property tests use Hypothesis: `@settings(max_examples=100)`

## Task Dependency Graph

```json
{
  "waves": [
    { "id": 0, "tasks": ["1.1"] },
    { "id": 1, "tasks": ["1.2", "1.4"] },
    { "id": 2, "tasks": ["1.3"] },
    { "id": 3, "tasks": ["1.5", "1.6"] },
    { "id": 4, "tasks": ["2.1"] },
    { "id": 5, "tasks": ["2.2"] },
    { "id": 6, "tasks": ["2.3"] },
    { "id": 7, "tasks": ["2.4", "2.5"] },
    { "id": 8, "tasks": ["4.1"] },
    { "id": 9, "tasks": ["4.2"] },
    { "id": 10, "tasks": ["4.3", "4.4"] },
    { "id": 11, "tasks": ["5.1"] },
    { "id": 12, "tasks": ["5.2", "5.3"] },
    { "id": 13, "tasks": ["6.1", "6.2"] },
    { "id": 14, "tasks": ["6.3", "6.4"] },
    { "id": 15, "tasks": ["8.1", "8.2"] },
    { "id": 16, "tasks": ["8.3", "8.4"] },
    { "id": 17, "tasks": ["9.1"] },
    { "id": 18, "tasks": ["9.2", "9.3"] },
    { "id": 19, "tasks": ["9.4", "9.5"] },
    { "id": 20, "tasks": ["11.1", "11.2"] },
    { "id": 21, "tasks": ["11.3", "11.4"] },
    { "id": 22, "tasks": ["12.1", "12.2"] },
    { "id": 23, "tasks": ["12.3", "12.4"] },
    { "id": 24, "tasks": ["14.1"] },
    { "id": 25, "tasks": ["14.2"] },
    { "id": 26, "tasks": ["14.3", "14.4"] }
  ]
}
```
