#!/usr/bin/env bash
# ===========================================================================
# lmcache-retention-test.sh — prove L1 *retention*, not just warm-cache-works.
# ---------------------------------------------------------------------------
# scripts/bench.sh's PREFILL_PROBE salts every request FRESH, so it measures
# COLD prefill and (by re-sending) the immediate warm hit — but it never fills
# past L1 capacity, so it can't show what a BIGGER L1 buys. That "bigger L1"
# question is the whole reason to spend host RAM on LMCACHE_L1_GB.
#
# This wrapper closes that gap. It inserts N distinct large prefixes whose
# combined KV exceeds a small L1 but fits a large one, then re-reads them:
#
#   Round 1 (cold insert):  send N distinct ~SESSION_TOKENS prefixes once each,
#                           small max_tokens so each request COMPLETES (LMCache
#                           only banks a session's blocks on completion — see
#                           lmcache.yml "run to COMPLETION" note). Fills L1.
#   Round 2 (retention read): re-send the SAME N prefixes in the SAME order.
#                           Session 1 is the LRU-oldest, so Round-2 TTFT per
#                           session IS the retention curve:
#                             - L1 big enough  -> every session WARM (retained)
#                             - L1 too small   -> earliest sessions COLD (evicted)
#
# PASS = all N warm in Round 2. On THIS rig (LMCACHE_L1_GB=60) a set sized
# between the 30 GB and 60 GB capacity lines should be 8/8 warm — and would
# have evicted its earliest sessions at the default L1=30.
#
# Prereq: the LMCache compose is serving (vllm/qwen-27b-dual-lmcache). Bring it
# up with scripts/switch.sh first; confirm the served id at /v1/models.
#
# Env vars (all optional):
#   PORT               Endpoint port. Default: 8017 (the slug's default_port)
#   URL                Full base URL. Default: http://localhost:${PORT}
#   MODEL              Served model id. Default: qwen3.6-27b
#                      (a WRONG id is a silent 404 — confirm via /v1/models)
#   NUM_SESSIONS       Distinct prefixes to insert. Default: 8
#   SESSION_TOKENS     Approx prompt tokens per prefix. Default: 48000
#                      (8 x 48K ~= 384K tok ~= 50 GB @ 131 KB/tok: inside a
#                       60 GB L1, past a 30 GB L1 — the discriminating window)
#   WARM_THRESHOLD_S   TTFT at/below this = warm hit. Default: 8.0
#                      (L1 rehydrate is sub-second..~few s per #423; cold
#                       re-prefill is ~35-45 s — 8 s cleanly separates them)
#   KV_KB_PER_TOKEN    LMCache cache rate for the GB math. Default: 131 (measured)
#   PROBE_MAX_TOKENS   Output cap per request (just enough to complete). Default: 8
#   SALT               Mixes into every prefix's seed + anchor so a re-run inserts
#                      BYTE-DIFFERENT prefixes -> fresh cache keys that cannot hit
#                      KV left on L2 disk by a prior run. Default: "" (the original
#                      deterministic prefixes). Give each A/B leg a distinct SALT so
#                      the two legs never read each other's (or old) cached blocks.
#
# Exit 0 = all sessions retained (PASS). Exit 1 = one or more evicted.
# ===========================================================================
set -euo pipefail

PORT="${PORT:-8017}"
URL="${URL:-http://localhost:${PORT}}"
MODEL="${MODEL:-qwen3.6-27b}"
NUM_SESSIONS="${NUM_SESSIONS:-8}"
SESSION_TOKENS="${SESSION_TOKENS:-48000}"
WARM_THRESHOLD_S="${WARM_THRESHOLD_S:-8.0}"
KV_KB_PER_TOKEN="${KV_KB_PER_TOKEN:-131}"
PROBE_MAX_TOKENS="${PROBE_MAX_TOKENS:-8}"
SALT="${SALT:-}"

command -v python3 >/dev/null || { echo "Fix: python3 not found on PATH." >&2; exit 2; }

python3 - "$URL" "$MODEL" "$NUM_SESSIONS" "$SESSION_TOKENS" \
           "$WARM_THRESHOLD_S" "$KV_KB_PER_TOKEN" "$PROBE_MAX_TOKENS" "$SALT" <<'PY'
import json, random, string, sys, time, urllib.request, zlib
# Community rigs run non-UTF-8 locales; a piped stdout defaults to ASCII (repo
# convention: pin utf-8 on both read and write).
sys.stdout.reconfigure(encoding="utf-8")

URL, MODEL, N, STOK, WARM, KVKB, PMAX, SALT = sys.argv[1:]
N = int(N); STOK = int(STOK); WARM = float(WARM); KVKB = float(KVKB); PMAX = int(PMAX)
# Stable integer offset derived from SALT (crc32 is process-independent, unlike
# builtin hash() under PYTHONHASHSEED). Empty SALT -> 0 -> original prefixes.
SALT_OFF = (zlib.crc32(SALT.encode()) % 1_000_000) if SALT else 0


def tokenize_count(text):
    """Exact prompt-token count via vLLM's /tokenize; None if unavailable."""
    for path in ("/tokenize", "/v1/tokenize"):
        try:
            body = json.dumps({"model": MODEL, "prompt": text}).encode()
            req = urllib.request.Request(URL + path, data=body,
                                         headers={"Content-Type": "application/json"})
            with urllib.request.urlopen(req, timeout=120) as r:
                return json.load(r).get("count")
        except Exception:
            continue
    return None


def rand_words(rng, n):
    # Seeded random words tokenize near ~1 token/word and do NOT BPE-compress
    # the way repeated filler does — so the prefix actually fills L1.
    return [''.join(rng.choice(string.ascii_lowercase)
            for _ in range(rng.randint(3, 9))) for _ in range(n)]


def make_prefix(idx, target_tokens):
    """Deterministic per (idx, SALT) -> byte-identical across rounds within a run
       (real cache hit) but byte-DIFFERENT across salts (fresh key vs prior runs).
       Calibrated against the live tokenizer so token count is ~on target."""
    key = idx + SALT_OFF
    rng = random.Random(key * 7919 + 13)
    sample = " ".join(rand_words(random.Random(key), 400))
    c = tokenize_count(sample)
    tpw = (c / 400.0) if c else 1.4          # tokens-per-word for this stream
    n_words = max(64, int(target_tokens / tpw))
    header = f"SESSION-{idx:03d}-salt-{SALT_OFF}-anchor-{key * 7919}\n"
    body = header + " ".join(rand_words(rng, n_words))
    return body + "\n\nReply with only: OK"


def run_once(prompt):
    """Stream a completion; return (ttft_seconds, prompt_tokens). Mirrors
       bench.sh's streaming-TTFT method (first content delta = TTFT)."""
    body = json.dumps({
        "model": MODEL,
        "messages": [{"role": "user", "content": prompt}],
        "max_tokens": PMAX,
        "temperature": 0.0,
        "stream": True,
        "stream_options": {"include_usage": True},
        "chat_template_kwargs": {"enable_thinking": False},
    }).encode()
    req = urllib.request.Request(f"{URL}/v1/chat/completions", data=body,
                                 headers={"Content-Type": "application/json"})
    t = time.time(); ttft = None; ptok = 0
    with urllib.request.urlopen(req, timeout=600) as r:
        for line in r:
            line = line.decode("utf-8", errors="ignore").rstrip()
            if not line.startswith("data: "):
                continue
            payload = line[6:]
            if payload == "[DONE]":
                break
            try:
                chunk = json.loads(payload)
            except json.JSONDecodeError:
                continue
            choices = chunk.get("choices") or []
            if choices:
                delta = choices[0].get("delta", {})
                content = delta.get("content") or delta.get("reasoning_content")
                if content and ttft is None:
                    ttft = time.time() - t
            usage = chunk.get("usage")
            if usage:
                ptok = usage.get("prompt_tokens", ptok)
    if ttft is None:
        ttft = time.time() - t
    return ttft, ptok


print(f"LMCache L1 retention probe -> {URL}   model={MODEL}")
print(f"  sessions={N}  target_tokens/session~={STOK}  "
      f"warm_threshold={WARM:.1f}s  kv={KVKB:.0f}KB/tok\n")

# Build all prefixes ONCE (identical objects reused across both rounds).
print("Building calibrated haystacks (one /tokenize probe each)...")
prefixes = [make_prefix(i, STOK) for i in range(1, N + 1)]

print("\nRound 1 - cold insert (fills L1):")
actual = []
for i, pfx in enumerate(prefixes, 1):
    ttft, ptok = run_once(pfx)
    actual.append(ptok)
    print(f"  session {i:2d}: cold TTFT {ttft:7.2f}s  (prompt_tokens={ptok})")

tot = sum(actual)
gb = tot * KVKB / 1e6
cap30 = 30e9 / (KVKB * 1e3)
cap60 = 60e9 / (KVKB * 1e3)
print(f"\n  inserted ~{tot} tokens ~= {gb:.1f} GB of L1")
print(f"  capacity lines: L1=30 -> ~{cap30/1000:.0f}K tok (~30 GB), "
      f"L1=60 -> ~{cap60/1000:.0f}K tok (~60 GB)")
if gb < 32:
    print("  NOTE: inserted set < ~32 GB — too small to distinguish L1=30 from 60."
          " Raise NUM_SESSIONS/SESSION_TOKENS.")
elif gb > 58:
    print("  NOTE: inserted set > ~58 GB — may exceed even L1=60. Lower it a touch.")

print("\nRound 2 - retention read (same prefixes; warm = retained):")
warm = 0
for i, pfx in enumerate(prefixes, 1):
    ttft, _ = run_once(pfx)
    hit = ttft <= WARM
    warm += 1 if hit else 0
    tag = "WARM  retained" if hit else "COLD  evicted / re-prefill"
    print(f"  session {i:2d}: TTFT {ttft:7.2f}s  {tag}")

print(f"\n  retained warm: {warm}/{N}")
if warm == N:
    print("\n  PASS - all sessions retained. L1 held the full working set;")
    print("         this set would have evicted its earliest sessions at L1=30.")
    sys.exit(0)
else:
    print(f"\n  FAIL - {N - warm} session(s) evicted. Working set exceeded the live L1,")
    print("         or L1 is smaller than expected. Check LMCACHE_L1_GB and that")
    print("         shm_size >= L1 (else the MP connector falls back to slow pickle).")
    sys.exit(1)
PY
