#!/usr/bin/env python3
"""Hands-on P09 — a recsys simulator, assembled from seven lego blocks."""
import numpy as np
from _harness import block, run_all
NU, NI, D, T = 400, 600, 6, 60          # users, items, latent dim, days

@block(1, "A user model you can interrogate", "the simulator's assumptions ARE its results")
def b1(s, show):
    rng = np.random.default_rng(9)
    U = rng.normal(0, 1, (NU, D)); V = rng.normal(0, 1, (NI, D))
    quality = rng.normal(0, 0.6, NI)
    util = U @ V.T + quality                       # true utility of item i to user u
    def click(u, ranked, rng, pos_bias=True):
        """Examine top-k with position-dependent probability; click if utility wins."""
        clicks = []
        for r, i in enumerate(ranked):
            exam = 1.0 / (1 + r) ** 1.0 if pos_bias else 1.0
            if rng.random() < exam and rng.random() < 1 / (1 + np.exp(-util[u, i])):
                clicks.append(i)
        return clicks
    if show:
        print(f"  {NU} users x {NI} items, utility = <u,v> + item quality")
        print(f"  utility spread: p10={np.quantile(util,.1):+.2f}  "
              f"p50={np.quantile(util,.5):+.2f}  p90={np.quantile(util,.9):+.2f}")
        print(f"  examination P(look at rank r) = 1/(1+r): "
              f"rank0={1.0:.2f} rank4={1/5:.2f} rank19={1/20:.3f}")
        print("  Every number this simulator later produces is a consequence of these")
        print("  three lines. State them at the top of the report, not in an appendix:")
        print("  a simulator is an argument, and these are its premises.")
    return {"util": util, "click": click, "quality": quality}

@block(2, "Position bias makes logs lie", "the log measures the ranker, not the user")
def b2(s, show):
    rng = np.random.default_rng(10)
    util = s["util"]
    if show:
        ctr_by_rank = np.zeros(20); shown = np.zeros(20)
        for u in range(NU):
            order = rng.permutation(NI)[:20]          # RANDOM ranking: no confound
            cl = set(s["click"](u, order, rng))
            for r, i in enumerate(order):
                shown[r] += 1; ctr_by_rank[r] += i in cl
        obs = ctr_by_rank / shown
        print("  Serving a RANDOM ranking, so item quality is independent of position:")
        print(f"  {'rank':>6}{'observed CTR':>15}{'1/(1+r) prediction':>21}")
        for r in (0, 1, 4, 9, 19):
            print(f"  {r:>6}{obs[r]:>15.4f}{obs[0]/(1+r):>21.4f}")
        print(f"  CTR at rank 0 is {obs[0]/max(obs[19],1e-9):.1f}x rank 19 for items chosen")
        print("  UNIFORMLY AT RANDOM. Naively training on click logs teaches the model")
        print("  'items at rank 0 are good', which is a fact about the old ranker.")
    return {}

@block(3, "Inverse propensity scoring", "divide out the mechanism you know")
def b3(s, show):
    rng = np.random.default_rng(11)
    if show:
        true_rate = np.zeros(NI); naive = np.zeros(NI); ips = np.zeros(NI)
        shown = np.zeros(NI)
        for u in range(NU):
            order = rng.permutation(NI)[:20]
            cl = set(s["click"](u, order, rng))
            for r, i in enumerate(order):
                p = 1.0 / (1 + r)                       # KNOWN propensity
                shown[i] += 1
                naive[i] += i in cl
                ips[i] += (i in cl) / p
                true_rate[i] += 1 / (1 + np.exp(-s["util"][u, i]))
        m = shown > 8
        def corr(a, b): return float(np.corrcoef(a[m] / shown[m], b[m] / shown[m])[0, 1])
        print(f"  correlation with true click propensity, over {int(m.sum())} items:")
        print(f"    naive CTR estimate      r = {corr(naive, true_rate):.4f}")
        print(f"    IPS-corrected estimate  r = {corr(ips, true_rate):.4f}")
        print("  IPS is unbiased when the propensity is known exactly -- which is true")
        print("  in a simulator and never true in production. There you estimate the")
        print("  propensity, and the variance of 1/p_hat at small p_hat is what")
        print("  destroys the estimator. Clipping p at 0.01-0.1 is the standard trade:")
        print("  accept a little bias to stop the variance from exploding.")
    return {}

@block(4, "Feedback loops", "the ranker trains on data the ranker created")
def b4(s, show):
    def simulate(policy, days=T, explore=0.0, seed=12, k=10, warmup=2, order_fn=None):
        """warmup days of RANDOM serving seed the estimates; without it every
        item ties at CTR 0 and 'greedy' just locks onto item ids 0..k-1, which
        would make the feedback loop look like an artefact of argsort."""
        rng = np.random.default_rng(seed)
        clicks = np.zeros(NI); impr = np.ones(NI)
        hist = []
        for d in range(days):
            served, got = np.zeros(NI), 0
            for u in range(NU):
                if d < warmup or (explore and rng.random() < explore):
                    order = rng.permutation(NI)[:k]
                elif order_fn is not None:
                    order = order_fn(clicks, impr, rng, k)
                else:
                    order = np.argsort(-policy(clicks, impr, rng))[:k]
                cl = s["click"](u, order, rng)
                for i in order: impr[i] += 1; served[i] += 1
                for i in cl: clicks[i] += 1
                got += len(cl)
            hist.append((got / NU, int((served > 0).sum())))
        return hist, clicks, impr
    greedy = lambda c, im, rng: c / im
    if show:
        h, c, im = simulate(greedy)
        print(f"  2 days of random serving, then greedy 'rank by observed CTR':")
        print(f"  {'day':>5}{'clicks/user':>13}{'distinct items shown':>22}")
        for d in (0, 1, 2, 4, 19, 39, T-1):
            tag = "  <- random warm-up" if d < 2 else ""
            print(f"  {d:>5}{h[d][0]:>13.3f}{h[d][1]:>22}{tag}")
        print(f"  catalogue collapsed from {h[1][1]} items on day 2 to {h[-1][1]} "
              f"on day {T} ({h[1][1]//max(h[-1][1],1)}x narrower)")
        print("  Nothing broke. Every step was locally optimal: show what performed")
        print("  well, observe it perform well, show it more. The feedback loop is")
        print("  not a bug in the policy -- it is the policy, iterated.")
        print("  Note the width is NOT monotone (185 -> 300 -> 313 -> 90). Straight")
        print("  after warm-up, greedy chases items whose CTR was over-estimated by")
        print("  noise; as they accumulate impressions their estimates regress and")
        print("  other items overtake them, so the served set churns before it")
        print("  freezes. That is the winner's curse, visible as a bump in a width")
        print("  plot -- and it is why 'the ranking looks unstable' early in a launch")
        print("  is expected rather than alarming.")
    return {"simulate": simulate, "greedy": greedy}

@block(5, "Exploration as insurance", "epsilon buys catalogue coverage with clicks")
def b5(s, show):
    if show:
        print(f"  {'policy':<28}{'clicks/user d60':>17}{'distinct items':>16}"
              f"{'cumulative':>12}")
        rows = []
        for lbl, ex in (("greedy (eps=0)", 0.0), ("eps=0.02", 0.02),
                        ("eps=0.10", 0.10), ("eps=0.30", 0.30)):
            h, c, im = s["simulate"](s["greedy"], explore=ex)
            cum = sum(x for x, _ in h)
            rows.append((lbl, h[-1][0], h[-1][1], cum))
            print(f"  {lbl:<28}{h[-1][0]:>17.3f}{h[-1][1]:>16}{cum:>12.1f}")
        best = max(rows, key=lambda r: r[3])
        print(f"  highest cumulative clicks: {best[0]}")
        print("  Exploration is NOT a pure cost here. eps=0.02 beats pure greedy on")
        print("  BOTH axes -- more clicks (126.8 vs 120.5) and a live estimate for")
        print("  items greedy abandoned. The cost only appears further along the")
        print("  curve: eps=0.30 gives up 6% of clicks to keep 522 items alive.")
        print("  The optimum is interior, so it has to be found by measurement; both")
        print("  'exploration is overhead' and 'more exploration is safer' are wrong.")
    return {}

@block(6, "When the bandit loses", "a prediction, a test, and a policy that fixes it")
def b6(s, show):
    def thompson(c, im, rng):
        return rng.beta(1 + c, 1 + np.maximum(im - c, 0))
    def ucb(c, im, rng):
        return c / im + np.sqrt(2 * np.log(max(im.sum(), 2)) / im)
    def slot_aware(c, im, rng, k):
        """Exploit the top slot; let Thompson have the rest."""
        ts = np.argsort(-rng.beta(1 + c, 1 + np.maximum(im - c, 0)))
        best = int(np.argmax(c / im))
        return [best] + [int(i) for i in ts if i != best][:k - 1]
    if show:
        print(f"  {'policy':<38}{'cum clicks/user':>17}{'distinct':>10}")
        for lbl, pol, ex in (("greedy", s["greedy"], 0.0),
                             ("eps-greedy 0.02", s["greedy"], 0.02),
                             ("UCB1", ucb, 0.0),
                             ("Thompson sampling", thompson, 0.0)):
            h, _, _ = s["simulate"](pol, explore=ex)
            print(f"  {lbl:<38}{sum(x for x,_ in h):>17.1f}{h[-1][1]:>10}")
        print("  The bandits LOSE, badly, and the textbook answer ('Thompson beats")
        print("  epsilon-greedy') does not survive contact with this environment.\n")
        ex = np.array([1 / (1 + r) for r in range(10)])
        print("  HYPOTHESIS: examination is 1/(1+r), so attention is concentrated:")
        print("    share by rank: " + "  ".join(f"{x:.0%}" for x in ex / ex.sum()))
        print(f"    rank 0 alone carries {ex[0]/ex.sum():.0%} of all examination.")
        print("  Thompson randomises ALL TEN slots, so it spends its most valuable")
        print("  slot on an uncertain item every single impression.")
        print("  PREDICTION: explore only in ranks 1-9 and most of the lost clicks")
        print("  come back, while coverage stays near Thompson's.\n")
        h, _, _ = s["simulate"](None, order_fn=slot_aware)
        g, _, _ = s["simulate"](s["greedy"])
        ts, _, _ = s["simulate"](thompson)
        cg, ct, ch = (sum(x for x,_ in z) for z in (g, ts, h))
        print(f"  {'Thompson in ranks 1-9, greedy at rank 0':<38}{ch:>17.1f}"
              f"{h[-1][1]:>10}")
        print(f"  VERDICT: confirmed. {ch/cg:.0%} of greedy's clicks "
              f"(vs {ct/cg:.0%} for full Thompson),")
        print(f"  with {h[-1][1]} distinct items against Thompson's {ts[-1][1]}. The exploration")
        print("  budget was never the problem -- WHERE it was spent was. This is why")
        print("  production rankers explore in the tail of the slate and why a bandit")
        print("  benchmarked without a position model reports the wrong winner.")
    return {"thompson": thompson, "ucb": ucb, "slot_aware": slot_aware}

@block(7, "The counterfactual question", "a simulator's only real job")
def b7(s, show):
    if show:
        print("  A/B tests answer 'which of these two shipped policies wins?'.")
        print("  Simulators answer 'what would have happened under a policy nobody")
        print("  ran?' -- and that answer is only as good as the user model.\n")
        print(f"  {'user model perturbation':<34}{'greedy':>9}{'Thompson':>11}"
              f"{'winner':>10}{'margin':>9}")
        base = None
        for lbl, mult in (("as specified", 1.0), ("position bias 2x steeper", 2.0),
                          ("position bias flat (no bias)", 0.0)):
            orig = s["click"]
            def click(u, ranked, rng, _m=mult):
                out = []
                for r, i in enumerate(ranked):
                    exam = 1.0 if _m == 0 else 1.0 / (1 + r) ** _m
                    if rng.random() < exam and rng.random() < 1/(1+np.exp(-s["util"][u,i])):
                        out.append(i)
                return out
            s["click"] = click
            g = sum(x for x, _ in s["simulate"](s["greedy"])[0])
            t = sum(x for x, _ in s["simulate"](s["thompson"])[0])
            s["click"] = orig
            print(f"  {lbl:<34}{g:>9.1f}{t:>11.1f}"
                  f"{('Thompson' if t > g else 'greedy'):>10}"
                  f"{max(g,t)/min(g,t)-1:>8.0%}")
        print("  The WINNER is stable across all three user models even though the")
        print("  absolute numbers move by 7x. That is the claim a simulator can")
        print("  support: ordinal, not cardinal. Quote 120.5 clicks/user to a")
        print("  stakeholder and you are quoting your own assumptions back at them.")
        print("  But read the margin column, because it is doing more work than the")
        print("  winner column. Greedy's edge is 23% under the specified bias, 20%")
        print("  when the bias doubles -- and 1% when it is removed entirely. That is")
        print("  an INDEPENDENT confirmation of block 6: greedy wins here because")
        print("  exploration is expensive at rank 0, so deleting position bias very")
        print("  nearly deletes greedy's advantage. Two blocks, two methods, one")
        print("  mechanism. A sensitivity table is not defensive paperwork; it is")
        print("  where the causal claim actually gets tested.")
    return {}

def assembly(s):
    print("\nSeven blocks = a simulator. Run four policies for 60 days.\n")
    print(f"  {'policy':<24}{'cum clicks/user':>17}{'day-60 rate':>13}"
          f"{'distinct items':>16}{'gini':>7}")
    def gini(x):
        x = np.sort(x[x >= 0]); n = len(x)
        return float((2*np.arange(1, n+1) - n - 1) @ x / (n * max(x.sum(), 1e-9)))
    for lbl, pol, ex in (("greedy", s["greedy"], 0.0),
                         ("eps-greedy 0.02", s["greedy"], 0.02),
                         ("eps-greedy 0.10", s["greedy"], 0.10),
                         ("UCB1", s["ucb"], 0.0),
                         ("Thompson", s["thompson"], 0.0)):
        h, c, im = s["simulate"](pol, explore=ex)
        print(f"  {lbl:<24}{sum(x for x,_ in h):>17.1f}{h[-1][0]:>13.3f}"
              f"{h[-1][1]:>16}{gini(im):>7.3f}")
    h, c, im = s["simulate"](None, order_fn=s["slot_aware"])
    print(f"  {'Thompson, ranks 1-9':<24}{sum(x for x,_ in h):>17.1f}{h[-1][0]:>13.3f}"
          f"{h[-1][1]:>16}{gini(im):>7.3f}")
    print("\n  The gini column is exposure inequality across the catalogue: 0 = every")
    print("  item shown equally, 1 = one item takes everything. Read the two ends")
    print("  first: greedy earns the most clicks of the pure policies and starves the")
    print("  catalogue (gini 0.91, 96 items alive); UCB1 keeps all 600 items alive and")
    print("  pays 16% of the clicks for it. There is no free lunch on that axis.")
    print("  The interesting rows are the two in between. eps=0.02 beats greedy on")
    print("  clicks AND on coverage -- a strict improvement, available for free. And")
    print("  the last row buys 5.5x greedy's catalogue for 7% of its clicks, because")
    print("  it explores in the slots nobody looks at. Exposure fairness is not")
    print("  bought at a fixed exchange rate; the rate depends on where you spend.")
    print("  None of this is measurable with an A/B test, because no one runs a")
    print("  knowingly worse policy for sixty days to find the shape of a curve.")
    print("\n  Built: user model -> position bias -> IPS -> feedback loop -> epsilon ->")
    print("  Thompson/UCB -> counterfactual sensitivity.")
    print("  Missing, on the project page: user arrival and churn dynamics (m4),")
    print("  a two-sided marketplace with supplier utility (m8), off-policy")
    print("  evaluation against logged data with estimated propensities (m9-m10),")
    print("  and E6 -- the calibration experiment where you fit the simulator to a")
    print("  real log and check whether it reproduces a held-out week.")

if __name__ == "__main__":
    run_all(assembly, "HANDS-ON P09 — Recsys simulation, block by block")
