"""
tests/test_bileshke_funnel.py — Integration tests for the Bileshke ↔ Fidelity Funnel.

Tests the funnel diagnostic integration into the bileshke engine:
  - LensCorrelation type
  - FunnelDiagnostic type with consecutive_ratios() and to_dict()
  - compute_funnel_diagnostic() — empty, single, independent, correlated
  - validate_lens_independence() — go/no-go pre-flight
  - compute_q3_kavaid() with empirical KV₇ via funnel_diagnostic
  - QualityReport.funnel_diagnostic field and serialization

Framework grounding:
  AX5:  Integration prerequisite — bileshke diagnoses itself
  AX27: Transparent vessel — same pattern, different domain
  AX52: Multiplicative gate — zero in any dimension collapses
  KV₇:  Independence — correlated lenses contaminate convergence
  T6:   Convergence bound — composite < 1.0
"""

import unittest
import random
from typing import Dict, List

from bileshke.types import (
    EpistemicGrade,
    FunnelDiagnostic,
    LensCorrelation,
    LensId,
    LensResult,
    LENS_ORDER,
    NUM_LENSES,
    QualityReport,
    LatifeVektor,
    OrtamVektor,
)

from bileshke.engine import (
    compute_funnel_diagnostic,
    validate_lens_independence,
)

from bileshke.quality import (
    compute_q3_kavaid,
)


# ========================================================================
# Helpers
# ========================================================================

def make_result(
    lens: LensId,
    score: float = 0.5,
    grade: EpistemicGrade = EpistemicGrade.TASDIK,
    checks_passed: int = 5,
    checks_total: int = 10,
) -> LensResult:
    """Helper to create a LensResult with defaults."""
    return LensResult(
        lens_id=lens,
        score=score,
        grade=grade,
        checks_passed=checks_passed,
        checks_total=checks_total,
    )


def make_all_results(score: float = 0.5) -> List[LensResult]:
    """Create results for all 7 lenses with uniform score."""
    return [make_result(lens, score) for lens in LENS_ORDER]


def make_independent_batch(
    n: int = 100,
    activation_prob: float = 0.5,
    threshold: float = 0.3,
    seed: int = 42,
) -> List[List[LensResult]]:
    """Create a batch where each lens activates independently.

    Each lens has `activation_prob` chance of scoring above `threshold`.

    For KV₇ statistics: use activation_prob=0.5 (stable sample sizes).
    For monotonicity: use activation_prob=0.10 (rare → funnel shape).
    """
    rng = random.Random(seed)
    batch = []
    for _ in range(n):
        results = []
        for lens in LENS_ORDER:
            if rng.random() < activation_prob:
                score = rng.uniform(threshold + 0.05, 0.95)
            else:
                score = rng.uniform(0.0, max(0.01, threshold - 0.05))
            results.append(make_result(lens, score))
        batch.append(results)
    return batch


def make_correlated_batch(
    n: int = 200,
    threshold: float = 0.3,
    seed: int = 42,
) -> List[List[LensResult]]:
    """Create a batch where Ontoloji and Mereoloji always co-activate.

    The correlated pair activates with ~8% probability, and they
    ALWAYS fire together (logical implication). Other lenses activate
    independently at ~8%.

    With P(j) ≈ 0.08 and P(j|i) ≈ 1.0:
      ratio ≈ 1.0 / 0.08 ≈ 12.5 → exceeds default threshold of 10.0.
    """
    rng = random.Random(seed)
    batch = []
    for _ in range(n):
        results = []
        # Correlated pair decision
        pair_active = rng.random() < 0.08
        for i, lens in enumerate(LENS_ORDER):
            if i in (0, 1):
                # Perfectly correlated: both on or both off
                if pair_active:
                    score = rng.uniform(threshold + 0.1, 0.9)
                else:
                    score = rng.uniform(0.0, max(0.01, threshold - 0.1))
            else:
                # Independent at ~8%
                if rng.random() < 0.08:
                    score = rng.uniform(threshold + 0.05, 0.95)
                else:
                    score = rng.uniform(0.0, max(0.01, threshold - 0.05))
            results.append(make_result(lens, score))
        batch.append(results)
    return batch


# ========================================================================
# 1. LensCorrelation
# ========================================================================

class TestLensCorrelation(unittest.TestCase):
    """Test the LensCorrelation frozen dataclass."""

    def test_construction(self):
        """LensCorrelation can be constructed with all fields."""
        lc = LensCorrelation(
            lens_i=LensId.ONTOLOJI,
            lens_j=LensId.BAYES,
            p_j=0.5,
            p_j_given_i=0.6,
            ratio=1.2,
            independent=True,
        )
        self.assertEqual(lc.lens_i, LensId.ONTOLOJI)
        self.assertEqual(lc.lens_j, LensId.BAYES)
        self.assertAlmostEqual(lc.p_j, 0.5)
        self.assertAlmostEqual(lc.p_j_given_i, 0.6)
        self.assertAlmostEqual(lc.ratio, 1.2)
        self.assertTrue(lc.independent)

    def test_frozen(self):
        """LensCorrelation is immutable (frozen dataclass)."""
        lc = LensCorrelation(
            lens_i=LensId.ONTOLOJI,
            lens_j=LensId.BAYES,
            p_j=0.5,
            p_j_given_i=0.6,
            ratio=1.2,
            independent=True,
        )
        with self.assertRaises(AttributeError):
            lc.ratio = 2.0  # type: ignore

    def test_independent_flag_true(self):
        """Independence when ratio <= threshold."""
        lc = LensCorrelation(
            lens_i=LensId.FOL,
            lens_j=LensId.MEREOLOJI,
            p_j=0.4,
            p_j_given_i=0.42,
            ratio=1.05,
            independent=True,
        )
        self.assertTrue(lc.independent)

    def test_independent_flag_false(self):
        """Violation when ratio exceeds threshold."""
        lc = LensCorrelation(
            lens_i=LensId.ONTOLOJI,
            lens_j=LensId.MEREOLOJI,
            p_j=0.05,
            p_j_given_i=0.95,
            ratio=19.0,
            independent=False,
        )
        self.assertFalse(lc.independent)


# ========================================================================
# 2. FunnelDiagnostic
# ========================================================================

class TestFunnelDiagnostic(unittest.TestCase):
    """Test the FunnelDiagnostic dataclass."""

    def _make_diagnostic(self, **overrides) -> FunnelDiagnostic:
        """Create a FunnelDiagnostic with defaults."""
        defaults = dict(
            threshold=0.3,
            n_analyses=10,
            spectrum={0: 5, 1: 3, 2: 1, 3: 1, 4: 0, 5: 0, 6: 0, 7: 0},
            is_monotonic=True,
            violation_at=None,
            correlations=[],
            kv7_satisfied=True,
            max_correlation_ratio=1.2,
        )
        defaults.update(overrides)
        return FunnelDiagnostic(**defaults)

    def test_construction(self):
        """FunnelDiagnostic can be constructed with all fields."""
        fd = self._make_diagnostic()
        self.assertAlmostEqual(fd.threshold, 0.3)
        self.assertEqual(fd.n_analyses, 10)
        self.assertTrue(fd.is_monotonic)
        self.assertTrue(fd.kv7_satisfied)

    def test_consecutive_ratios_monotonic(self):
        """consecutive_ratios returns D(m-1)/D(m) for each level."""
        fd = self._make_diagnostic(
            spectrum={0: 20, 1: 10, 2: 5, 3: 2, 4: 1, 5: 0, 6: 0, 7: 0}
        )
        ratios = fd.consecutive_ratios()
        # D(0)/D(1) = 20/10 = 2.0
        # D(1)/D(2) = 10/5 = 2.0
        # D(2)/D(3) = 5/2 = 2.5
        # D(3)/D(4) = 2/1 = 2.0
        # D(4)/D(5) = 1/0 → None
        # D(5)/D(6) = 0/0 → None
        # D(6)/D(7) = 0/0 → None
        self.assertEqual(len(ratios), 7)
        self.assertAlmostEqual(ratios[0], 2.0)
        self.assertAlmostEqual(ratios[1], 2.0)
        self.assertAlmostEqual(ratios[2], 2.5)
        self.assertAlmostEqual(ratios[3], 2.0)
        self.assertIsNone(ratios[4])
        self.assertIsNone(ratios[5])
        self.assertIsNone(ratios[6])

    def test_consecutive_ratios_all_positive_means_funnel(self):
        """When all defined ratios > 1, the spectrum funnels down."""
        fd = self._make_diagnostic(
            spectrum={0: 100, 1: 50, 2: 25, 3: 12, 4: 6, 5: 3, 6: 1, 7: 0}
        )
        ratios = fd.consecutive_ratios()
        for r in ratios:
            if r is not None:
                self.assertGreater(r, 1.0)

    def test_consecutive_ratios_empty_spectrum(self):
        """Empty spectrum yields empty ratios."""
        fd = self._make_diagnostic(spectrum={})
        ratios = fd.consecutive_ratios()
        self.assertEqual(ratios, [])

    def test_to_dict_keys(self):
        """to_dict produces all expected keys."""
        fd = self._make_diagnostic()
        d = fd.to_dict()
        expected_keys = {
            "threshold", "n_analyses", "spectrum", "is_monotonic",
            "violation_at", "kv7_satisfied", "max_correlation_ratio",
            "consecutive_ratios", "correlations",
        }
        self.assertEqual(set(d.keys()), expected_keys)

    def test_to_dict_with_correlations(self):
        """to_dict serializes correlations correctly."""
        lc = LensCorrelation(
            lens_i=LensId.ONTOLOJI,
            lens_j=LensId.BAYES,
            p_j=0.5,
            p_j_given_i=0.5,
            ratio=1.0,
            independent=True,
        )
        fd = self._make_diagnostic(correlations=[lc])
        d = fd.to_dict()
        self.assertEqual(len(d["correlations"]), 1)
        self.assertEqual(d["correlations"][0]["lens_i"], "Ontoloji")
        self.assertEqual(d["correlations"][0]["lens_j"], "Bayes")
        self.assertTrue(d["correlations"][0]["independent"])

    def test_violation_at_none_when_monotonic(self):
        """violation_at is None when spectrum is monotonic."""
        fd = self._make_diagnostic(
            spectrum={0: 10, 1: 5, 2: 3, 3: 1, 4: 0, 5: 0, 6: 0, 7: 0},
            is_monotonic=True,
            violation_at=None,
        )
        self.assertIsNone(fd.violation_at)

    def test_violation_at_marks_first_increase(self):
        """violation_at records first m where D(m) > D(m-1)."""
        fd = self._make_diagnostic(
            spectrum={0: 2, 1: 5, 2: 3, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0},
            is_monotonic=False,
            violation_at=1,
        )
        self.assertEqual(fd.violation_at, 1)
        self.assertFalse(fd.is_monotonic)


# ========================================================================
# 3. compute_funnel_diagnostic
# ========================================================================

class TestComputeFunnelDiagnostic(unittest.TestCase):
    """Test the compute_funnel_diagnostic engine function."""

    def test_empty_batch(self):
        """Empty batch returns a trivial diagnostic."""
        fd = compute_funnel_diagnostic([])
        self.assertEqual(fd.n_analyses, 0)
        self.assertTrue(fd.is_monotonic)
        self.assertTrue(fd.kv7_satisfied)
        self.assertEqual(fd.correlations, [])
        self.assertAlmostEqual(fd.max_correlation_ratio, 0.0)

    def test_single_analysis(self):
        """Single analysis batch produces a valid diagnostic."""
        results = make_all_results(0.5)
        fd = compute_funnel_diagnostic([results], threshold=0.3)
        self.assertEqual(fd.n_analyses, 1)
        # All 7 lenses above 0.3 → D(7) = 1, all others 0
        self.assertEqual(fd.spectrum[7], 1)
        for m in range(7):
            self.assertEqual(fd.spectrum[m], 0)

    def test_single_analysis_all_below(self):
        """Single analysis where all lenses below threshold."""
        results = make_all_results(0.1)
        fd = compute_funnel_diagnostic([results], threshold=0.3)
        self.assertEqual(fd.spectrum[0], 1)
        for m in range(1, 8):
            self.assertEqual(fd.spectrum[m], 0)

    def test_spectrum_coverage(self):
        """Spectrum covers 0 through NUM_LENSES (7)."""
        batch = make_independent_batch(30, activation_prob=0.3)
        fd = compute_funnel_diagnostic(batch)
        self.assertEqual(len(fd.spectrum), NUM_LENSES + 1)
        for m in range(NUM_LENSES + 1):
            self.assertIn(m, fd.spectrum)

    def test_spectrum_sum_equals_n(self):
        """Sum of all D(m) values equals the number of analyses."""
        n = 40
        batch = make_independent_batch(n, activation_prob=0.3)
        fd = compute_funnel_diagnostic(batch)
        total = sum(fd.spectrum.values())
        self.assertEqual(total, n)

    def test_independent_batch_kv7_satisfied(self):
        """Independent lenses should satisfy KV₇ (default threshold=10.0)."""
        batch = make_independent_batch(200, activation_prob=0.5)
        fd = compute_funnel_diagnostic(batch, threshold=0.3)
        # With truly independent data, no pair should have ratio > 10.0
        self.assertTrue(fd.kv7_satisfied)

    def test_independent_batch_correlations_reasonable(self):
        """Independent lenses have correlation ratios well below threshold."""
        batch = make_independent_batch(300, activation_prob=0.5)
        fd = compute_funnel_diagnostic(batch, threshold=0.3)
        for c in fd.correlations:
            # With independent data at 50% activation, ratio ≈ 1.0 ± noise
            self.assertLess(c.ratio, 5.0,
                f"Unexpected high ratio for {c.lens_i.value}→{c.lens_j.value}")

    def test_correlated_batch_kv7_violated(self):
        """Correlated lenses should violate KV₇.

        make_correlated_batch creates Ontoloji and Mereoloji with
        perfect co-activation at ~8%, giving ratio ≈ 12.5 > 10.0.
        """
        batch = make_correlated_batch(200)
        fd = compute_funnel_diagnostic(batch, threshold=0.3)
        self.assertFalse(fd.kv7_satisfied,
            f"Expected KV₇ violation, max_ratio={fd.max_correlation_ratio}")
        self.assertGreater(fd.max_correlation_ratio, 5.0)

    def test_correlated_batch_identifies_pair(self):
        """Correlated pair should appear in correlations with high ratio."""
        batch = make_correlated_batch(200)
        fd = compute_funnel_diagnostic(batch, threshold=0.3)
        # Find the Ontoloji→Mereoloji correlation
        pair = None
        for c in fd.correlations:
            if c.lens_i == LensId.ONTOLOJI and c.lens_j == LensId.MEREOLOJI:
                pair = c
                break
        self.assertIsNotNone(pair, "Ontoloji→Mereoloji pair not found")
        # Correlated pair should have high ratio
        self.assertGreater(pair.ratio, 3.0)

    def test_threshold_affects_activation(self):
        """Higher threshold means fewer lenses are 'active'."""
        results = [make_result(lens, 0.6) for lens in LENS_ORDER]
        batch = [results]
        fd_low = compute_funnel_diagnostic(batch, threshold=0.3)
        fd_high = compute_funnel_diagnostic(batch, threshold=0.8)
        # At 0.3: all 7 are active. At 0.8: none are active.
        self.assertEqual(fd_low.spectrum[7], 1)
        self.assertEqual(fd_high.spectrum[0], 1)

    def test_custom_independence_threshold(self):
        """Lower independence_threshold makes the test stricter."""
        batch = make_independent_batch(100, activation_prob=0.5)
        fd_strict = compute_funnel_diagnostic(
            batch, threshold=0.3, independence_threshold=1.1
        )
        fd_lenient = compute_funnel_diagnostic(
            batch, threshold=0.3, independence_threshold=100.0
        )
        # Lenient should be satisfied if strict is
        if fd_strict.kv7_satisfied:
            self.assertTrue(fd_lenient.kv7_satisfied)

    def test_monotonicity_with_rare_activation(self):
        """Rare activation (p ≈ 0.10) produces a monotonic funnel spectrum.

        With 7 lenses and p=0.10:
          E[D(0)] ≈ 0.478*N, E[D(1)] ≈ 0.372*N, E[D(2)] ≈ 0.124*N
        This is strictly decreasing — the Fidelity Funnel Conjecture in action.
        """
        batch = make_independent_batch(500, activation_prob=0.10)
        fd = compute_funnel_diagnostic(batch, threshold=0.3)
        self.assertTrue(fd.is_monotonic,
            f"Non-monotonic spectrum: {fd.spectrum}, violation at m={fd.violation_at}")

    def test_high_activation_not_monotonic(self):
        """High activation (p=0.5) produces bell-shaped spectrum, NOT monotonic.

        This is expected: with 7 lenses at 50% each, the binomial
        distribution peaks at ~3.5, not at 0.
        """
        batch = make_independent_batch(200, activation_prob=0.5)
        fd = compute_funnel_diagnostic(batch, threshold=0.3)
        # Binomial(7, 0.5) peaks at 3-4, so D(1) > D(0) → not monotonic
        self.assertFalse(fd.is_monotonic)

    def test_n_analyses_field(self):
        """n_analyses reflects the batch size."""
        n = 37
        batch = make_independent_batch(n, activation_prob=0.3)
        fd = compute_funnel_diagnostic(batch)
        self.assertEqual(fd.n_analyses, n)

    def test_threshold_field_preserved(self):
        """threshold is recorded in the diagnostic."""
        fd = compute_funnel_diagnostic(
            make_independent_batch(10, activation_prob=0.3), threshold=0.42
        )
        self.assertAlmostEqual(fd.threshold, 0.42)


# ========================================================================
# 4. validate_lens_independence
# ========================================================================

class TestValidateLensIndependence(unittest.TestCase):
    """Test the validate_lens_independence pre-flight check."""

    def test_independent_returns_true(self):
        """Independent batch → (True, None)."""
        batch = make_independent_batch(200, activation_prob=0.5)
        is_valid, msg = validate_lens_independence(batch)
        self.assertTrue(is_valid)
        self.assertIsNone(msg)

    def test_correlated_returns_false_with_message(self):
        """Correlated batch → (False, warning message)."""
        batch = make_correlated_batch(200)
        is_valid, msg = validate_lens_independence(batch)
        self.assertFalse(is_valid,
            f"Expected validation to fail for correlated batch")
        self.assertIsNotNone(msg)
        self.assertIn("KV₇ VIOLATION", msg)
        self.assertIn("correlated", msg.lower())

    def test_correlated_message_identifies_lenses(self):
        """Warning message identifies the correlated lens pair."""
        batch = make_correlated_batch(200)
        is_valid, msg = validate_lens_independence(batch)
        if not is_valid:
            # Should mention at least one of the correlated lenses
            self.assertTrue(
                "Ontoloji" in msg or "Mereoloji" in msg,
                f"Message doesn't identify correlated lenses: {msg}"
            )
        else:
            # If the seed happens to not trigger, check the diagnostic directly
            fd = compute_funnel_diagnostic(batch)
            self.assertGreater(fd.max_correlation_ratio, 3.0,
                "Correlated batch should have high correlations")

    def test_empty_batch_returns_true(self):
        """Empty batch → (True, None) — no data to violate."""
        is_valid, msg = validate_lens_independence([])
        self.assertTrue(is_valid)
        self.assertIsNone(msg)

    def test_custom_thresholds(self):
        """Custom thresholds are forwarded correctly."""
        batch = make_independent_batch(50, activation_prob=0.5)
        is_valid, msg = validate_lens_independence(
            batch, threshold=0.5, independence_threshold=50.0
        )
        self.assertTrue(is_valid)
        self.assertIsNone(msg)


# ========================================================================
# 5. compute_q3_kavaid with funnel_diagnostic
# ========================================================================

class TestQ3KavaidWithFunnel(unittest.TestCase):
    """Test compute_q3_kavaid's empirical KV₇ via funnel_diagnostic."""

    def test_without_funnel_structural_kv7(self):
        """Without funnel_diagnostic, KV₇ defaults to structural (True)."""
        result = compute_q3_kavaid(composite_score=0.5)
        self.assertTrue(result["checks"]["KV7"])
        self.assertEqual(result["kv7_method"], "structural (no lens imports)")

    def test_with_funnel_empirical_kv7_pass(self):
        """With passing funnel_diagnostic, KV₇ uses empirical check."""
        fd = FunnelDiagnostic(
            threshold=0.3,
            n_analyses=100,
            spectrum={0: 20, 1: 30, 2: 25, 3: 15, 4: 7, 5: 2, 6: 1, 7: 0},
            is_monotonic=True,
            violation_at=None,
            correlations=[],
            kv7_satisfied=True,
            max_correlation_ratio=1.2,
        )
        result = compute_q3_kavaid(composite_score=0.5, funnel_diagnostic=fd)
        self.assertTrue(result["checks"]["KV7"])
        self.assertEqual(result["kv7_method"], "empirical (fidelity funnel)")
        self.assertAlmostEqual(result["kv7_max_ratio"], 1.2)
        self.assertEqual(result["kv7_n_analyses"], 100)

    def test_with_funnel_empirical_kv7_fail(self):
        """With failing funnel_diagnostic, KV₇ reports False."""
        fd = FunnelDiagnostic(
            threshold=0.3,
            n_analyses=50,
            spectrum={0: 5, 1: 10, 2: 15, 3: 10, 4: 5, 5: 3, 6: 1, 7: 1},
            is_monotonic=False,
            violation_at=2,
            correlations=[
                LensCorrelation(
                    lens_i=LensId.ONTOLOJI,
                    lens_j=LensId.MEREOLOJI,
                    p_j=0.1,
                    p_j_given_i=0.95,
                    ratio=19.0,
                    independent=False,
                ),
            ],
            kv7_satisfied=False,
            max_correlation_ratio=19.0,
        )
        result = compute_q3_kavaid(composite_score=0.5, funnel_diagnostic=fd)
        self.assertFalse(result["checks"]["KV7"])
        self.assertFalse(result["all_pass"])
        self.assertEqual(result["kv7_method"], "empirical (fidelity funnel)")

    def test_funnel_does_not_override_explicit_kv7(self):
        """If kavaid_checks already sets KV7, funnel_diagnostic doesn't override."""
        fd = FunnelDiagnostic(
            threshold=0.3,
            n_analyses=10,
            spectrum={0: 10},
            is_monotonic=True,
            violation_at=None,
            correlations=[],
            kv7_satisfied=False,  # Would fail empirically
            max_correlation_ratio=15.0,
        )
        # Explicit KV7=True should NOT be overridden by funnel
        result = compute_q3_kavaid(
            kavaid_checks={"KV7": True},
            composite_score=0.5,
            funnel_diagnostic=fd,
        )
        self.assertTrue(result["checks"]["KV7"])

    def test_kv4_still_works_with_funnel(self):
        """KV₄ composite check works alongside funnel diagnostic."""
        fd = FunnelDiagnostic(
            threshold=0.3,
            n_analyses=10,
            spectrum={0: 10},
            is_monotonic=True,
            violation_at=None,
            correlations=[],
            kv7_satisfied=True,
            max_correlation_ratio=1.0,
        )
        # High composite should trigger KV₄ warning
        result = compute_q3_kavaid(composite_score=0.96, funnel_diagnostic=fd)
        self.assertFalse(result["checks"]["KV4"])
        self.assertFalse(result["all_pass"])


# ========================================================================
# 6. QualityReport with funnel_diagnostic
# ========================================================================

class TestQualityReportFunnelField(unittest.TestCase):
    """Test QualityReport's funnel_diagnostic field."""

    def test_default_is_none(self):
        """funnel_diagnostic defaults to None."""
        qr = QualityReport()
        self.assertIsNone(qr.funnel_diagnostic)

    def test_can_set_funnel_diagnostic(self):
        """funnel_diagnostic can be set to a FunnelDiagnostic."""
        fd = FunnelDiagnostic(
            threshold=0.3,
            n_analyses=10,
            spectrum={0: 5, 1: 3, 2: 2},
            is_monotonic=True,
            violation_at=None,
            correlations=[],
            kv7_satisfied=True,
            max_correlation_ratio=0.9,
        )
        qr = QualityReport(funnel_diagnostic=fd)
        self.assertIsNotNone(qr.funnel_diagnostic)
        self.assertEqual(qr.funnel_diagnostic.n_analyses, 10)
        self.assertTrue(qr.funnel_diagnostic.kv7_satisfied)

    def test_to_dict_includes_funnel_when_set(self):
        """to_dict includes funnel_diagnostic when set."""
        fd = FunnelDiagnostic(
            threshold=0.3,
            n_analyses=10,
            spectrum={0: 5, 1: 3, 2: 2},
            is_monotonic=True,
            violation_at=None,
            correlations=[],
            kv7_satisfied=True,
            max_correlation_ratio=0.9,
        )
        qr = QualityReport(funnel_diagnostic=fd)
        d = qr.to_dict()
        self.assertIsNotNone(d["funnel_diagnostic"])
        self.assertEqual(d["funnel_diagnostic"]["n_analyses"], 10)
        self.assertTrue(d["funnel_diagnostic"]["kv7_satisfied"])

    def test_to_dict_funnel_none_when_unset(self):
        """to_dict has funnel_diagnostic=None when not set."""
        qr = QualityReport()
        d = qr.to_dict()
        self.assertIsNone(d["funnel_diagnostic"])

    def test_to_dict_funnel_has_spectrum(self):
        """Serialized funnel includes the spectrum."""
        fd = FunnelDiagnostic(
            threshold=0.3,
            n_analyses=20,
            spectrum={0: 10, 1: 5, 2: 3, 3: 2, 4: 0, 5: 0, 6: 0, 7: 0},
            is_monotonic=True,
            violation_at=None,
            correlations=[],
            kv7_satisfied=True,
            max_correlation_ratio=1.1,
        )
        qr = QualityReport(funnel_diagnostic=fd)
        d = qr.to_dict()
        self.assertIn("spectrum", d["funnel_diagnostic"])
        self.assertEqual(d["funnel_diagnostic"]["spectrum"][0], 10)


# ========================================================================
# 7. End-to-end integration scenarios
# ========================================================================

class TestEndToEndIntegration(unittest.TestCase):
    """Full pipeline: create batch → diagnose → feed to quality."""

    def test_independent_pipeline(self):
        """Independent batch: compute diagnostic, validate, check quality."""
        # Step 1: Generate independent batch with rare activation → funnel
        batch = make_independent_batch(500, activation_prob=0.10)

        # Step 2: Pre-flight check
        is_valid, msg = validate_lens_independence(batch)
        self.assertTrue(is_valid)
        self.assertIsNone(msg)

        # Step 3: Compute diagnostic — rare activation → monotonic
        fd = compute_funnel_diagnostic(batch, threshold=0.3)
        self.assertTrue(fd.kv7_satisfied)
        self.assertTrue(fd.is_monotonic,
            f"Expected monotonic spectrum with p=0.10, got {fd.spectrum}")

        # Step 4: Feed to quality framework
        q3 = compute_q3_kavaid(composite_score=0.5, funnel_diagnostic=fd)
        self.assertTrue(q3["checks"]["KV7"])
        self.assertEqual(q3["kv7_method"], "empirical (fidelity funnel)")

        # Step 5: Attach to QualityReport
        qr = QualityReport(
            composite_score=0.5,
            funnel_diagnostic=fd,
        )
        d = qr.to_dict()
        self.assertIsNotNone(d["funnel_diagnostic"])
        self.assertTrue(d["funnel_diagnostic"]["kv7_satisfied"])

    def test_correlated_pipeline(self):
        """Correlated batch: diagnostic catches KV₇ violation end-to-end."""
        # Step 1: Generate correlated batch
        batch = make_correlated_batch(200)

        # Step 2: Compute diagnostic — should catch the violation
        fd = compute_funnel_diagnostic(batch, threshold=0.3)
        self.assertFalse(fd.kv7_satisfied,
            f"Expected KV₇ violation, max_ratio={fd.max_correlation_ratio}")

        # Step 3: Pre-flight should fail
        is_valid, msg = validate_lens_independence(batch)
        self.assertFalse(is_valid)
        self.assertIn("KV₇", msg)

        # Step 4: Quality framework reflects the failure
        q3 = compute_q3_kavaid(composite_score=0.5, funnel_diagnostic=fd)
        self.assertFalse(q3["checks"]["KV7"])
        self.assertFalse(q3["all_pass"])

    def test_diagnostic_to_dict_roundtrip(self):
        """Diagnostic serializes to dict and contains all critical info."""
        batch = make_independent_batch(50, activation_prob=0.3)
        fd = compute_funnel_diagnostic(batch)
        d = fd.to_dict()

        # All keys present
        self.assertIn("threshold", d)
        self.assertIn("n_analyses", d)
        self.assertIn("spectrum", d)
        self.assertIn("is_monotonic", d)
        self.assertIn("kv7_satisfied", d)
        self.assertIn("max_correlation_ratio", d)
        self.assertIn("consecutive_ratios", d)
        self.assertIn("correlations", d)

        # Spectrum sums to n_analyses
        self.assertEqual(sum(d["spectrum"].values()), d["n_analyses"])

    def test_backward_compatibility(self):
        """Existing bileshke API works unchanged without funnel_diagnostic.

        compute_q3_kavaid() with 3 args (no funnel_diagnostic)
        should still work — the new parameter has default None.
        """
        result = compute_q3_kavaid(
            kavaid_checks={"KV1": True, "KV2": True},
            composite_score=0.5,
        )
        self.assertTrue(result["checks"]["KV7"])
        self.assertEqual(result["kv7_method"], "structural (no lens imports)")
        self.assertTrue(result["all_pass"])


if __name__ == "__main__":
    unittest.main()
