"""
binding_locus_check.py - empirical check on weak-form path (c) for V.5.2.

irreducible_floor.py uses a log-barrier on Q as a pragmatic stand-in for the
structural quality ceiling. This script tests the substantive commitment the
weak-form path (c) actually wants:

    Q <= Q_legit - delta(tau)

with delta convex, delta(0) = 0. Substrate's quality is bounded above by what
remains after rent extraction degrades it; within that bound substrate still
chooses Q (so the existing Module 2 comparative-statics geometry survives where
the constraint is slack).

The script answers two questions:

  (1) In the chapter's region of interest, does the constraint bind at
      substrate's optimum, or is it typically slack?

  (2) The "Floor is an interval on the binding locus" framing of V.5.2 is
      valid only where the constraint binds. Where it is slack, substrate's
      optimum sits strictly inside the constraint set; the Floor is still
      a point with tau* > 0 and Q* < 1, but it is not on the locus.

delta(tau) = kappa * tau^alpha with alpha in {1.5, 2.0, 2.5} (convex range
implied by the substantive theory: deviations from legitimate behavior compound)
and kappa as a steepness sensitivity.

Run: uv run python binding_locus_check.py
"""

import numpy as np
from scipy import optimize, stats

from _helpers import banner


def substrate_payoff(x, V_rent, chi, sigma, tau_c0, gamma, delta_S, V_cont):
    """Negative of substrate's per-period Bellman objective.
    No log-barrier on Q -- the structural ceiling enters as a constraint."""
    tau, Q = x
    Pi = tau * V_rent * (1.0 - chi * Q)
    tau_c = tau_c0 + gamma * Q
    r = stats.norm.cdf((tau - tau_c) / sigma)
    F = Pi * (1.0 - r) + delta_S * (1.0 - r) * V_cont
    return -F


def quality_ceiling(tau, Q_legit, kappa, alpha):
    """Structural quality ceiling: Q_legit - delta(tau) with delta(tau) = kappa * tau^alpha."""
    return Q_legit - kappa * tau ** alpha


def solve_constrained(V_rent=1.0, chi=0.40, sigma=0.10, tau_c0=0.15, gamma=0.20,
                      delta_S=0.90, V_cont=1.00, Q_legit=1.0, kappa=1.0, alpha=2.0):
    """Solve substrate's optimization under Q <= Q_legit - kappa*tau^alpha."""
    cons = {
        "type": "ineq",
        "fun":  lambda x: quality_ceiling(x[0], Q_legit, kappa, alpha) - x[1],
    }
    res = optimize.minimize(
        substrate_payoff,
        x0=[0.10, 0.50],
        args=(V_rent, chi, sigma, tau_c0, gamma, delta_S, V_cont),
        bounds=[(1e-6, 5.0), (1e-6, Q_legit - 1e-6)],
        constraints=[cons],
        method="SLSQP",
    )
    tau_s, Q_s = res.x[0], res.x[1]
    ceil_at_opt = quality_ceiling(tau_s, Q_legit, kappa, alpha)
    binding = abs(ceil_at_opt - Q_s) < 1e-4
    return tau_s, Q_s, binding, res.success


def main():
    banner("Binding-locus check (weak-form path (c) for V.5.2)")

    central = dict(V_rent=1.0, chi=0.40, sigma=0.10, tau_c0=0.15, gamma=0.20,
                   delta_S=0.90, V_cont=1.00, Q_legit=1.0)

    # ---- Part 1: central calibration under each (alpha, kappa) ----
    banner("(1) Central calibration: where does the optimum sit?")
    print(f"\n  Central calibration:")
    for k, v in central.items():
        print(f"      {k:<8s} = {v}")
    print()
    print(f"  Constraint: Q <= {central['Q_legit']} - kappa * tau^alpha")
    print()
    print(f"  {'alpha':>5} {'kappa':>5}   {'tau*':>8} {'Q*':>8} {'Q_ceil':>8} {'binding?':>10}")
    for alpha in [1.5, 2.0, 2.5]:
        for kappa in [0.5, 1.0, 2.0, 4.0]:
            tau, Q, binding, ok = solve_constrained(
                **central, kappa=kappa, alpha=alpha)
            ceil = quality_ceiling(tau, 1.0, kappa, alpha)
            mark = "YES" if binding else "no"
            print(f"  {alpha:5.1f} {kappa:5.1f}   {tau:8.4f} {Q:8.4f} {ceil:8.4f} {mark:>10s}")

    # ---- Part 2: parameter sweep, binding frequency ----
    banner("(2) Parameter sweep: binding frequency in chapter's region of interest")

    sweep_params = {
        "chi":     np.linspace(0.20, 0.80, 7),
        "sigma":   np.linspace(0.03, 0.20, 8),
        "tau_c0":  np.linspace(0.05, 0.30, 8),
        "gamma":   np.linspace(0.10, 0.60, 8),
        "delta_S": np.linspace(0.50, 0.99, 8),
        "V_cont":  np.linspace(0.20, 3.00, 8),
    }

    print(f"\n  For each (alpha, kappa), count sweep points where the")
    print(f"  constraint binds. Region matches irreducible_floor.py's test (b).")
    print()
    print(f"  {'alpha':>5} {'kappa':>5}   {'binding/total':>14} {'fraction':>10}")
    summary = {}
    for alpha in [1.5, 2.0, 2.5]:
        for kappa in [0.5, 1.0, 2.0, 4.0]:
            n_bind, n_tot = 0, 0
            tb, ts, qb, qs = [], [], [], []
            for pname, vals in sweep_params.items():
                for v in vals:
                    calib = dict(central); calib[pname] = float(v)
                    tau, Q, binding, ok = solve_constrained(
                        **calib, kappa=kappa, alpha=alpha)
                    if not ok:
                        continue
                    n_tot += 1
                    if binding:
                        n_bind += 1
                        tb.append(tau); qb.append(Q)
                    else:
                        ts.append(tau); qs.append(Q)
            frac = n_bind / max(n_tot, 1)
            print(f"  {alpha:5.1f} {kappa:5.1f}   {n_bind:>5d}/{n_tot:<5d}   {frac:10.2%}")
            summary[(alpha, kappa)] = (n_bind, n_tot, tb, ts, qb, qs)

    # ---- Part 3: regime characterization ----
    banner("(3) Floor distribution: binding vs. slack regimes")

    print(f"\n  (tau*, Q*) distribution split by regime, for representative")
    print(f"  (alpha, kappa) settings.")
    print()
    for alpha, kappa in [(2.0, 1.0), (2.0, 2.0), (1.5, 1.0), (2.5, 1.0)]:
        n_bind, n_tot, tb, ts, qb, qs = summary[(alpha, kappa)]
        print(f"  alpha={alpha}, kappa={kappa}:")
        print(f"    binding sweep points (n={n_bind}):")
        if tb:
            print(f"      tau* in [{min(tb):.4f}, {max(tb):.4f}], mean {np.mean(tb):.4f}")
            print(f"      Q*   in [{min(qb):.4f}, {max(qb):.4f}], mean {np.mean(qb):.4f}")
        else:
            print(f"      (none)")
        print(f"    slack sweep points (n={n_tot - n_bind}):")
        if ts:
            print(f"      tau* in [{min(ts):.4f}, {max(ts):.4f}], mean {np.mean(ts):.4f}")
            print(f"      Q*   in [{min(qs):.4f}, {max(qs):.4f}], mean {np.mean(qs):.4f}")
        else:
            print(f"      (none)")
        print()

    # ---- Part 4: per-parameter binding pattern ----
    banner("(4) Which parameters drive the binding/slack distinction?")

    print(f"\n  For alpha=2.0, kappa=1.0: report binding status as each parameter")
    print(f"  varies across its sweep range, holding others at central.")
    print()
    alpha, kappa = 2.0, 1.0
    for pname, vals in sweep_params.items():
        bind_at, slack_at = [], []
        for v in vals:
            calib = dict(central); calib[pname] = float(v)
            tau, Q, binding, ok = solve_constrained(
                **calib, kappa=kappa, alpha=alpha)
            if not ok:
                continue
            (bind_at if binding else slack_at).append(v)
        print(f"  {pname:<8s}: binding at {len(bind_at)}/{len(vals)} points; "
              f"slack at {len(slack_at)}/{len(vals)}")
        if bind_at and slack_at:
            print(f"           binding range: [{min(bind_at):.3f}, {max(bind_at):.3f}]")
            print(f"           slack range:   [{min(slack_at):.3f}, {max(slack_at):.3f}]")

    # ---- Part 5: structural claim Q* < 1 in both regimes ----
    banner("(5) Q* < 1 strict across both regimes")

    print(f"\n  The Floor result Q* < 1 must hold in both regimes for the chapter's")
    print(f"  central claim to survive. Verify across all (alpha, kappa, sweep).")
    print()
    violations = []
    Q_max_overall = -float("inf")
    n_check = 0
    for (alpha, kappa), (_, _, tb, ts, qb, qs) in summary.items():
        for tau, Q in zip(tb + ts, qb + qs):
            n_check += 1
            Q_max_overall = max(Q_max_overall, Q)
            if Q >= 1.0 - 1e-4:
                violations.append((alpha, kappa, tau, Q))
    print(f"  Checked {n_check} sweep points across all (alpha, kappa).")
    print(f"  Maximum Q* observed: {Q_max_overall:.6f}")
    print(f"  Violations of Q* < 1 (Q >= 0.9999): {len(violations)}")
    assert not violations, f"Q* >= 1 at {len(violations)} points"
    print(f"  [pass] Q* < 1 strictly across binding AND slack regimes")

    # ---- Part 6: implications for V.5.2 framing ----
    banner("(6) Implications for V.5.2")

    print("""
  Summary of findings (read off Parts 2-5):

  - Structural claim Q* < 1: holds across binding AND slack regimes, all
    (alpha, kappa) settings, all sweep points. The weak-form constraint plus
    interior optimization between them deliver Q_floor < 1 unconditionally
    on the parameter region tested. The asymmetry exposed by the log-barrier
    verification is fully closed.

  - Binding-locus framing of V.5.2 ("the Floor is an interval on the binding
    locus Q = Q_legit - delta(tau)"): applies where the constraint binds. The
    Part 2 sweep table shows under what (alpha, kappa, parameter) combinations
    the constraint binds. Where the table shows mixed regimes, V.5.2 needs
    language that covers both:
      * binding case: Floor on the locus Q = Q_legit - delta(tau);
      * slack case:   Floor strictly inside the constraint set, Q < Q_legit
                      - delta(tau), determined by the interior FOCs.
    Where the table shows binding at all swept points (likely at higher kappa
    and higher alpha), V.5.2 can use the binding-locus framing without
    qualification.

  - Which parameters drive binding (Part 4): which calibration dimensions move
    substrate on or off the locus. This also bears on the diversity-detection
    reinforcement claim: heterogeneous audiences with different Q-thresholds
    sample the locus at different (tau, Q) points where binding holds, and
    sample the slack interior where it does not.
""")


if __name__ == "__main__":
    main()
