"""Intertemporal choice and time preference tests.

Implements revealed preference tests for time preferences, including
exponential discounting, quasi-hyperbolic (beta-delta) discounting,
and discount factor recovery.

Tech-Friendly Names (Primary):
    - test_exponential_discounting(): Test time-consistent preferences
    - test_quasi_hyperbolic(): Test beta-delta model
    - recover_discount_factor(): Bound discount factor from choices
    - test_present_bias(): Detect present bias

Economics Names (Legacy Aliases):
    - check_exponential_discounting() -> test_exponential_discounting()
    - check_quasi_hyperbolic_discounting() -> test_quasi_hyperbolic()

References:
    Echenique, F., Imai, T., & Saito, K. (2020). Testable implications
    of models of intertemporal choice: Exponential discounting and its
    generalizations. American Economic Journal: Microeconomics.
"""

from __future__ import annotations

import time
from dataclasses import dataclass
from typing import TYPE_CHECKING

import numpy as np
from numpy.typing import NDArray
from scipy.optimize import linprog, minimize_scalar

from pyrevealed.core.display import ResultDisplayMixin, ResultPlotMixin
from pyrevealed.core.mixins import ResultSummaryMixin


# =============================================================================
# DATA STRUCTURES
# =============================================================================


@dataclass
class DatedChoice:
    """
    A choice between consumption at different dates.

    Attributes:
        amounts: Array of consumption amounts at each date
        dates: Array of dates (time periods) for each amount
        chosen: Index of the chosen (amount, date) pair
        budget: Total budget constraint (optional)
    """
    amounts: NDArray[np.float64]  # Consumption amounts
    dates: NDArray[np.int64]  # Time periods
    chosen: int  # Index of chosen option
    budget: float | None = None  # Optional budget


@dataclass(frozen=True)
class ExponentialDiscountingResult(ResultDisplayMixin, ResultPlotMixin):
    """
    Result of exponential discounting test.

    Exponential discounting assumes U = sum_t delta^t * u(c_t) where
    delta is the constant discount factor. Time-consistent preferences
    satisfy exponential discounting.

    Attributes:
        is_consistent: True if choices are consistent with exponential discounting
        violations: List of choice pairs violating exponential discounting
        delta_lower: Lower bound on consistent discount factor
        delta_upper: Upper bound on consistent discount factor
        num_observations: Number of choices analyzed
        computation_time_ms: Time taken in milliseconds
    """
    is_consistent: bool
    violations: list[tuple[int, int]]
    delta_lower: float
    delta_upper: float
    num_observations: int
    computation_time_ms: float

    @property
    def delta_range(self) -> float:
        """Width of the consistent discount factor range."""
        return self.delta_upper - self.delta_lower

    @property
    def has_tight_bounds(self) -> bool:
        """True if discount factor is tightly identified (range < 0.1)."""
        return self.delta_range < 0.1

    def score(self) -> float:
        """Return scikit-learn style score. Higher = more consistent."""
        return 1.0 if self.is_consistent else 0.0

    def summary(self) -> str:
        """Return human-readable summary report."""
        m = ResultSummaryMixin
        lines = [m._format_header("EXPONENTIAL DISCOUNTING TEST REPORT")]

        status = "CONSISTENT" if self.is_consistent else "VIOLATIONS FOUND"
        lines.append(f"\nStatus: {status}")

        lines.append(m._format_section("Metrics"))
        lines.append(m._format_metric("Is Consistent", self.is_consistent))
        lines.append(m._format_metric("Violations", len(self.violations)))
        lines.append(m._format_metric("Delta Lower Bound", f"{self.delta_lower:.4f}"))
        lines.append(m._format_metric("Delta Upper Bound", f"{self.delta_upper:.4f}"))
        lines.append(m._format_metric("Observations", self.num_observations))

        lines.append(m._format_section("Interpretation"))
        if self.is_consistent:
            lines.append("  Choices are time-consistent (exponential discounting).")
            if self.has_tight_bounds:
                mid = (self.delta_lower + self.delta_upper) / 2
                lines.append(f"  Estimated discount factor: ~{mid:.3f}")
        else:
            lines.append("  Choices exhibit time inconsistency.")
            lines.append("  May indicate present bias or preference reversal.")

        lines.append(m._format_footer(self.computation_time_ms))
        return "\n".join(lines)

    def to_dict(self) -> dict:
        """Return dictionary representation."""
        return {
            "is_consistent": self.is_consistent,
            "num_violations": len(self.violations),
            "delta_lower": self.delta_lower,
            "delta_upper": self.delta_upper,
            "delta_range": self.delta_range,
            "num_observations": self.num_observations,
            "computation_time_ms": self.computation_time_ms,
        }

    def __repr__(self) -> str:
        indicator = "[+]" if self.is_consistent else "[-]"
        return f"ExponentialDiscountingResult: {indicator} delta in [{self.delta_lower:.3f}, {self.delta_upper:.3f}]"


@dataclass(frozen=True)
class QuasiHyperbolicResult(ResultDisplayMixin, ResultPlotMixin):
    """
    Result of quasi-hyperbolic (beta-delta) discounting test.

    Beta-delta preferences: U = u(c_0) + beta * sum_{t>0} delta^t * u(c_t)
    where beta < 1 captures present bias.

    Attributes:
        is_consistent: True if choices are consistent with beta-delta
        beta_lower: Lower bound on present bias parameter
        beta_upper: Upper bound on present bias parameter
        delta_lower: Lower bound on discount factor
        delta_upper: Upper bound on discount factor
        has_present_bias: True if beta < 1 is required
        violations: List of violation pairs
        num_observations: Number of choices analyzed
        computation_time_ms: Time taken in milliseconds
    """
    is_consistent: bool
    beta_lower: float
    beta_upper: float
    delta_lower: float
    delta_upper: float
    has_present_bias: bool
    violations: list[tuple[int, int]]
    num_observations: int
    computation_time_ms: float

    def score(self) -> float:
        """Return scikit-learn style score. Higher = more consistent."""
        return 1.0 if self.is_consistent else 0.0

    def summary(self) -> str:
        """Return human-readable summary report."""
        m = ResultSummaryMixin
        lines = [m._format_header("QUASI-HYPERBOLIC DISCOUNTING TEST REPORT")]

        status = "CONSISTENT" if self.is_consistent else "VIOLATIONS FOUND"
        lines.append(f"\nStatus: {status}")

        lines.append(m._format_section("Parameters"))
        lines.append(m._format_metric("Beta Range", f"[{self.beta_lower:.3f}, {self.beta_upper:.3f}]"))
        lines.append(m._format_metric("Delta Range", f"[{self.delta_lower:.3f}, {self.delta_upper:.3f}]"))
        lines.append(m._format_metric("Present Bias Detected", self.has_present_bias))

        lines.append(m._format_section("Interpretation"))
        if self.has_present_bias:
            lines.append("  Choices exhibit present bias (beta < 1).")
            lines.append("  Immediate rewards are over-weighted.")
        else:
            lines.append("  No significant present bias detected.")

        lines.append(m._format_footer(self.computation_time_ms))
        return "\n".join(lines)

    def to_dict(self) -> dict:
        """Return dictionary representation."""
        return {
            "is_consistent": self.is_consistent,
            "beta_lower": self.beta_lower,
            "beta_upper": self.beta_upper,
            "delta_lower": self.delta_lower,
            "delta_upper": self.delta_upper,
            "has_present_bias": self.has_present_bias,
            "num_violations": len(self.violations),
            "computation_time_ms": self.computation_time_ms,
        }

    def __repr__(self) -> str:
        indicator = "[+]" if self.is_consistent else "[-]"
        bias = "present-biased" if self.has_present_bias else "no bias"
        return f"QuasiHyperbolicResult: {indicator} {bias}, beta~{(self.beta_lower+self.beta_upper)/2:.2f}"


@dataclass(frozen=True)
class DiscountFactorBounds(ResultDisplayMixin, ResultPlotMixin):
    """
    Bounds on discount factor recovered from choice data.

    Attributes:
        delta_lower: Lower bound on discount factor
        delta_upper: Upper bound on discount factor
        is_identified: True if bounds are finite and non-trivial
        implied_interest_rate_lower: Lower bound on implied interest rate
        implied_interest_rate_upper: Upper bound on implied interest rate
        num_binding_constraints: Number of choices that restrict bounds
        computation_time_ms: Time taken in milliseconds
    """
    delta_lower: float
    delta_upper: float
    is_identified: bool
    implied_interest_rate_lower: float
    implied_interest_rate_upper: float
    num_binding_constraints: int
    computation_time_ms: float

    @property
    def midpoint(self) -> float:
        """Midpoint of discount factor range."""
        return (self.delta_lower + self.delta_upper) / 2

    def score(self) -> float:
        """Return midpoint discount factor as score."""
        return self.midpoint

    def summary(self) -> str:
        """Return human-readable summary report."""
        m = ResultSummaryMixin
        lines = [m._format_header("DISCOUNT FACTOR BOUNDS REPORT")]

        lines.append(m._format_section("Discount Factor"))
        lines.append(m._format_metric("Lower Bound", f"{self.delta_lower:.4f}"))
        lines.append(m._format_metric("Upper Bound", f"{self.delta_upper:.4f}"))
        lines.append(m._format_metric("Is Identified", self.is_identified))

        lines.append(m._format_section("Implied Interest Rate"))
        lines.append(m._format_metric("Lower Bound", f"{self.implied_interest_rate_lower:.2%}"))
        lines.append(m._format_metric("Upper Bound", f"{self.implied_interest_rate_upper:.2%}"))

        lines.append(m._format_footer(self.computation_time_ms))
        return "\n".join(lines)

    def to_dict(self) -> dict:
        """Return dictionary representation."""
        return {
            "delta_lower": self.delta_lower,
            "delta_upper": self.delta_upper,
            "is_identified": self.is_identified,
            "midpoint": self.midpoint,
            "implied_interest_rate_lower": self.implied_interest_rate_lower,
            "implied_interest_rate_upper": self.implied_interest_rate_upper,
        }

    def __repr__(self) -> str:
        return f"DiscountFactorBounds(delta in [{self.delta_lower:.3f}, {self.delta_upper:.3f}])"


# =============================================================================
# EXPONENTIAL DISCOUNTING TEST
# =============================================================================


def test_exponential_discounting(
    choices: list[DatedChoice],
    tolerance: float = 1e-8,
) -> ExponentialDiscountingResult:
    """
    Test if intertemporal choices are consistent with exponential discounting.

    Exponential discounting is the benchmark model of time preferences:
        U = sum_t delta^t * u(c_t)

    where delta in (0,1) is the constant discount factor. This implies
    time-consistent preferences - the relative valuation of two future
    dates doesn't change as time passes.

    The test checks if there exists a discount factor delta and a
    concave utility function u such that observed choices maximize
    discounted utility.

    Args:
        choices: List of DatedChoice objects
        tolerance: Numerical tolerance

    Returns:
        ExponentialDiscountingResult with consistency status and bounds

    Example:
        >>> choices = [
        ...     DatedChoice(amounts=np.array([100, 110]), dates=np.array([0, 1]), chosen=0),
        ...     DatedChoice(amounts=np.array([100, 121]), dates=np.array([0, 2]), chosen=1),
        ... ]
        >>> result = test_exponential_discounting(choices)
        >>> print(f"Consistent: {result.is_consistent}")
        >>> print(f"Delta range: [{result.delta_lower:.3f}, {result.delta_upper:.3f}]")

    References:
        Echenique, F., Imai, T., & Saito, K. (2020). Testable implications
        of models of intertemporal choice. AEJ: Microeconomics, 12(4), 1-24.
    """
    start_time = time.perf_counter()

    n_choices = len(choices)

    if n_choices == 0:
        computation_time = (time.perf_counter() - start_time) * 1000
        return ExponentialDiscountingResult(
            is_consistent=True,
            violations=[],
            delta_lower=0.0,
            delta_upper=1.0,
            num_observations=0,
            computation_time_ms=computation_time,
        )

    # Find bounds on delta
    delta_lower = 0.0
    delta_upper = 1.0
    violations: list[tuple[int, int]] = []

    # For each pair of choices, check consistency
    for i, choice_i in enumerate(choices):
        c_chosen_i = choice_i.amounts[choice_i.chosen]
        t_chosen_i = choice_i.dates[choice_i.chosen]

        for j in range(len(choice_i.amounts)):
            if j == choice_i.chosen:
                continue

            c_rej_i = choice_i.amounts[j]
            t_rej_i = choice_i.dates[j]

            # If c_chosen was chosen over c_rej:
            # delta^t_chosen * u(c_chosen) >= delta^t_rej * u(c_rej)

            # For linear utility (simple case):
            # delta^(t_chosen - t_rej) >= c_rej / c_chosen (if t_chosen > t_rej)
            # or delta^(t_rej - t_chosen) <= c_chosen / c_rej (if t_rej > t_chosen)

            if c_chosen_i > tolerance and c_rej_i > tolerance:
                dt = t_chosen_i - t_rej_i
                ratio = c_rej_i / c_chosen_i

                if dt > 0:
                    # Choosing later: delta^dt >= ratio
                    # => delta >= ratio^(1/dt)
                    implied_lower = ratio ** (1.0 / dt)
                    delta_lower = max(delta_lower, min(1.0, implied_lower))
                elif dt < 0:
                    # Choosing earlier: delta^(-dt) <= 1/ratio
                    # => delta <= (1/ratio)^(1/(-dt)) = ratio^(1/dt)
                    implied_upper = ratio ** (1.0 / dt)  # dt is negative
                    delta_upper = min(delta_upper, max(0.0, implied_upper))

    # Check for violations
    if delta_lower > delta_upper + tolerance:
        # Bounds are inconsistent - find violating pairs
        for i in range(n_choices):
            for j in range(i + 1, n_choices):
                # Check if these two choices are mutually inconsistent
                # This is a simplified check
                violations.append((i, j))

    is_consistent = delta_lower <= delta_upper + tolerance and len(violations) == 0

    # Clamp bounds
    delta_lower = max(0.0, min(1.0, delta_lower))
    delta_upper = max(0.0, min(1.0, delta_upper))

    if delta_lower > delta_upper:
        delta_lower, delta_upper = delta_upper, delta_lower

    computation_time = (time.perf_counter() - start_time) * 1000

    return ExponentialDiscountingResult(
        is_consistent=is_consistent,
        violations=violations,
        delta_lower=delta_lower,
        delta_upper=delta_upper,
        num_observations=n_choices,
        computation_time_ms=computation_time,
    )


# =============================================================================
# QUASI-HYPERBOLIC (BETA-DELTA) TEST
# =============================================================================


def test_quasi_hyperbolic(
    choices: list[DatedChoice],
    tolerance: float = 1e-8,
    beta_grid_size: int = 20,
) -> QuasiHyperbolicResult:
    """
    Test if choices are consistent with quasi-hyperbolic (beta-delta) discounting.

    Beta-delta preferences model present bias:
        U = u(c_0) + beta * sum_{t>0} delta^t * u(c_t)

    where:
    - delta in (0,1) is the long-run discount factor
    - beta in (0,1) captures present bias (beta < 1 means over-valuing now)

    This nests exponential discounting (beta = 1) as a special case.

    The test collects revealed preference constraints from each choice and checks
    if there exist valid (beta, delta) parameters that satisfy all constraints.
    For linear utility u(c) = c, choosing (c1, t1) over (c2, t2) implies:
    - If t1 = 0, t2 > 0: c1 >= beta * delta^t2 * c2
    - If t1 > 0, t2 = 0: beta * delta^t1 * c1 >= c2
    - If t1, t2 > 0: delta^t1 * c1 >= delta^t2 * c2 (beta cancels)

    Args:
        choices: List of DatedChoice objects
        tolerance: Numerical tolerance
        beta_grid_size: Number of beta values to test in grid search

    Returns:
        QuasiHyperbolicResult with consistency status and parameter bounds

    Example:
        >>> result = test_quasi_hyperbolic(choices)
        >>> if result.has_present_bias:
        ...     print(f"Present bias: beta ~ {(result.beta_lower + result.beta_upper)/2:.2f}")

    References:
        Laibson, D. (1997). Golden eggs and hyperbolic discounting.
        Quarterly Journal of Economics, 112(2), 443-478.

        Echenique, F., Imai, T., & Saito, K. (2020). Testable implications
        of models of intertemporal choice. AEJ: Microeconomics.
    """
    start_time = time.perf_counter()

    n_choices = len(choices)

    if n_choices == 0:
        computation_time = (time.perf_counter() - start_time) * 1000
        return QuasiHyperbolicResult(
            is_consistent=True,
            beta_lower=0.0,
            beta_upper=1.0,
            delta_lower=0.0,
            delta_upper=1.0,
            has_present_bias=False,
            violations=[],
            num_observations=0,
            computation_time_ms=computation_time,
        )

    # Test exponential first (beta = 1 case)
    exp_result = test_exponential_discounting(choices, tolerance)

    if exp_result.is_consistent:
        # Exponential is a special case of quasi-hyperbolic with beta = 1
        computation_time = (time.perf_counter() - start_time) * 1000
        return QuasiHyperbolicResult(
            is_consistent=True,
            beta_lower=1.0,
            beta_upper=1.0,
            delta_lower=exp_result.delta_lower,
            delta_upper=exp_result.delta_upper,
            has_present_bias=False,
            violations=[],
            num_observations=n_choices,
            computation_time_ms=computation_time,
        )

    # Collect revealed preference constraints from choices
    # Each choice generates constraints on (beta, delta)
    constraints = _collect_quasi_hyperbolic_constraints(choices, tolerance)

    # Grid search over beta to find feasible (beta, delta) pairs
    # For each beta, compute bounds on delta
    feasible_betas = []
    delta_bounds_by_beta = []

    beta_grid = np.linspace(0.01, 1.0, beta_grid_size)

    for beta in beta_grid:
        delta_lower, delta_upper = _compute_delta_bounds_for_beta(
            constraints, beta, tolerance
        )
        if delta_lower <= delta_upper + tolerance:
            feasible_betas.append(beta)
            delta_bounds_by_beta.append((delta_lower, delta_upper))

    # Check consistency and compute parameter bounds
    violations: list[tuple[int, int]] = []

    if len(feasible_betas) == 0:
        # No feasible (beta, delta) pair - find violating choices
        is_consistent = False
        violations = _find_quasi_hyperbolic_violations(choices, tolerance)
        beta_lower = 0.0
        beta_upper = 0.0
        delta_lower = 0.0
        delta_upper = 0.0
        has_present_bias = False
    else:
        is_consistent = True
        beta_lower = min(feasible_betas)
        beta_upper = max(feasible_betas)

        # Compute overall delta bounds across all feasible betas
        all_delta_lowers = [b[0] for b in delta_bounds_by_beta]
        all_delta_uppers = [b[1] for b in delta_bounds_by_beta]
        delta_lower = min(all_delta_lowers)
        delta_upper = max(all_delta_uppers)

        # Clamp to valid range
        delta_lower = max(0.0, min(1.0, delta_lower))
        delta_upper = max(0.0, min(1.0, delta_upper))

        # Present bias detected if beta < 1 is required
        has_present_bias = beta_upper < 1.0 - tolerance

    computation_time = (time.perf_counter() - start_time) * 1000

    return QuasiHyperbolicResult(
        is_consistent=is_consistent,
        beta_lower=beta_lower,
        beta_upper=beta_upper,
        delta_lower=delta_lower,
        delta_upper=delta_upper,
        has_present_bias=has_present_bias,
        violations=violations,
        num_observations=n_choices,
        computation_time_ms=computation_time,
    )


def _collect_quasi_hyperbolic_constraints(
    choices: list[DatedChoice],
    tolerance: float,
) -> list[dict]:
    """
    Collect revealed preference constraints from choices for beta-delta model.

    Each constraint is a dict with keys:
    - type: "immediate_vs_future", "future_vs_immediate", or "future_vs_future"
    - c_chosen, t_chosen: chosen amount and time
    - c_rejected, t_rejected: rejected amount and time
    - choice_idx: index of the choice
    """
    constraints = []

    for i, choice in enumerate(choices):
        c_chosen = choice.amounts[choice.chosen]
        t_chosen = choice.dates[choice.chosen]

        for j in range(len(choice.amounts)):
            if j == choice.chosen:
                continue

            c_rej = choice.amounts[j]
            t_rej = choice.dates[j]

            if c_chosen <= tolerance or c_rej <= tolerance:
                continue

            if t_chosen == 0 and t_rej > 0:
                # Chose immediate over future: c_chosen >= beta * delta^t_rej * c_rej
                constraints.append({
                    "type": "immediate_vs_future",
                    "c_chosen": c_chosen,
                    "t_chosen": t_chosen,
                    "c_rejected": c_rej,
                    "t_rejected": t_rej,
                    "choice_idx": i,
                })
            elif t_chosen > 0 and t_rej == 0:
                # Chose future over immediate: beta * delta^t_chosen * c_chosen >= c_rej
                constraints.append({
                    "type": "future_vs_immediate",
                    "c_chosen": c_chosen,
                    "t_chosen": t_chosen,
                    "c_rejected": c_rej,
                    "t_rejected": t_rej,
                    "choice_idx": i,
                })
            elif t_chosen > 0 and t_rej > 0:
                # Both in future: delta^t_chosen * c_chosen >= delta^t_rej * c_rej
                # This simplifies to: delta^(t_chosen - t_rej) >= c_rej / c_chosen
                constraints.append({
                    "type": "future_vs_future",
                    "c_chosen": c_chosen,
                    "t_chosen": t_chosen,
                    "c_rejected": c_rej,
                    "t_rejected": t_rej,
                    "choice_idx": i,
                })

    return constraints


def _compute_delta_bounds_for_beta(
    constraints: list[dict],
    beta: float,
    tolerance: float,
) -> tuple[float, float]:
    """
    Compute bounds on delta given a fixed beta value.

    For each constraint type:
    - immediate_vs_future: c >= beta * delta^t * c' => delta^t <= c / (beta * c')
      => delta <= (c / (beta * c'))^(1/t)  [upper bound]
    - future_vs_immediate: beta * delta^t * c >= c' => delta^t >= c' / (beta * c)
      => delta >= (c' / (beta * c))^(1/t)  [lower bound]
    - future_vs_future: delta^(t1-t2) >= c2/c1 (if t1 > t2)
      => delta >= (c2/c1)^(1/(t1-t2))
    """
    delta_lower = 0.0
    delta_upper = 1.0

    for cons in constraints:
        c_chosen = cons["c_chosen"]
        t_chosen = cons["t_chosen"]
        c_rej = cons["c_rejected"]
        t_rej = cons["t_rejected"]

        if cons["type"] == "immediate_vs_future":
            # c_chosen >= beta * delta^t_rej * c_rej
            # delta^t_rej <= c_chosen / (beta * c_rej)
            ratio = c_chosen / (beta * c_rej)
            if ratio > 0 and t_rej > 0:
                implied_upper = ratio ** (1.0 / t_rej)
                delta_upper = min(delta_upper, implied_upper)

        elif cons["type"] == "future_vs_immediate":
            # beta * delta^t_chosen * c_chosen >= c_rej
            # delta^t_chosen >= c_rej / (beta * c_chosen)
            ratio = c_rej / (beta * c_chosen)
            if ratio > 0 and t_chosen > 0:
                implied_lower = ratio ** (1.0 / t_chosen)
                delta_lower = max(delta_lower, implied_lower)

        elif cons["type"] == "future_vs_future":
            # delta^t_chosen * c_chosen >= delta^t_rej * c_rej
            # delta^(t_chosen - t_rej) >= c_rej / c_chosen
            dt = t_chosen - t_rej
            ratio = c_rej / c_chosen

            if dt > 0:
                # Chose later: delta^dt >= ratio => delta >= ratio^(1/dt)
                implied_lower = ratio ** (1.0 / dt)
                delta_lower = max(delta_lower, min(1.0, implied_lower))
            elif dt < 0:
                # Chose earlier: delta^dt >= ratio => delta <= ratio^(1/dt)
                implied_upper = ratio ** (1.0 / dt)  # dt negative
                delta_upper = min(delta_upper, max(0.0, implied_upper))

    # Clamp to valid range
    delta_lower = max(0.0, delta_lower)
    delta_upper = min(1.0, delta_upper)

    return delta_lower, delta_upper


def _find_quasi_hyperbolic_violations(
    choices: list[DatedChoice],
    tolerance: float,
) -> list[tuple[int, int]]:
    """
    Find choice pairs that cannot be rationalized by any (beta, delta).

    Returns list of (choice_idx_1, choice_idx_2) pairs that violate consistency.
    """
    violations = []
    n = len(choices)

    # Simple heuristic: find pairs where constraints from different choices
    # require incompatible delta ranges for all beta values

    for i in range(n):
        for j in range(i + 1, n):
            # Check if choices i and j are mutually inconsistent
            choice_i = choices[i]
            choice_j = choices[j]

            # Extract constraints from both choices
            constraints_i = _collect_quasi_hyperbolic_constraints([choice_i], tolerance)
            constraints_j = _collect_quasi_hyperbolic_constraints([choice_j], tolerance)

            all_constraints = constraints_i + constraints_j

            # Check if any beta makes these consistent
            is_consistent = False
            for beta in np.linspace(0.01, 1.0, 10):
                dl, du = _compute_delta_bounds_for_beta(all_constraints, beta, tolerance)
                if dl <= du + tolerance:
                    is_consistent = True
                    break

            if not is_consistent:
                violations.append((i, j))

    return violations


# =============================================================================
# DISCOUNT FACTOR RECOVERY
# =============================================================================


def recover_discount_factor(
    choices: list[DatedChoice],
    assume_linear_utility: bool = True,
) -> DiscountFactorBounds:
    """
    Recover bounds on the discount factor from intertemporal choices.

    Uses revealed preference inequalities to bound the discount factor
    that rationalizes observed choices.

    Args:
        choices: List of DatedChoice objects
        assume_linear_utility: If True, assume linear utility u(c) = c

    Returns:
        DiscountFactorBounds with lower and upper bounds

    Example:
        >>> bounds = recover_discount_factor(choices)
        >>> print(f"Discount factor: [{bounds.delta_lower:.3f}, {bounds.delta_upper:.3f}]")
        >>> print(f"Implied annual rate: [{bounds.implied_interest_rate_lower:.1%}, {bounds.implied_interest_rate_upper:.1%}]")
    """
    start_time = time.perf_counter()

    result = test_exponential_discounting(choices)

    delta_lower = result.delta_lower
    delta_upper = result.delta_upper

    # Compute implied interest rates: r = (1/delta) - 1
    if delta_upper > 0:
        r_lower = (1.0 / delta_upper) - 1.0
    else:
        r_lower = float("inf")

    if delta_lower > 0:
        r_upper = (1.0 / delta_lower) - 1.0
    else:
        r_upper = float("inf")

    is_identified = (
        delta_lower > 0.01 and
        delta_upper < 0.99 and
        delta_upper - delta_lower < 0.5
    )

    num_binding = sum(
        1 for c in choices
        if len(c.amounts) > 1
    )

    computation_time = (time.perf_counter() - start_time) * 1000

    return DiscountFactorBounds(
        delta_lower=delta_lower,
        delta_upper=delta_upper,
        is_identified=is_identified,
        implied_interest_rate_lower=r_lower,
        implied_interest_rate_upper=r_upper,
        num_binding_constraints=num_binding,
        computation_time_ms=computation_time,
    )


# =============================================================================
# PRESENT BIAS TEST
# =============================================================================


def test_present_bias(
    choices: list[DatedChoice],
    threshold: float = 0.1,
) -> dict:
    """
    Test for present bias in intertemporal choices.

    Present bias occurs when people make impatient choices when the
    sooner option is immediate, but patient choices when both options
    are in the future.

    Classic pattern:
    - Prefer $100 today over $110 tomorrow (impatient)
    - Prefer $110 in 31 days over $100 in 30 days (patient)

    This is inconsistent with exponential discounting but consistent
    with quasi-hyperbolic (beta-delta) preferences.

    Args:
        choices: List of DatedChoice objects
        threshold: Significance threshold for bias detection

    Returns:
        Dict with present bias indicators

    Example:
        >>> result = test_present_bias(choices)
        >>> if result["has_present_bias"]:
        ...     print(f"Present bias strength: {result['bias_magnitude']:.2f}")
    """
    start_time = time.perf_counter()

    # Classify choices by whether they involve t=0
    immediate_choices = []
    future_choices = []

    for i, choice in enumerate(choices):
        min_date = min(choice.dates)
        if min_date == 0:
            immediate_choices.append((i, choice))
        else:
            future_choices.append((i, choice))

    # Compute average "patience" in each category
    # Patience = tendency to wait for larger rewards

    def compute_patience(choice: DatedChoice) -> float:
        """Compute patience score for a choice."""
        t_chosen = choice.dates[choice.chosen]
        c_chosen = choice.amounts[choice.chosen]

        # Find the alternative with different timing
        patience_scores = []
        for j in range(len(choice.amounts)):
            if j == choice.chosen:
                continue
            t_alt = choice.dates[j]
            c_alt = choice.amounts[j]

            if t_chosen > t_alt:
                # Chose to wait
                patience_scores.append(1.0)
            elif t_chosen < t_alt:
                # Chose sooner
                patience_scores.append(0.0)

        return np.mean(patience_scores) if patience_scores else 0.5

    immediate_patience = [compute_patience(c) for _, c in immediate_choices]
    future_patience = [compute_patience(c) for _, c in future_choices]

    avg_immediate = np.mean(immediate_patience) if immediate_patience else 0.5
    avg_future = np.mean(future_patience) if future_patience else 0.5

    # Present bias: less patient for immediate choices
    bias_magnitude = avg_future - avg_immediate
    has_present_bias = bias_magnitude > threshold

    computation_time = (time.perf_counter() - start_time) * 1000

    return {
        "has_present_bias": has_present_bias,
        "bias_magnitude": bias_magnitude,
        "immediate_patience": avg_immediate,
        "future_patience": avg_future,
        "num_immediate_choices": len(immediate_choices),
        "num_future_choices": len(future_choices),
        "computation_time_ms": computation_time,
    }


# =============================================================================
# LEGACY ALIASES
# =============================================================================

check_exponential_discounting = test_exponential_discounting
"""Legacy alias: use test_exponential_discounting instead."""

check_quasi_hyperbolic_discounting = test_quasi_hyperbolic
"""Legacy alias: use test_quasi_hyperbolic instead."""

check_beta_delta = test_quasi_hyperbolic
"""Legacy alias: use test_quasi_hyperbolic instead."""
