"""
=============================================================================
CS349E · 第 7 讲共享模块：kv_store.py —— 前缀缓存、分层 KV 存储与 Mooncake 式的调度
=============================================================================

【这个模块要解决什么问题】
    vLLM 管的是**一块 GPU 里**的 KV。Mooncake（Kimi 的服务平台）把 KV 当成**整个集群**的一等公民：
        - 很多请求的开头是一样的（系统提示、同一篇长文档、多轮对话的历史），算过的 KV 存下来，下次直接用；
        - GPU 显存放不下，就放到集群里闲着的 CPU 内存和 SSD 上（"用存储换计算"）；
        - 调度时按"谁手里有这个请求的前缀"来分配 prefill 实例，同时兼顾负载；热门的 KV 自动复制到多台机器；
        - 集群过载时，在 prefill 之前就预测 decode 阶段会不会满，提前拒绝，免得 prefill 白做。
    本模块把这些写成小模拟器：

    block_hashes      ：链式哈希（每块的哈希 = hash(前一块的哈希 + 这块的 token)），Mooncake 图 3、vLLM 的前缀缓存
    PrefixCache       ：按块缓存，命中长度 = 从头开始连续命中的块数；淘汰策略 LRU / LFU
    make_trace        ：仿 Mooncake 公开 trace 统计特征的合成负载（块大小 512、平均输入约 8.6K token、约一半可复用）
    KVTier / load_vs_recompute：从不同存储层读 KV vs 重算的时间（LLaMA2-70B、8×A800）
    prefill_cluster   ：多个 prefill 实例 + 各自的本地前缀缓存；调度策略 random / load / cache / kvcentric（Mooncake 算法 1）
    overload_cluster  ：prefill 池 + decode 池，过载时的三种拒绝策略：baseline / early / predict（Mooncake §7）

【模型的简化（务必知道）】
    - 合成 trace 只模仿论文给出的统计量（平均长度、复用比例、热门块很集中），不是真实 trace；
      结论只看方案之间的相对差别。论文开源了真实 trace（github.com/kvcache-ai/Mooncake），有兴趣可以替换。
    - prefill 时间用 roofline：矩阵乘法 + 注意力 FLOPs / (峰值 × 0.6) + 固定开销；读缓存的 KV 和计算按层重叠
      （Mooncake §5.2 的 layer-wise prefill），所以时间 = max(读 KV, 计算)。
    - decode 池只模拟"容量"（同时能容纳多少 token 的 KV），每个请求的 decode 时间 = 输出长度 × 每 token 时间。

【运行】python3 kv_store.py   # 自检
【依赖】numpy、hashlib；../lecture01 的 llm_math（模型账本）。
"""

import bisect
import hashlib
import heapq
import math
import os
import sys
from collections import OrderedDict
from dataclasses import dataclass, field

import numpy as np

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "lecture01"))

from llm_math import ModelConfig  # noqa: E402

BLOCK = 512    # Mooncake trace 的块大小（token）

# Mooncake 论文所有实验用的"哑模型"：和 LLaMA2-70B 结构相同（80 层、d=8192、64 个 query 头、8 组 KV、d_ff=28672）。
LLAMA2_70B = ModelConfig("LLaMA2-70B", n_layers=80, d_model=8192, n_heads=64, n_kv_heads=8, d_head=128,
                         d_ff=28672, vocab=32000)


# =============================================================================
# 1. 链式哈希与前缀缓存
# =============================================================================
def block_hashes(tokens, block=BLOCK):
    """
    把 token 序列按 block 个一组切开，每一块的哈希 = sha256(前一块的哈希 ‖ 这一块的 token)。只算**完整**的块。

    【为什么要"链式"】KV 不只取决于这一块的 token，还取决于它前面的全部 token（注意力看得见前文）。
        链式哈希让"哈希相同"⇔"从开头到这一块的内容完全相同"，于是可以直接用哈希当缓存的键。
        Mooncake 图 3 和 vLLM 的自动前缀缓存（automatic prefix caching）都是这么做的。
    【返回】十六进制字符串列表（这里只取前 12 位，方便打印）
    【边界】最后不满一块的 token 不参与（它们的 KV 不会被缓存共享）。
    【Python 小知识】hashlib.sha256(字节串).hexdigest() 得到 64 位十六进制字符串；它在任何机器、任何进程里都一样
        （Python 内置的 hash() 对字符串会随机化，不能用来做跨进程的缓存键）。
    """
    out = []
    prev = b""
    n_full = len(tokens) // block
    for i in range(n_full):
        chunk = ",".join(str(int(t)) for t in tokens[i * block:(i + 1) * block]).encode()
        h = hashlib.sha256(prev + b"|" + chunk).hexdigest()[:12]
        out.append(h)
        prev = h.encode()
    return out


class PrefixCache:
    """
    以块为单位的前缀缓存（LRU 淘汰）：键 = 块的链式哈希（或 trace 里的块编号），容量 = 最多存多少块。

    【方法】
        match(keys)  ：从第一块开始数连续命中了几块（中间断了就停——后面的块即使在缓存里也用不上）
        insert(keys) ：把这个请求的所有块放进缓存（已经在的标记为"刚用过"），超出容量就淘汰最久没用的
    【边界】capacity = None 表示无限大。
    【Python 小知识】OrderedDict 记住插入顺序；move_to_end(k) 把 k 挪到最后（"最近用过"），
        popitem(last=False) 弹出最前面的（"最久没用"）。
    """

    def __init__(self, capacity=None):
        self.capacity = capacity
        self.store = OrderedDict()

    def __len__(self):
        return len(self.store)

    def __contains__(self, k):
        return k in self.store

    def match(self, keys):
        n = 0
        for k in keys:
            if k not in self.store:
                break
            n += 1
        return n

    def insert(self, keys):
        for k in keys:
            if k in self.store:
                self.store.move_to_end(k)
            else:
                self.store[k] = True
        while self.capacity is not None and len(self.store) > self.capacity:
            self.store.popitem(last=False)


class LFUCache(PrefixCache):
    """
    LFU 淘汰：用得最少的先走（次数相同时最久没用的先走）。接口和 PrefixCache 相同。

    【实现】一个堆存 (使用次数, 最后使用的时刻, 键)，堆顶就是该淘汰的那个。键的次数变了以后，堆里它的旧记录
        不删（删堆里任意元素很慢），弹出时检查"次数和时刻是不是最新的"，过期的直接跳过——"惰性删除"。
    【Python 小知识】heapq.heappush / heappop 维护一个最小堆；元组按第一个元素比较，相同再比第二个。
    """

    def __init__(self, capacity=None):
        super().__init__(capacity)
        self.freq = {}
        self.stamp = {}
        self.heap = []
        self.clock = 0

    def insert(self, keys):
        for k in keys:
            self.clock += 1
            self.freq[k] = self.freq.get(k, 0) + 1
            self.stamp[k] = self.clock
            self.store[k] = True
            heapq.heappush(self.heap, (self.freq[k], self.clock, k))
        while self.capacity is not None and len(self.store) > self.capacity:
            f, c, k = heapq.heappop(self.heap)
            if k in self.store and self.freq[k] == f and self.stamp[k] == c:
                del self.store[k]
                del self.freq[k]
                del self.stamp[k]


def make_cache(capacity, policy):
    """按名字建缓存："lru" 或 "lfu"。"""
    return LFUCache(capacity) if policy == "lfu" else PrefixCache(capacity)


# =============================================================================
# 2. 仿 Mooncake 的合成 trace
# =============================================================================
@dataclass
class TraceReq:
    """trace 里的一个请求：到达时间（秒）、输入 / 输出 token 数、块编号列表（同一个编号 = 同样的前缀，可复用）。"""
    rid: int
    arrival: float
    input_len: int
    output_len: int
    blocks: list = field(default_factory=list)


def make_trace(n_sessions=2500, rate=2.0, seed=0, n_docs=100000, doc_zipf=0.9, n_sys=30, p_turn=0.6):
    """
    生成一个仿 Mooncake 公开 trace 的合成负载（论文 §4：平均输入 8,590、平均输出 182 个 token；热门块被访问上万次，
    超过一半的块只出现一次；无限缓存时命中率约 55%）。

    【怎样生成】以"会话"为单位，会话按泊松过程到达（每秒 rate 个）：
        - 70% 的会话是"长文档问答"：从 n_docs 篇文档里按 Zipf 分布挑一篇（少数文档被很多会话问到），
          文档长 4–37 块；前面再加一个公共的系统提示（1 块）；
        - 30% 的会话是"普通对话"：一个系统提示（从 n_sys 个里按 Zipf 挑，1–3 块）。
        - 每个会话有几轮（几何分布，平均约 1.7 轮），每轮的输入 = 上一轮的输入 + 上一轮的回答和这次的问题（新加 1 块），
          所以多轮对话天然共享前缀；轮与轮之间隔一段时间（指数分布，平均 30 秒）。
    【返回】TraceReq 列表，按到达时间排序
    【边界】块编号从 0 开始全局唯一；同一篇文档在不同会话里块编号相同（可以复用），每个会话自己的问题块编号不同。
    """
    rng = np.random.default_rng(seed)
    next_id = [0]

    def new_ids(n):
        ids = list(range(next_id[0], next_id[0] + n))
        next_id[0] += n
        return ids

    sys_prompts = [new_ids(int(rng.integers(1, 4))) for _ in range(n_sys)]
    doc_len = rng.integers(4, 38, n_docs)
    docs = {}
    w_doc = 1.0 / np.arange(1, n_docs + 1) ** doc_zipf
    w_doc /= w_doc.sum()
    w_sys = 1.0 / np.arange(1, n_sys + 1) ** 1.0
    w_sys /= w_sys.sum()
    reqs = []
    t = 0.0
    for _ in range(n_sessions):
        t += rng.exponential(1.0 / rate)
        if rng.random() < 0.7:
            d = int(rng.choice(n_docs, p=w_doc))
            if d not in docs:
                docs[d] = new_ids(int(doc_len[d]))
            ctx = sys_prompts[0][:1] + docs[d]
        else:
            ctx = list(sys_prompts[int(rng.choice(n_sys, p=w_sys))])
        n_turns = int(rng.geometric(p_turn))
        tt = t
        for _ in range(n_turns):
            ctx = ctx + new_ids(1)
            out = int(np.clip(round(rng.lognormal(math.log(182) - 0.5, 1.0)), 1, 2000))
            in_len = len(ctx) * BLOCK - int(rng.integers(0, BLOCK))
            reqs.append(TraceReq(0, tt, in_len, out, list(ctx)))
            tt += rng.exponential(30.0)
    reqs.sort(key=lambda r: r.arrival)
    for i, r in enumerate(reqs):
        r.rid = i
    return reqs


def hit_ratio(trace, capacity, policy):
    """按到达顺序回放 trace，单一的全局缓存：命中的块数 / 请求的总块数（Mooncake 表 1 的算法）。"""
    cache = make_cache(capacity, policy)
    hit = tot = 0
    for r in trace:
        hit += cache.match(r.blocks)
        tot += len(r.blocks)
        cache.insert(r.blocks)
    return hit / tot


# =============================================================================
# 3. 分层存储：读 KV vs 重算
# =============================================================================
@dataclass(frozen=True)
class KVTier:
    """一层 KV 存储：名字、到 GPU 的有效带宽（字节/秒，整台 8 卡机器合计）、每次访问的固定延迟（秒）。"""
    name: str
    bw: float
    latency: float


# 带宽是整台 8×A800 机器合计的有效值（假设值，量级参考：HBM 每卡约 2 TB/s；PCIe 4.0 x16 每卡约 25 GB/s；
# Mooncake 的 RDMA 每台机器最高 800 Gbps = 100 GB/s；一块 NVMe SSD 约 6 GB/s，一台机器几块）。
TIERS = [
    KVTier("本机 GPU 显存（HBM）", 8 * 2.0e12 * 0.8, 0.0),
    KVTier("本机 CPU 内存（经 PCIe）", 8 * 25e9, 20e-6),
    KVTier("其他机器的内存（RDMA 800 Gbps）", 100e9, 50e-6),
    KVTier("本机 SSD（4 块 NVMe）", 4 * 6e9, 100e-6),
]

A800_PEAK = 312e12       # A800 和 A100 的算力相同（BF16 稠密）
N_GPU = 8


def prefill_compute_time(model, n_new, n_prefix, mfu=0.6, overhead=5e-3, n_gpu=N_GPU, peak=A800_PEAK):
    """
    prefill 新的 n_new 个 token（前面已有 n_prefix 个 token 的 KV）要算多久：
        FLOPs = n_new × 每 token 矩阵乘法 + 注意力：第 i 个新 token 看 n_prefix + i 个位置。
    【返回】秒（n_gpu 块 GPU 张量并行，按 mfu 的效率；不计通信）
    """
    attn = model.attn_flops_per_token(1) * (n_new * n_prefix + n_new * (n_new + 1) / 2)
    flops = n_new * model.linear_flops_per_token() + attn
    return flops / (n_gpu * peak * mfu) + overhead


def kv_load_time(model, n_tokens, tier):
    """从某一层存储把 n_tokens 个 token 的 KV 读到 GPU 要多久（按块读，每块一次固定延迟）。"""
    if n_tokens <= 0:
        return 0.0
    n_blk = math.ceil(n_tokens / BLOCK)
    return n_blk * tier.latency + n_tokens * model.kv_bytes_per_token() / tier.bw


def prefill_time(model, n_input, n_hit, tier, overlap=True, **kw):
    """
    一个请求的 prefill：前 n_hit 个 token 的 KV 从 tier 读，剩下的算。
    overlap=True 时按层重叠（Mooncake §5.2：读第 l+1 层的 KV 和算第 l 层同时进行），时间 = max(读, 算)；否则相加。
    """
    t_load = kv_load_time(model, n_hit, tier)
    t_comp = prefill_compute_time(model, n_input - n_hit, n_hit, **kw)
    return max(t_load, t_comp) if overlap else t_load + t_comp


# =============================================================================
# 4. prefill 集群：按缓存调度（Mooncake 算法 1）
# =============================================================================
def prefill_cluster(trace, n_inst=8, policy="kvcentric", cache_blocks=3000, ttft_slo=10.0, seed=0,
                    local=TIERS[1], remote=TIERS[2], balance_threshold=2.0, model=LLAMA2_70B):
    """
    n_inst 个 prefill 实例，每个有自己的本地前缀缓存（放在本机 CPU 内存里，LRU，最多 cache_blocks 块）和一个先到先服务的队列。
    请求到达时由全局调度器（Mooncake 叫 Conductor）选一个实例：

        "random"   ：随机选；
        "load"     ：选队列最短（预计排队时间最少）的；
        "cache"    ：缓存感知（§6.1）：对每个实例估计 TTFT = 排队时间 + prefill(用它本地命中的前缀)，选最小的；
        "kvcentric"：再加上缓存的负载均衡（§6.2）：如果别的实例命中的前缀比本地长得多（超过 balance_threshold 倍），
                     就把那段 KV 通过 RDMA 拉过来（估计 TTFT 时加上传输时间）；拉过来的块存进本地缓存——
                     热门的前缀因此自动复制到多台机器上（"hot-spot migration"）。
    【返回】字典：ttft（每个请求的 TTFT 数组）、mean / p90、attain（TTFT ≤ ttft_slo 的比例）、
        hit（命中的 token 比例）、computed（实际算了多少 token）、fetched（远程拉了多少 token）
    【边界】实例一次只做一个 prefill（不批处理）；队列时间 = 排在前面的请求的预计 prefill 时间之和（论文 §6.1 的估计方法）。
    """
    rng = np.random.default_rng(seed)
    caches = [PrefixCache(cache_blocks) for _ in range(n_inst)]
    busy_until = [0.0] * n_inst
    ttft = []
    hit_tok = comp_tok = fetch_tok = tot_tok = 0
    for r in trace:
        now = r.arrival
        q = [max(0.0, busy_until[j] - now) for j in range(n_inst)]
        local_hit = [min(caches[j].match(r.blocks) * BLOCK, r.input_len - 1) for j in range(n_inst)]
        best_hit = max(local_hit)
        holder = local_hit.index(best_hit)
        if policy == "random":
            j = int(rng.integers(n_inst))
            use_hit, fetch = local_hit[j], 0
        elif policy == "load":
            j = int(np.argmin(q))
            use_hit, fetch = local_hit[j], 0
        else:
            best = None
            for jj in range(n_inst):
                t_local = q[jj] + prefill_time(model, r.input_len, local_hit[jj], local)
                cand = (t_local, jj, local_hit[jj], 0)
                if policy == "kvcentric" and best_hit > balance_threshold * max(local_hit[jj], 1) and jj != holder:
                    t_fetch = kv_load_time(model, best_hit - local_hit[jj], remote)
                    t_remote = q[jj] + t_fetch + prefill_time(model, r.input_len, best_hit, local)
                    if t_remote < t_local:
                        cand = (t_remote, jj, best_hit, best_hit - local_hit[jj])
                if best is None or cand[0] < best[0]:
                    best = cand
            _, j, use_hit, fetch = best
        t_fetch = kv_load_time(model, fetch, remote) if fetch else 0.0
        dur = t_fetch + prefill_time(model, r.input_len, use_hit, local)
        start = max(now, busy_until[j])
        busy_until[j] = start + dur
        ttft.append(busy_until[j] - now)
        caches[j].insert(r.blocks)
        hit_tok += use_hit
        fetch_tok += fetch
        comp_tok += r.input_len - use_hit
        tot_tok += r.input_len
    ttft = np.array(ttft)
    return {"ttft": ttft, "mean": float(ttft.mean()), "p90": float(np.percentile(ttft, 90)),
            "attain": float(np.mean(ttft <= ttft_slo)), "hit": hit_tok / tot_tok,
            "computed": comp_tok, "fetched": fetch_tok}


# =============================================================================
# 5. 过载：提前拒绝（Mooncake §7）
# =============================================================================
def iid_requests(trace, n, rate, seed=1):
    """
    平稳的过载负载：泊松到达（每秒 rate 个），每个请求的输入 / 输出长度从 trace 里随机抽一个（不带块编号）。

    【为什么需要它】合成 trace 是按会话生成的，自带忽高忽低的突发；研究"调度策略自己会不会引起负载振荡"时，
        要先把到达过程换成平稳的，否则分不清振荡是负载本身的还是策略造成的。
    """
    rng = np.random.default_rng(seed)
    t = np.cumsum(rng.exponential(1.0 / rate, n))
    idx = rng.integers(0, len(trace), n)
    return [TraceReq(i, float(t[i]), trace[j].input_len, trace[j].output_len, []) for i, j in enumerate(idx)]


def overload_cluster(trace, policy="baseline", n_prefill=8, n_decode=8, decode_cap_tokens=60_000,
                     ttft_slo=30.0, tpot=0.05, hit_frac=0.5, td=None, model=LLAMA2_70B, sample_dt=5.0):
    """
    prefill 池 + decode 池，请求多于集群的处理能力时，决定接不接。

    【decode 池的模型】每个 decode 实例最多同时容纳 decode_cap_tokens 个 token 的 KV（显存和 TBT 的限制合在一起）；
        一个请求在 decode 实例上停留 输出长度 × tpot 秒，占用"输入 + 输出"个 token 的容量。
    【prefill 池的模型】n_prefill 个实例，先到先服务；prefill 时间按命中 hit_frac 的前缀算（这里不模拟缓存，固定比例）；
        预计 TTFT 超过 ttft_slo 的请求直接拒绝。
    【三种策略（Mooncake §7）】
        "baseline"：到达时只看 prefill。prefill 做完再找 decode 实例，放不下就拒绝——这次 prefill 白做了。
        "early"   ：提前拒绝：到达时同时看**现在的** decode 负载，现在就放不下的直接拒绝。
        "predict" ：基于预测的提前拒绝：估计"这个请求 prefill 完成那一刻"decode 池的占用。
                    和论文一样不知道每个请求会生成多久，假设每个请求的 decode 都用同样的时间 td
                    （默认 = trace 的平均输出长度 × tpot）：已经在 decode 的请求，开始时间 + td 早于那一刻的算作已结束；
                    还在 prefill、会比它先完成的请求算作已进入。预测的总占用超过 decode 池的总容量就拒绝。
    【返回】字典：completed、rejected（含 prefill 之后才被拒的）、rejected_after_prefill、wasted_prefill_s（白做的 prefill 时间）、
        decode_load（每 sample_dt 秒采样一次的 decode 池占用率）、prefill_load（同样采样的 prefill 实例忙碌比例）
    【边界】decode 实例的选择：放得下的实例里选占用最少的。
    """
    if td is None:
        td = float(np.mean([r.output_len for r in trace])) * tpot
    p_busy = [0.0] * n_prefill
    dec_used = [0.0] * n_decode
    dec_events = []                       # 堆：(真实结束时间, 实例, token 数)
    dec_starts = []                       # 预测用：(进入 decode 的时间, token 数)，按时间排序
    pending = []                          # 已接纳、还在 prefill 的请求：(prefill 完成时间, token 数)，按时间排序
    completed = rejected = late_reject = 0
    wasted = 0.0
    samples_d, samples_p = [], []
    next_sample = trace[0].arrival if trace else 0.0

    def advance(t):
        while dec_events and dec_events[0][0] <= t:
            _, inst, tok = heapq.heappop(dec_events)
            dec_used[inst] -= tok

    finish_q = []                         # 堆：(prefill 完成时间, 序号, token 数, decode 时长, prefill 时长)
    ai = 0
    seq = 0
    while ai < len(trace) or finish_q:
        t_arr = trace[ai].arrival if ai < len(trace) else math.inf
        t_fin = finish_q[0][0] if finish_q else math.inf
        now = min(t_arr, t_fin)
        while next_sample <= now:
            advance(next_sample)
            samples_d.append(sum(dec_used) / (n_decode * decode_cap_tokens))
            samples_p.append(sum(1 for b in p_busy if b > next_sample) / n_prefill)
            next_sample += sample_dt
        if t_fin <= t_arr:                                   # 一个 prefill 完成，进入 decode
            tf, _, tok, ddur, pdur = heapq.heappop(finish_q)
            pending.pop(bisect.bisect_left(pending, (tf, -1)))
            advance(tf)
            cand = [i for i in range(n_decode) if dec_used[i] + tok <= decode_cap_tokens]
            if cand:
                i = min(cand, key=lambda x: dec_used[x])
                dec_used[i] += tok
                heapq.heappush(dec_events, (tf + ddur, i, tok))
                bisect.insort(dec_starts, (tf, tok))
                completed += 1
            else:
                late_reject += 1
                wasted += pdur
            continue
        r = trace[ai]                                        # 一个请求到达
        ai += 1
        tok = r.input_len + r.output_len
        n_hit = int(hit_frac * r.input_len)
        pdur = prefill_compute_time(model, r.input_len - n_hit, n_hit)
        j = min(range(n_prefill), key=lambda x: p_busy[x])
        t_done = max(now, p_busy[j]) + pdur
        if t_done - now > ttft_slo:
            rejected += 1
            continue
        advance(now)
        if policy == "early":
            if not any(u + tok <= decode_cap_tokens for u in dec_used):
                rejected += 1
                continue
        elif policy == "predict":
            k = bisect.bisect_right(dec_starts, (t_done - td, math.inf))
            del dec_starts[:bisect.bisect_right(dec_starts, (now - td, math.inf))]   # 早就"预测已结束"的不必再留
            k = bisect.bisect_right(dec_starts, (t_done - td, math.inf))
            still = sum(tk for (_, tk) in dec_starts[k:])
            ahead = sum(tk for (tp, tk) in pending if tp <= t_done)
            if still + ahead + tok > n_decode * decode_cap_tokens:
                rejected += 1
                continue
        elif policy != "baseline":
            raise ValueError(policy)
        p_busy[j] = t_done
        seq += 1
        heapq.heappush(finish_q, (t_done, seq, tok, r.output_len * tpot, pdur))
        bisect.insort(pending, (t_done, tok))
    return {"completed": completed, "rejected": rejected + late_reject, "rejected_after_prefill": late_reject,
            "wasted_prefill_s": wasted, "decode_load": np.array(samples_d), "prefill_load": np.array(samples_p)}


def _selftest():
    h1 = block_hashes(list(range(1024)) + [7, 7], block=512)
    h2 = block_hashes(list(range(512)) + [9] + list(range(513, 1024)), block=512)
    assert len(h1) == 2 and h1[0] == h2[0] and h1[1] != h2[1], "链式哈希：第一块相同、第二块不同"
    c = PrefixCache(3)
    c.insert([1, 2, 3])
    c.insert([1, 4])                      # 容量 3：淘汰最久没用的 2
    assert c.match([1, 2, 3]) == 1 and c.match([1, 4]) == 2
    lf = LFUCache(2)
    lf.insert([1, 1, 1, 2])
    lf.insert([3])                        # 淘汰次数最少、最久没用的 2
    assert 1 in lf and 3 in lf and 2 not in lf
    tr = make_trace(n_sessions=300, seed=1)
    assert all(r.input_len > 0 for r in tr)
    print(f"kv_store 自检通过：链式哈希、LRU / LFU、合成 trace（{len(tr)} 个请求）")


if __name__ == "__main__":
    _selftest()
