| 560 | // Check if we already have a disk entry covering this prefix. |
| 561 | PrefixHash hash = hash_prefix(prompt_ids.data(), best); |
| 562 | std::lock_guard<std::mutex> lock(mu_); |
| 563 | if (find_entry(hash) >= 0) return 0; // already cached |
| 564 | |
| 565 | return best; |
| 566 | } |
| 567 | |
| 568 | // ─── Budget enforcement ───────────────────────────────────────────────── |
| 569 | |
| 570 | void DiskPrefixCache::enforce_budget() { |
| 571 | uint64_t now = now_unix(); |
| 572 | |
| 573 | // DS4-style eviction scoring: (effective_hits + 1) * tokens / file_size |
| 574 | // with exponential decay on hits (6-hour half-life). |
| 575 | auto score = [now](const DiskEntry & e) -> double { |
| 576 | // Protect recently-saved entries (< 60 seconds old). |
| 577 | if (e.created_at > 0 && now > 0 && (now - e.created_at) < 60) { |
| 578 | return 1e18; |
| 579 | } |
| 580 | // Decay hits: half-life 6h → decay_rate = ln(2) / (6*3600) ≈ 3.2e-5 |
| 581 | double age_s = (now > e.last_used) ? (double)(now - e.last_used) : 0.0; |
| 582 | double effective_hits = (double)e.hits * std::exp(-age_s * 3.2e-5); |
| 583 | double size_factor = (e.file_size > 0) ? (double)e.file_size : 1.0; |
| 584 | return (effective_hits + 1.0) * (double)e.token_count / size_factor; |
| 585 | }; |
| 586 | |
| 587 | while (total_bytes_ > config_.budget_bytes && !entries_.empty()) { |
| 588 | // Find entry with lowest eviction score. |
| 589 | auto it = std::min_element(entries_.begin(), entries_.end(), |
| 590 | [&score](const DiskEntry & a, const DiskEntry & b) { |
| 591 | return score(a) < score(b); |
| 592 | }); |
| 593 | |
| 594 | // Don't evict protected entries. |
| 595 | if (score(*it) >= 1e18) break; |
| 596 | |
| 597 | std::fprintf(stderr, "[disk-cache] evicting %s (%.1f MB, hits=%u, score=%.3f)\n", |
| 598 | hex(it->token_hash.data(), 16).c_str(), |
| 599 | (double)it->file_size / (1024.0 * 1024.0), |
| 600 | it->hits, score(*it)); |
| 601 | |