From 6d46fbb816c90ea4547ce33871006a7aa2e446e1 Mon Sep 17 00:00:00 2001 From: David Roth Date: Thu, 30 Jul 2026 21:15:39 -0500 Subject: [PATCH 1/5] feat(prefix-cache): sticky protected tools-boundary pins Pin the system+tools head on tool-heavy cold turns (Python thin-pin parity) and spare those entries during eviction so multi-chat deepen snaps cannot thrash Hermes-sized tool prefixes. --- server/docs/TOOL_PREFIX_CACHE.md | 15 ++++-- server/src/server/http_server.cpp | 6 ++- server/src/server/prefix_cache.cpp | 77 ++++++++++++++++++++++-------- server/src/server/prefix_cache.h | 40 +++++++++++----- server/test/test_server_unit.cpp | 24 ++++++++++ 5 files changed, 127 insertions(+), 35 deletions(-) diff --git a/server/docs/TOOL_PREFIX_CACHE.md b/server/docs/TOOL_PREFIX_CACHE.md index dc1e31740..0fb16ac64 100644 --- a/server/docs/TOOL_PREFIX_CACHE.md +++ b/server/docs/TOOL_PREFIX_CACHE.md @@ -10,9 +10,15 @@ For Qwen chat templates, the first request is effectively: ```text [system + tool schemas] [user request] [assistant start] - ^ native snapshot boundary + ^ protected tools-boundary snapshot ``` +Turn 1 pays the tool-schema prefill once and commits a **protected** native +inline snapshot at the system/tools head (first chat boundary). Later turns +restore that head and prefill only the new conversation suffix. Progressive +deeper conversation boundaries remain unprotected LRU leaves so multi-chat +traffic cannot thrash the shared tools pin away. + The snapshot is a normal, backend-owned prefix snapshot. This is important for hybrid architectures such as Qwen3.5/3.6: it contains attention KV, recurrent state, convolution state, and the last-token seed together. Composing a @@ -21,9 +27,12 @@ a valid restore. ## Runtime behavior -- Turn 1 pays the tool-schema prefill once and commits a native inline snapshot. -- Turn 2 restores the snapshot keyed by the system/tool boundary. +- Turn 1 (tools present, tools head miss): pay the tool-schema prefill once and + commit a protected inline snapshot at the first chat boundary. +- Turn 2 restores the tools-head snapshot and can deepen to a later turn + boundary after the head is already restored. - Later turns can restore progressively deeper conversation boundaries. +- Protected tools pins are skipped by eviction while unprotected leaves exist. - The cache key is the exact token prefix. Changing a tool name, description, parameter, system prompt, or template produces a miss; incompatible KV state is never reused. diff --git a/server/src/server/http_server.cpp b/server/src/server/http_server.cpp index 46081fa23..8b1335df4 100644 --- a/server/src/server/http_server.cpp +++ b/server/src/server/http_server.cpp @@ -2908,10 +2908,14 @@ HttpServer::GenerationCacheState HttpServer::prepare_generation_cache( // requests prefer the reusable system/tool boundary; otherwise an // enabled exact full-prompt cache retains its existing priority. const bool prefer_inline_snap = !req.tools.empty(); + // When tools are present, pin the system+tools head first (sticky under + // eviction). After that head is restored, deepen to the turn boundary. + const bool prefer_tools_boundary = prefer_inline_snap; auto prepare_inline = [&]() { const auto prepared_snapshot = prefix_cache_.prepare_inline_snap( effective_prompt, - cache.using_restore ? cache.prefix_len : 0); + cache.using_restore ? cache.prefix_len : 0, + prefer_tools_boundary); cache.snap_slot = prepared_snapshot.first; cache.snap_cut = prepared_snapshot.second; }; diff --git a/server/src/server/prefix_cache.cpp b/server/src/server/prefix_cache.cpp index 4ab20c590..0e986ad68 100644 --- a/server/src/server/prefix_cache.cpp +++ b/server/src/server/prefix_cache.cpp @@ -149,32 +149,48 @@ static bool is_strict_prefix(const std::vector & a, return std::equal(a.begin(), a.end(), b.begin()); } -int select_inline_evict_victim(const std::vector *> & ids_lru) { +int select_inline_evict_victim(const std::vector *> & ids_lru, + const std::vector * protected_lru) { const int n = (int)ids_lru.size(); if (n <= 0) return 0; - // Oldest-first scan: evict the first entry that is not a strict prefix of any - // other entry (a leaf). Shared ancestor prefixes are thereby kept resident. + auto is_protected = [&](int i) { + return protected_lru && i >= 0 && i < (int)protected_lru->size() && + (*protected_lru)[(size_t)i]; + }; + // Oldest-first scan: prefer an unprotected leaf so sticky tools pins survive. + int oldest_protected_leaf = -1; for (int i = 0; i < n; i++) { bool is_ancestor = false; for (int j = 0; j < n; j++) { if (j == i) continue; if (is_strict_prefix(*ids_lru[i], *ids_lru[j])) { is_ancestor = true; break; } } - if (!is_ancestor) return i; // oldest leaf + if (is_ancestor) continue; + if (!is_protected(i)) return i; // oldest unprotected leaf + if (oldest_protected_leaf < 0) oldest_protected_leaf = i; } + if (oldest_protected_leaf >= 0) return oldest_protected_leaf; return 0; // unreachable (the longest entry is always a leaf); pure-LRU fallback } -int select_inline_evict_victim(const std::vector> & ids_lru) { +int select_inline_evict_victim(const std::vector> & ids_lru, + const std::vector * protected_lru) { std::vector *> ptrs; ptrs.reserve(ids_lru.size()); for (const auto & v : ids_lru) ptrs.push_back(&v); - return select_inline_evict_victim(ptrs); + return select_inline_evict_victim(ptrs, protected_lru); } int select_inline_snapshot_boundary(const std::vector & boundaries, - int restored_prefix_len) { + int restored_prefix_len, + bool prefer_tools_boundary) { if (boundaries.empty()) return 0; + // Tool-heavy cold path: pin the system+tools head (first marker) before + // deepening into conversation turns. Matches Python thin-pin semantics. + if (prefer_tools_boundary) { + const int tools_cut = boundaries.front(); + if (tools_cut > restored_prefix_len) return tools_cut; + } const int target = boundaries.size() >= 2 ? boundaries[boundaries.size() - 2] : boundaries.back(); @@ -272,34 +288,47 @@ std::pair PrefixCache::lookup(const std::vector & prompt_ids) std::pair PrefixCache::prepare_inline_snap( const std::vector & prompt_ids, - int restored_prefix_len) { + int restored_prefix_len, + bool prefer_tools_boundary) { if (disabled_) return {-1, 0}; auto candidates = find_all_boundaries(prompt_ids, markers_); const int target_cut = - select_inline_snapshot_boundary(candidates, restored_prefix_len); + select_inline_snapshot_boundary( + candidates, restored_prefix_len, prefer_tools_boundary); if (target_cut <= 0) return {-1, 0}; auto key = hash_prefix(prompt_ids.data(), target_cut); if (find_entry(key) >= 0) return {-1, 0}; // already cached + // Protect the tools head pin (first boundary) for tool-heavy requests so + // multi-chat deepen snaps cannot thrash the ~18k system+tools KV away. + pending_protect_ = prefer_tools_boundary && !candidates.empty() && + target_cut == candidates.front(); + int slot; if ((int)entries_.size() >= cap_) { // At capacity — reserve a slot without evicting yet. Prefix-aware: prefer // the oldest leaf so shared ancestor prefixes (reused by later branches) - // stay resident. entries_ is already in LRU order (front = oldest). + // stay resident. Skip protected tools pins when an unprotected leaf exists. std::vector *> ids_lru; + std::vector protected_lru; ids_lru.reserve(entries_.size()); - for (const auto & e : entries_) ids_lru.push_back(&e.ids); - int victim = select_inline_evict_victim(ids_lru); + protected_lru.reserve(entries_.size()); + for (const auto & e : entries_) { + ids_lru.push_back(&e.ids); + protected_lru.push_back(e.protect); + } + int victim = select_inline_evict_victim(ids_lru, &protected_lru); pending_evict_key_ = entries_[victim].hash; has_pending_evict_ = true; slot = entries_[victim].slot; - if (victim != 0) { + if (victim != 0 || entries_[victim].protect) { std::fprintf(stderr, - "[pc] prefix-aware evict: victim idx=%d (len=%zu) kept oldest " - "ancestor (len=%zu)\n", - victim, entries_[victim].ids.size(), entries_.front().ids.size()); + "[pc] prefix-aware evict: victim idx=%d protect=%d (len=%zu) " + "kept oldest ancestor (len=%zu)\n", + victim, (int)entries_[victim].protect, + entries_[victim].ids.size(), entries_.front().ids.size()); } } else { slot = next_slot_; @@ -311,7 +340,8 @@ std::pair PrefixCache::prepare_inline_snap( } void PrefixCache::confirm_inline_snap(int slot, int target_cut, - const std::vector & prompt_ids) { + const std::vector & prompt_ids, + bool protect) { if (disabled_) return; // Evict the reserved entry (if any). @@ -339,12 +369,16 @@ void PrefixCache::confirm_inline_snap(int slot, int target_cut, } } + const bool protect_entry = protect || pending_protect_; + pending_protect_ = false; + auto key = hash_prefix(prompt_ids.data(), target_cut); std::vector ids(prompt_ids.begin(), prompt_ids.begin() + target_cut); - entries_.push_back({key, slot, std::move(ids)}); + entries_.push_back({key, slot, std::move(ids), protect_entry}); entries_size_count_.fetch_add(1, std::memory_order_relaxed); - std::fprintf(stderr, "[pc] inline-snap committed slot=%d prefix_len=%d\n", - slot, target_cut); + std::fprintf(stderr, + "[pc] inline-snap committed slot=%d prefix_len=%d protect=%d\n", + slot, target_cut, (int)protect_entry); } void PrefixCache::abort_inline_snap(int slot) { @@ -360,6 +394,7 @@ void PrefixCache::abort_inline_snap(int slot) { } } has_pending_evict_ = false; + pending_protect_ = false; } void PrefixCache::cancel_inline_snap(int slot) { @@ -369,6 +404,7 @@ void PrefixCache::cancel_inline_snap(int slot) { if (idx >= 0 && entries_[idx].slot != slot) return; } has_pending_evict_ = false; + pending_protect_ = false; } void PrefixCache::mark_all_cleared() { @@ -378,6 +414,7 @@ void PrefixCache::mark_all_cleared() { entries_size_count_.store(0, std::memory_order_relaxed); next_slot_ = 0; has_pending_evict_ = false; + pending_protect_ = false; std::fprintf(stderr, "[pc] all-cleared — dropped %d LRU entries\n", n); } diff --git a/server/src/server/prefix_cache.h b/server/src/server/prefix_cache.h index 2a0515749..cfd0cc796 100644 --- a/server/src/server/prefix_cache.h +++ b/server/src/server/prefix_cache.h @@ -54,14 +54,25 @@ PrefixHash hash_prefix(const int32_t * ids, int count); // The pointer overload is the core (the caller passes pointers into its own // entries so no token vectors are copied); the value overload is a convenience // wrapper for tests. -int select_inline_evict_victim(const std::vector *> & ids_lru); -int select_inline_evict_victim(const std::vector> & ids_lru); - -// Pick the inline snapshot boundary for a request. We cache the boundary before -// the current user turn (second-to-last marker) and only when it advances past -// an already-restored prefix. Returns 0 when there is no useful new boundary. +// +// When `protected_lru` is non-null and same-sized, entries with +// `(*protected_lru)[i] == true` are skipped unless every leaf is protected +// (then the oldest protected leaf is chosen as a last resort). +int select_inline_evict_victim(const std::vector *> & ids_lru, + const std::vector * protected_lru = nullptr); +int select_inline_evict_victim(const std::vector> & ids_lru, + const std::vector * protected_lru = nullptr); + +// Pick the inline snapshot boundary for a request. +// Default: boundary before the current user turn (second-to-last marker), +// only when it advances past an already-restored prefix. +// When prefer_tools_boundary is set (tool-heavy agent requests), prefer the +// first marker (system+tools head) until that cut is already restored — this +// is the sticky "thin pin" Python tool-split used to keep under multi-chat +// eviction. Returns 0 when there is no useful new boundary. int select_inline_snapshot_boundary(const std::vector & boundaries, - int restored_prefix_len = 0); + int restored_prefix_len = 0, + bool prefer_tools_boundary = false); // ─── Prefix cache entry ───────────────────────────────────────────────── @@ -94,15 +105,19 @@ class PrefixCache { std::pair lookup(const std::vector & prompt_ids); // Prepare an inline snapshot. `restored_prefix_len` prevents reserving a - // slot for a boundary already covered by the restored snapshot. Returns - // (slot, target_cut) or (-1, 0). + // slot for a boundary already covered by the restored snapshot. + // `prefer_tools_boundary` selects the system/tools head first (see + // select_inline_snapshot_boundary). Returns (slot, target_cut) or (-1, 0). std::pair prepare_inline_snap( const std::vector & prompt_ids, - int restored_prefix_len = 0); + int restored_prefix_len = 0, + bool prefer_tools_boundary = false); // Confirm after daemon successfully saved the snapshot. + // `protect` marks the entry non-evictable by unprotected traffic (tool pin). void confirm_inline_snap(int slot, int target_cut, - const std::vector & prompt_ids); + const std::vector & prompt_ids, + bool protect = false); // Abort if the snapshot failed. void abort_inline_snap(int slot); @@ -169,7 +184,10 @@ class PrefixCache { PrefixHash hash; int slot; std::vector ids; // prefix tokens [0, target_cut) for prefix-aware eviction + bool protect = false; // sticky tools-boundary pin }; + // Pending protect flag for the in-flight reservation (applied on confirm). + bool pending_protect_ = false; std::vector entries_; int next_slot_ = 0; PrefixHash pending_evict_key_{}; diff --git a/server/test/test_server_unit.cpp b/server/test/test_server_unit.cpp index 06e3521d4..c59908a4e 100644 --- a/server/test/test_server_unit.cpp +++ b/server/test/test_server_unit.cpp @@ -1799,6 +1799,17 @@ TEST_CASE(ServerUnitFixture, test_inline_snapshot_boundary_advances_past_restore TEST_ASSERT(select_inline_snapshot_boundary({100}, 0) == 100); } +TEST_CASE(ServerUnitFixture, test_inline_snapshot_prefers_tools_boundary_until_restored) { + const std::vector boundaries = {100, 240, 380, 520}; + // Cold tool-heavy: pin system+tools head (first marker), not deepen cut. + TEST_ASSERT(select_inline_snapshot_boundary(boundaries, 0, true) == 100); + // After tools head is restored, deepen to second-to-last. + TEST_ASSERT(select_inline_snapshot_boundary(boundaries, 100, true) == 380); + TEST_ASSERT(select_inline_snapshot_boundary(boundaries, 380, true) == 0); + TEST_ASSERT(select_inline_snapshot_boundary({100}, 0, true) == 100); + TEST_ASSERT(select_inline_snapshot_boundary({100}, 100, true) == 0); +} + // ── Prefix-aware eviction policy (model-free) ─────────────────────────── TEST_CASE(ServerUnitFixture, test_evict_empty_is_zero) { @@ -1833,6 +1844,19 @@ TEST_CASE(ServerUnitFixture, test_evict_branch_spares_shared_root) { TEST_ASSERT(v != 0); // the shared root must be spared } +TEST_CASE(ServerUnitFixture, test_evict_skips_protected_leaf) { + // Two unrelated leaves; oldest is protected → evict next unprotected leaf. + std::vector> ids = {{1, 1}, {2, 2}, {3, 3}}; + std::vector protect = {true, false, false}; + TEST_ASSERT(select_inline_evict_victim(ids, &protect) == 1); +} + +TEST_CASE(ServerUnitFixture, test_evict_all_protected_falls_back) { + std::vector> ids = {{1, 1}, {2, 2}}; + std::vector protect = {true, true}; + TEST_ASSERT(select_inline_evict_victim(ids, &protect) == 0); +} + // ═══════════════════════════════════════════════════════════════════════ // PFlash config tests (model-free) // ═══════════════════════════════════════════════════════════════════════ From 33f57602073083ec9d99d94f3c321a056b555fe5 Mon Sep 17 00:00:00 2001 From: David Roth Date: Fri, 31 Jul 2026 07:27:39 -0500 Subject: [PATCH 2/5] =?UTF-8?q?feat(prefix-cache):=20DiffPin=20=E2=80=94?= =?UTF-8?q?=20float=20volatility=20for=20contiguous=20KV=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diff the tools/system head against recent traffic, relocate small ephemeral hunks after the stable prefix+suffix, and pin that contiguous blob so session clocks no longer force a full head re-prefill. --- server/CMakeLists.txt | 1 + server/docs/PIN_FRIENDLY_PROMPT.md | 216 ++++++++++++++++ server/docs/TOOL_PREFIX_CACHE.md | 8 + server/src/server/http_server.cpp | 94 ++++++- server/src/server/http_server.h | 13 + server/src/server/pin_friendly_prompt.cpp | 293 ++++++++++++++++++++++ server/src/server/pin_friendly_prompt.h | 112 +++++++++ server/src/server/prefix_cache.cpp | 44 +++- server/src/server/prefix_cache.h | 7 +- server/test/test_server_unit.cpp | 113 +++++++++ 10 files changed, 884 insertions(+), 17 deletions(-) create mode 100644 server/docs/PIN_FRIENDLY_PROMPT.md create mode 100644 server/src/server/pin_friendly_prompt.cpp create mode 100644 server/src/server/pin_friendly_prompt.h diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 0da29ea33..2697741ab 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -342,6 +342,7 @@ add_library(dflash_common STATIC src/server/tool_memory.cpp src/server/sse_emitter.cpp src/server/prefix_cache.cpp + src/server/pin_friendly_prompt.cpp src/server/disk_prefix_cache.cpp src/server/freeze_history.cpp # ── Jinja chat-template engine (vendored under deps/llama.cpp/common/) ── diff --git a/server/docs/PIN_FRIENDLY_PROMPT.md b/server/docs/PIN_FRIENDLY_PROMPT.md new file mode 100644 index 000000000..0ceb677ca --- /dev/null +++ b/server/docs/PIN_FRIENDLY_PROMPT.md @@ -0,0 +1,216 @@ +# DiffPin + +**Making agent prompts prefix-cacheable by floating volatility out of the stable head** + +--- + +## In one sentence + +**DiffPin** finds the small changing part of a long prompt (a session clock, a banner, a one-line header), moves it *after* everything that stayed the same, and tells the inference engine: *pin the KV cache here — this contiguous block will come back*. + +--- + +## Why this exists + +Modern agents send a huge head on every turn: identity, rules, dozens of tool schemas. That head is expensive to **prefill** — the model must write a **KV cache** (the memory of “what I already saw”) before it can answer. + +Most of that head does not change. A few tokens do — often a line like *Conversation started: Friday, July 31…*. + +Prefix caching only works when the **beginning** of the prompt matches exactly. One changed date in the middle of the head, and the engine treats the whole head as new. You pay the long prefill again. + +```mermaid +flowchart LR + subgraph cold ["Without DiffPin"] + A1["Same tools + identity"] + A2["Different clock"] + A3["Full head looks new"] + A4["Rebuild all KV 💸"] + A1 --> A2 --> A3 --> A4 + end +``` + +The waste is not “bad networking.” It is **throwing away valid KV** because a tiny volatile island sat where the pin key could not ignore it. + +--- + +## The invention: DiffPin + +**Name:** DiffPin +**Idea:** Treat volatility as a *diff hunk*, not as fate. + +Compare today’s tools/system head to recent traffic. The shared parts form a **prefix** and a **suffix**. The disagreement is a small **middle** (the clock). DiffPin rewrites the head so stable tokens become one contiguous block, with the volatile middle after them: + +```text +Before: [ stable … TIME … stable … ] +After: [ stable ……… stable ][ TIME ][ end ] + ↑ pin here +``` + +```mermaid +flowchart TB + subgraph before ["What the client sent"] + B1["████████"] + B2["🕐 clock"] + B3["████"] + B1 --- B2 --- B3 + end + + subgraph after ["What DiffPin serves"] + A1["████████████"] + A2["🕐 clock"] + A1 --- A2 + end + + before -->|"diff → float the clock"| after + A1 -.->|"protected pin
reuse this KV"| KV["KV cache"] +``` + +Same information. Better *shape* for caching. + +--- + +## How to picture it + +Think of a highlighter and a sticky note. + +1. **Diff** — lay two prompts side by side; highlight what matches from the left and from the right; the unhighlighted island is the sticky note (time, model line, …). +2. **Float** — peel the sticky note off the middle and place it after the highlighted paper. +3. **Pin** — stack the highlighted paper in the KV closet. Next time the sticky note says a different day, the paper still matches. + +```mermaid +sequenceDiagram + participant Turn1 as Turn with July 30 + participant DiffPin + participant Turn2 as Turn with July 31 + participant KV as KV pin + + Turn1->>DiffPin: Long head + clock A + DiffPin->>KV: Remember contiguous stable head + Turn2->>DiffPin: Same head + clock B + DiffPin->>DiffPin: Diff spots only the clock + DiffPin->>DiffPin: Float clock after stable block + DiffPin->>KV: Stable block matches — restore + Note over Turn2,KV: Prefill only clock + new user text +``` + +--- + +## What “pin-friendly” means + +A layout is **pin-friendly** when the tokens you want to reuse form one uninterrupted prefix: + +```mermaid +flowchart LR + subgraph friendly ["Pin-friendly"] + S["STABLE STABLE STABLE"] + V["volatile"] + T["transcript…"] + S --> V --> T + end +``` + +Not pin-friendly when volatility punches a hole in the middle — the shared suffix cannot join the pin, because prefix caches only care about *beginnings*: + +```mermaid +flowchart LR + subgraph unfriendly ["Not pin-friendly"] + S1["STABLE"] + V["volatile"] + S2["STABLE"] + T["transcript…"] + S1 --> V --> S2 --> T + end +``` + +DiffPin’s job is to turn the second shape into the first. + +--- + +## Where it sits in the story + +```mermaid +flowchart TB + Agent["Agent / harness
sends messages + tools"] + Render["Chat template
→ token stream"] + DiffPin["DiffPin
diff · float · pin"] + Engine["Inference engine
restore KV · prefill suffix · decode"] + + Agent --> Render --> DiffPin --> Engine +``` + +DiffPin does not invent a new chat API. It does not need a conversation-id header. It works from **tokens the engine already has**, plus a short memory of recent tool-bearing heads. + +--- + +## What it is careful about + +DiffPin only floats a middle hunk when that hunk looks like **ephemeral noise** (small). If the tools themselves changed, the “middle” is huge — DiffPin leaves the prompt alone. Wrong merges would be worse than a cold prefill. + +```mermaid +flowchart TD + D{Diff middle size?} + D -->|"Small — a clock"| Float["Float it · pin the rest"] + D -->|"Huge — tools changed"| Leave["Leave alone · new pin if needed"] +``` + +End-of-message markers (the “this turn is over” tokens) stay at the end of the head so the chat shape still reads as a coherent system turn. + +--- + +## What you should feel in production + +| Moment | Without DiffPin | With DiffPin | +|---|---|---| +| First tool-heavy turn | Pay for the long head | Same — fill the pin once | +| Next turn, same tools, new clock | Pay for the long head again | Restore pin; prefill clock + new text | +| Multi-chat noise | Long head may get evicted | Protected pin prefers to stay | + +The win is wall-clock and GPU time: tens of seconds of head prefill collapsing to a short suffix prefill when the stable block hits. + +--- + +## Relation to other ideas + +```mermaid +mindmap + root((Reuse agent KV)) + DiffPin + Diff finds volatility + Float to make a contiguous pin + No special client headers + Harness layout + Put clocks in a later message + Ideal when clients cooperate + Slot / protect policy + Keep the pin from being thrashing away + Complements DiffPin +``` + +DiffPin is the engine-side invention when clients bury clocks inside one big system blob. A disciplined harness still helps; DiffPin is the safety net that makes the common messy case pin-friendly anyway. + +--- + +## Naming + +| Term | Meaning | +|---|---| +| **DiffPin** | The invention: diff → float volatility → pin contiguous stable prefix | +| **Pin** | The KV snapshot of that stable prefix | +| **Float** | Moving the volatile hunk after the stable block | +| **Pin-friendly** | Stable tokens form one uninterrupted prefix | + +--- + +## Closing picture + +```mermaid +flowchart TB + Q["Question: why did warm cache miss
when only the date changed?"] + A["Answer: the date sat inside the pin key"] + I["Invention: DiffPin"] + M["Method: diff the head, float the date,
pin the paper that stayed the same"] + + Q --> A --> I --> M +``` + +Agents will keep sending large, almost-stable prompts. DiffPin makes “almost” good enough for KV reuse — by shaping the prompt so the cache can see the stability that was there all along. diff --git a/server/docs/TOOL_PREFIX_CACHE.md b/server/docs/TOOL_PREFIX_CACHE.md index 0fb16ac64..3b05df7f7 100644 --- a/server/docs/TOOL_PREFIX_CACHE.md +++ b/server/docs/TOOL_PREFIX_CACHE.md @@ -82,3 +82,11 @@ For an A/B against an unmodified `main` server, use the same command with `--timing-only`. That mode accepts the older three-field `usage.timings` shape and reports prefill speedup, but deliberately does not claim cache correctness because `main` cannot expose the restored-token counts. + +## See also + +When volatile text (for example a session clock) sits *inside* the first system +message, the first chat boundary cannot exclude it. **DiffPin** diffs the head, +floats that volatility after the stable block, and pins the contiguous prefix. +See [PIN_FRIENDLY_PROMPT.md](./PIN_FRIENDLY_PROMPT.md). Enable/disable with +`DFLASH_PPP` (on by default). diff --git a/server/src/server/http_server.cpp b/server/src/server/http_server.cpp index 8b1335df4..a0eb23b54 100644 --- a/server/src/server/http_server.cpp +++ b/server/src/server/http_server.cpp @@ -21,6 +21,7 @@ #include "sse_emitter.h" #include "prompt_normalize.h" #include "tool_hint.h" +#include "pin_friendly_prompt.h" #include "common/sha1.h" #include "freeze_history.h" @@ -953,6 +954,36 @@ HttpServer::HttpServer(ModelBackend & backend, } disk_cache_.init(); status_html_path_ = resolve_status_html(); + + // PPP env overrides (operator-facing; no CLI flags required). + auto env_truthy = [](const char * v) -> bool { + if (!v || !*v) return false; + return !(v[0] == '0' && v[1] == '\0') && + !(v[0] == 'f' || v[0] == 'F' || v[0] == 'n' || v[0] == 'N'); + }; + if (const char * e = std::getenv("DFLASH_PPP")) { + config_.ppp_enabled = env_truthy(e); + } + if (const char * e = std::getenv("DFLASH_PPP_REARRANGE")) { + config_.ppp_rearrange = env_truthy(e); + } + if (const char * e = std::getenv("DFLASH_PPP_LCP_WINDOW")) { + const int n = std::atoi(e); + if (n > 0) config_.ppp_lcp_window = n; + } + if (const char * e = std::getenv("DFLASH_PPP_MIN_PIN_TOKENS")) { + const int n = std::atoi(e); + if (n > 0) config_.ppp_min_pin_tokens = n; + } + if (const char * e = std::getenv("DFLASH_PPP_MAX_EPHEMERAL")) { + const int n = std::atoi(e); + if (n > 0) config_.ppp_max_ephemeral_tokens = n; + } + std::fprintf(stderr, + "[ppp] enabled=%d rearrange=%d lcp_window=%d min_pin=%d max_ephemeral=%d\n", + (int)config_.ppp_enabled, (int)config_.ppp_rearrange, + config_.ppp_lcp_window, config_.ppp_min_pin_tokens, + config_.ppp_max_ephemeral_tokens); } // Resolve path to share/status.html at startup. @@ -1837,7 +1868,20 @@ bool HttpServer::route_request(SocketHandle fd, const HttpRequest & hr) { apply_request_reasoning(body, req); // Bandit: parse session_id from extra_body (opt-in adaptive keep_ratio). req.session_id = parse_session_id_from_body(body); - if (!render_and_tokenize_request(fd, chat_messages, req)) return true; + + // PPP rearrange (optional): peel ephemeral system banners into a + // following system message so the first chat boundary is stable. + std::vector render_messages = chat_messages; + if (config_.ppp_enabled && config_.ppp_rearrange && !req.tools.empty()) { + auto layout = PinFriendlyPrompt::rearrange(chat_messages, true); + if (layout.rearranged) { + render_messages = std::move(layout.messages); + std::fprintf(stderr, + "[ppp] rearranged: peeled ephemeral system tail\n"); + } + } + + if (!render_and_tokenize_request(fd, render_messages, req)) return true; // count_tokens: short-circuit after tokenization. Skip generation // entirely — Anthropic's contract is just {"input_tokens": N}. @@ -2659,6 +2703,47 @@ HttpServer::GenerationCacheState HttpServer::prepare_generation_cache( const ParsedRequest & req, PreparedPrompt & prepared, GenerateRequest & generate_request) { auto & effective_prompt = prepared.tokens; + // Tool-heavy requests prefer the reusable system/tool boundary under eviction. + const bool prefer_inline_snap = !req.tools.empty(); + const bool prefer_tools_boundary = prefer_inline_snap; + int forced_cut = req.pin_end_token; + + // PPP runs *before* lookup: rewrite the tools/system head into + // [shared prefix][shared suffix][volatile][end markers] so cache keys + // match across session-clock drift. + if (config_.ppp_enabled && prefer_tools_boundary) { + const auto boundaries = find_all_boundaries( + effective_prompt, prefix_cache_.chat_markers()); + auto rewrite = PinFriendlyPrompt::diff_make_pin_friendly( + effective_prompt, boundaries, recent_tool_prefixes_, + prefix_cache_.chat_markers(), + config_.ppp_lcp_window, config_.ppp_min_pin_tokens, + config_.ppp_max_ephemeral_tokens); + if (rewrite.rewritten) { + effective_prompt = std::move(rewrite.tokens); + generate_request.prompt = effective_prompt; + std::fprintf(stderr, + "[ppp] diff-rewrite prefix=%d suffix=%d middle=%d " + "pin_end=%d prompt=%zu\n", + rewrite.prefix_len, rewrite.suffix_len, rewrite.middle_len, + rewrite.pin_end, effective_prompt.size()); + } + if (forced_cut <= 0) forced_cut = rewrite.pin_end; + if (forced_cut > 0 && !rewrite.rewritten) { + std::fprintf(stderr, + "[ppp] pin_end=%d (no rewrite; prompt=%zu)\n", + forced_cut, effective_prompt.size()); + } + const auto remember_bounds = find_all_boundaries( + effective_prompt, prefix_cache_.chat_markers()); + const int remember_n = !remember_bounds.empty() + ? remember_bounds.front() + : (int)effective_prompt.size(); + PinFriendlyPrompt::remember_tool_prefix( + recent_tool_prefixes_, effective_prompt, remember_n, + config_.ppp_lcp_window); + } + GenerationCacheState cache; cache.cache_slot = prepared.full_cache_hit_slot; cache.prefix_len = prepared.full_cache_hit_len; @@ -2907,15 +2992,12 @@ HttpServer::GenerationCacheState HttpServer::prepare_generation_cache( // A generation can save only one snapshot during prefill. Tool-heavy // requests prefer the reusable system/tool boundary; otherwise an // enabled exact full-prompt cache retains its existing priority. - const bool prefer_inline_snap = !req.tools.empty(); - // When tools are present, pin the system+tools head first (sticky under - // eviction). After that head is restored, deepen to the turn boundary. - const bool prefer_tools_boundary = prefer_inline_snap; auto prepare_inline = [&]() { const auto prepared_snapshot = prefix_cache_.prepare_inline_snap( effective_prompt, cache.using_restore ? cache.prefix_len : 0, - prefer_tools_boundary); + prefer_tools_boundary, + forced_cut); cache.snap_slot = prepared_snapshot.first; cache.snap_cut = prepared_snapshot.second; }; diff --git a/server/src/server/http_server.h b/server/src/server/http_server.h index 56f555369..d3aedb540 100644 --- a/server/src/server/http_server.h +++ b/server/src/server/http_server.h @@ -61,6 +61,15 @@ struct ServerConfig { int prefix_cache_cap = 32; // prefix cache slots (0 disables) int prefill_cache_cap = 0; // full-prompt/prefill cache slots (0 disables) + // Pin-Friendly Prompt Processor (PPP): LCP pin_end + optional rearrange. + // See docs/PIN_FRIENDLY_PROMPT.md. Env: DFLASH_PPP=0|1, + // DFLASH_PPP_REARRANGE=0|1, DFLASH_PPP_LCP_WINDOW=N. + bool ppp_enabled = true; + bool ppp_rearrange = false; + int ppp_lcp_window = 8; + int ppp_min_pin_tokens = 512; + int ppp_max_ephemeral_tokens = 256; // diff hunk relocate cap + // Thinking-budget v2. Applied when a request opts in via // `thinking: {type: "enabled"}` or `reasoning: {effort: ...}`. // think_max_tokens caps phase-1 reasoning generation; the combined @@ -228,6 +237,8 @@ struct ParsedRequest { // Bandit: per-session adaptive keep_ratio opt-in std::string session_id; DiskPrefixCachePolicy disk_cache_policy; + // PPP: stable pin cut for tool-heavy requests (0 = use default boundary). + int pin_end_token = 0; }; // Parse request sampler fields, applying model-card defaults where present. @@ -433,6 +444,8 @@ class HttpServer { // Track prompt tokens for each snapshot slot (for shutdown save). std::unordered_map> slot_tokens_; std::vector> recent_disk_prompts_; + // Recent tool-bearing prompt prefixes for PPP LCP annotate. + std::vector> recent_tool_prefixes_; // FlowKV freeze-history: per-message compression cache. // Key: SHA-1 hash of the drafter-token slice for an aged message. diff --git a/server/src/server/pin_friendly_prompt.cpp b/server/src/server/pin_friendly_prompt.cpp new file mode 100644 index 000000000..0f82fc5fb --- /dev/null +++ b/server/src/server/pin_friendly_prompt.cpp @@ -0,0 +1,293 @@ +#include "pin_friendly_prompt.h" + +#include +#include + +namespace dflash::common { + +int PinFriendlyPrompt::longest_common_prefix_len( + const std::vector & a, + const std::vector & b) { + const int n = (int)std::min(a.size(), b.size()); + int i = 0; + while (i < n && a[(size_t)i] == b[(size_t)i]) ++i; + return i; +} + +int PinFriendlyPrompt::longest_common_suffix_len( + const std::vector & a, + const std::vector & b, + int prefix_len) { + int i = (int)a.size() - 1; + int j = (int)b.size() - 1; + int s = 0; + while (i >= prefix_len && j >= prefix_len && + a[(size_t)i] == b[(size_t)j]) { + --i; + --j; + ++s; + } + return s; +} + +TokenDiffSplit PinFriendlyPrompt::diff_split( + const std::vector & reference, + const std::vector & current) { + TokenDiffSplit out; + out.prefix_len = longest_common_prefix_len(reference, current); + out.suffix_len = longest_common_suffix_len( + reference, current, out.prefix_len); + out.middle_begin = out.prefix_len; + out.middle_end = (int)current.size() - out.suffix_len; + if (out.middle_end < out.middle_begin) { + out.middle_end = out.middle_begin; + out.suffix_len = (int)current.size() - out.prefix_len; + } + return out; +} + +int PinFriendlyPrompt::safe_boundary_cut( + int n, const std::vector & boundaries) { + if (n <= 0) return 0; + int best = 0; + for (int b : boundaries) { + if (b > 0 && b <= n) best = std::max(best, b); + } + return best; +} + +int PinFriendlyPrompt::choose_pin_end( + int lcp, + const std::vector & boundaries, + int min_pin_tokens) { + if (lcp < min_pin_tokens) return 0; + const int boundary = safe_boundary_cut(lcp, boundaries); + if (boundary >= min_pin_tokens) return boundary; + return lcp; +} + +int PinFriendlyPrompt::annotate_pin_end( + const std::vector & tokens, + const std::vector & boundaries, + const std::vector> & recent_tool_prefixes, + int window, + int min_pin_tokens) { + if (tokens.empty() || recent_tool_prefixes.empty() || window <= 0) { + return 0; + } + const int n = std::min(window, (int)recent_tool_prefixes.size()); + int common = 0; + for (int i = 0; i < n; ++i) { + const int idx = (int)recent_tool_prefixes.size() - n + i; + common = std::max( + common, + longest_common_prefix_len(tokens, recent_tool_prefixes[(size_t)idx])); + } + return choose_pin_end(common, boundaries, min_pin_tokens); +} + +int PinFriendlyPrompt::trailing_end_marker_len( + const std::vector & ids, + const ChatMarkers & markers) { + if (ids.empty()) return 0; + int best = 0; + for (const auto & seq : markers.end_msg_seqs) { + if (seq.empty() || (int)seq.size() > (int)ids.size()) continue; + const int start = (int)ids.size() - (int)seq.size(); + bool match = true; + for (int i = 0; i < (int)seq.size(); ++i) { + if (ids[(size_t)(start + i)] != seq[(size_t)i]) { + match = false; + break; + } + } + if (match) best = std::max(best, (int)seq.size()); + } + // Qwen boundaries often include a trailing '\n' token after im_end. + // Peek one extra equal-length attempt is unnecessary; keep marker only. + return best; +} + +PinFriendlyRewrite PinFriendlyPrompt::diff_make_pin_friendly( + const std::vector & tokens, + const std::vector & boundaries, + const std::vector> & recent_tool_prefixes, + const ChatMarkers & markers, + int window, + int min_pin_tokens, + int max_ephemeral_tokens) { + PinFriendlyRewrite out; + out.tokens = tokens; + if (tokens.empty() || recent_tool_prefixes.empty() || window <= 0) { + return out; + } + + // Only rewrite the tools/system head; leave transcript untouched. + const int head_end = boundaries.empty() + ? (int)tokens.size() + : boundaries.front(); + if (head_end < min_pin_tokens) return out; + + std::vector head(tokens.begin(), tokens.begin() + head_end); + const std::vector rest(tokens.begin() + head_end, tokens.end()); + + const int trailer_len = trailing_end_marker_len(head, markers); + std::vector trailer; + if (trailer_len > 0) { + trailer.assign(head.end() - trailer_len, head.end()); + head.resize((size_t)((int)head.size() - trailer_len)); + } + if ((int)head.size() < min_pin_tokens) return out; + + // Pick the reference that yields the largest stable span + // (prefix + suffix) with a small ephemeral middle. + TokenDiffSplit best{}; + int best_stable = -1; + bool found = false; + const int n = std::min(window, (int)recent_tool_prefixes.size()); + for (int i = 0; i < n; ++i) { + const int idx = (int)recent_tool_prefixes.size() - n + i; + const auto & ref_full = recent_tool_prefixes[(size_t)idx]; + // Align reference to body length (strip its own trailer if present). + std::vector ref = ref_full; + if ((int)ref.size() > head_end) { + ref.resize((size_t)head_end); + } + const int ref_trailer = trailing_end_marker_len(ref, markers); + if (ref_trailer > 0 && ref_trailer < (int)ref.size()) { + ref.resize((size_t)((int)ref.size() - ref_trailer)); + } + if (ref.empty()) continue; + + const TokenDiffSplit split = diff_split(ref, head); + const int middle = split.middle_end - split.middle_begin; + const int stable = split.prefix_len + split.suffix_len; + if (middle < 0 || middle > max_ephemeral_tokens) continue; + if (stable < min_pin_tokens) continue; + // Require a real middle relocation opportunity (suffix beyond prefix). + if (middle == 0) continue; + if (stable > best_stable) { + best_stable = stable; + best = split; + found = true; + } + } + + if (!found) { + // Fall back: LCP pin without rewrite. + out.pin_end = annotate_pin_end( + tokens, boundaries, recent_tool_prefixes, window, min_pin_tokens); + return out; + } + + std::vector new_head; + new_head.reserve(head.size() + trailer.size()); + new_head.insert(new_head.end(), + head.begin(), head.begin() + best.prefix_len); + if (best.suffix_len > 0) { + new_head.insert(new_head.end(), + head.end() - best.suffix_len, head.end()); + } + const int pin_body = (int)new_head.size(); + new_head.insert(new_head.end(), + head.begin() + best.middle_begin, + head.begin() + best.middle_end); + new_head.insert(new_head.end(), trailer.begin(), trailer.end()); + + out.tokens.clear(); + out.tokens.insert(out.tokens.end(), new_head.begin(), new_head.end()); + out.tokens.insert(out.tokens.end(), rest.begin(), rest.end()); + out.pin_end = pin_body; // contiguous stable blob; volatile + trailer after + // Prefer including trailer in the pin when it yields a chat boundary cut. + if (!trailer.empty()) { + const int with_trailer = pin_body + (int)trailer.size(); + // Only if volatile sits after trailer we would have moved wrong; + // here volatile is before trailer, so pin stays at pin_body. + (void)with_trailer; + } + out.rewritten = (out.tokens != tokens); + out.prefix_len = best.prefix_len; + out.suffix_len = best.suffix_len; + out.middle_len = best.middle_end - best.middle_begin; + + if (out.pin_end < min_pin_tokens) { + out.tokens = tokens; + out.rewritten = false; + out.pin_end = annotate_pin_end( + tokens, boundaries, recent_tool_prefixes, window, min_pin_tokens); + } + return out; +} + +static std::string trim_leading_newlines(std::string s) { + while (!s.empty() && (s[0] == '\n' || s[0] == '\r')) { + s.erase(s.begin()); + } + return s; +} + +std::pair +PinFriendlyPrompt::split_ephemeral_system_tail(const std::string & system) { + static const char * kMarkers[] = { + "\nConversation started:", + "\n\nConversation started:", + "\nSession started:", + "\n\nSession started:", + }; + size_t cut = std::string::npos; + for (const char * marker : kMarkers) { + const size_t p = system.rfind(marker); + if (p == std::string::npos) continue; + if (cut == std::string::npos || p < cut) cut = p; + } + if (cut == std::string::npos || cut == 0) { + return {system, {}}; + } + std::string stable = system.substr(0, cut); + while (!stable.empty() && + (stable.back() == ' ' || stable.back() == '\t' || + stable.back() == '\n' || stable.back() == '\r')) { + stable.pop_back(); + } + std::string ephemeral = trim_leading_newlines(system.substr(cut)); + if (ephemeral.empty()) return {system, {}}; + return {std::move(stable), std::move(ephemeral)}; +} + +PinFriendlyLayout PinFriendlyPrompt::rearrange( + const std::vector & messages, bool enable) { + PinFriendlyLayout layout; + layout.messages = messages; + if (!enable || messages.empty() || messages[0].role != "system") { + return layout; + } + auto [stable, ephemeral] = split_ephemeral_system_tail(messages[0].content); + if (ephemeral.empty() || stable.empty()) { + return layout; + } + layout.messages[0].content = std::move(stable); + ChatMessage meta; + meta.role = "system"; + meta.content = std::move(ephemeral); + layout.messages.insert(layout.messages.begin() + 1, std::move(meta)); + layout.rearranged = true; + return layout; +} + +void PinFriendlyPrompt::remember_tool_prefix( + std::vector> & ring, + const std::vector & prompt_ids, + int max_prefix_tokens, + int window) { + if (prompt_ids.empty() || window <= 0) return; + const int n = std::min((int)prompt_ids.size(), + max_prefix_tokens > 0 ? max_prefix_tokens + : (int)prompt_ids.size()); + std::vector prefix(prompt_ids.begin(), prompt_ids.begin() + n); + ring.push_back(std::move(prefix)); + while ((int)ring.size() > window) { + ring.erase(ring.begin()); + } +} + +} // namespace dflash::common diff --git a/server/src/server/pin_friendly_prompt.h b/server/src/server/pin_friendly_prompt.h new file mode 100644 index 000000000..05c54fedf --- /dev/null +++ b/server/src/server/pin_friendly_prompt.h @@ -0,0 +1,112 @@ +// Pin-Friendly Prompt Processor (PPP) +// +// Diffes the tools/system head against recent traffic, isolates the volatile +// span, and rewrites tokens to: +// [shared prefix][shared suffix][volatile middle][end markers] +// so PrefixCache can pin the contiguous stable blob. See docs/PIN_FRIENDLY_PROMPT.md. + +#pragma once + +#include "chat_template.h" +#include "prefix_cache.h" // ChatMarkers + +#include +#include +#include +#include + +namespace dflash::common { + +struct PinFriendlyPromptConfig { + bool enabled = true; + bool rearrange = false; // optional text-level peel (legacy / explicit) + int lcp_window = 8; // recent tool-bearing prefixes to compare + int min_pin_tokens = 512; + int max_ephemeral_tokens = 256; // only relocate small volatile hunks +}; + +struct PinFriendlyLayout { + std::vector messages; + int pin_end_token = 0; + bool rearranged = false; + int lcp_len = 0; +}; + +// Result of a prefix/suffix diff on one sequence against a reference. +struct TokenDiffSplit { + int prefix_len = 0; // shared head + int suffix_len = 0; // shared tail (non-overlapping with prefix) + int middle_begin = 0; // in `current` + int middle_end = 0; // exclusive in `current` +}; + +// Full-prompt rewrite for pin-friendly layout. +struct PinFriendlyRewrite { + std::vector tokens; + int pin_end = 0; + bool rewritten = false; + int prefix_len = 0; + int suffix_len = 0; + int middle_len = 0; +}; + +class PinFriendlyPrompt { +public: + static int longest_common_prefix_len(const std::vector & a, + const std::vector & b); + + static int longest_common_suffix_len(const std::vector & a, + const std::vector & b, + int prefix_len); + + // Split `current` vs `reference` into shared prefix / volatile middle / + // shared suffix (classic single-hunk diff). + static TokenDiffSplit diff_split(const std::vector & reference, + const std::vector & current); + + // Max chat boundary ≤ n (0 if none). + static int safe_boundary_cut(int n, const std::vector & boundaries); + + static int choose_pin_end(int lcp, + const std::vector & boundaries, + int min_pin_tokens); + + // Legacy LCP-only annotate (no rewrite). + static int annotate_pin_end( + const std::vector & tokens, + const std::vector & boundaries, + const std::vector> & recent_tool_prefixes, + int window, + int min_pin_tokens); + + // Diff the tools/system head against recent prefixes and rewrite: + // [tokentokentoken] with a mid clock → [tokentoken][token][time][im_end] + // pin_end covers the contiguous stable blob. No-op when history is empty, + // the volatile hunk is too large, or nothing moves. + static PinFriendlyRewrite diff_make_pin_friendly( + const std::vector & tokens, + const std::vector & boundaries, + const std::vector> & recent_tool_prefixes, + const ChatMarkers & markers, + int window, + int min_pin_tokens, + int max_ephemeral_tokens); + + static std::pair + split_ephemeral_system_tail(const std::string & system); + + static PinFriendlyLayout rearrange(const std::vector & messages, + bool enable); + + static void remember_tool_prefix( + std::vector> & ring, + const std::vector & prompt_ids, + int max_prefix_tokens, + int window); + + // Peel trailing chat end-message marker tokens from `ids`. + static int trailing_end_marker_len(const std::vector & ids, + const ChatMarkers & markers); +}; + +} // namespace dflash::common diff --git a/server/src/server/prefix_cache.cpp b/server/src/server/prefix_cache.cpp index 0e986ad68..aa9a33136 100644 --- a/server/src/server/prefix_cache.cpp +++ b/server/src/server/prefix_cache.cpp @@ -254,6 +254,7 @@ std::pair PrefixCache::lookup(const std::vector & prompt_ids) auto boundaries = find_all_boundaries(prompt_ids, markers_); int best_slot = -1, best_len = 0; + int best_idx = -1; for (int cut : boundaries) { auto key = hash_prefix(prompt_ids.data(), cut); @@ -273,12 +274,27 @@ std::pair PrefixCache::lookup(const std::vector & prompt_ids) if (cut > best_len) { best_slot = entries_[idx].slot; best_len = cut; + best_idx = idx; } - move_to_end(idx); } } - if (best_slot >= 0) { + // Match committed entry prefixes directly. Required for PPP mid-message + // pin_end cuts that are not chat-template boundaries. + for (int i = 0; i < (int)entries_.size(); ++i) { + const auto & e = entries_[(size_t)i]; + const int len = (int)e.ids.size(); + if (len <= best_len || len > (int)prompt_ids.size()) continue; + if (!std::equal(e.ids.begin(), e.ids.end(), prompt_ids.begin())) { + continue; + } + best_slot = e.slot; + best_len = len; + best_idx = i; + } + + if (best_idx >= 0) { + move_to_end(best_idx); lifetime_hits_.fetch_add(1, std::memory_order_relaxed); std::fprintf(stderr, "[pc] lookup hit slot=%d prefix_len=%d (of %zu total)\n", best_slot, best_len, prompt_ids.size()); @@ -289,22 +305,32 @@ std::pair PrefixCache::lookup(const std::vector & prompt_ids) std::pair PrefixCache::prepare_inline_snap( const std::vector & prompt_ids, int restored_prefix_len, - bool prefer_tools_boundary) { + bool prefer_tools_boundary, + int forced_cut) { if (disabled_) return {-1, 0}; auto candidates = find_all_boundaries(prompt_ids, markers_); - const int target_cut = - select_inline_snapshot_boundary( + int target_cut = 0; + bool forced = false; + if (forced_cut > restored_prefix_len && + forced_cut <= (int)prompt_ids.size()) { + target_cut = forced_cut; + forced = true; + } else { + target_cut = select_inline_snapshot_boundary( candidates, restored_prefix_len, prefer_tools_boundary); + } if (target_cut <= 0) return {-1, 0}; auto key = hash_prefix(prompt_ids.data(), target_cut); if (find_entry(key) >= 0) return {-1, 0}; // already cached - // Protect the tools head pin (first boundary) for tool-heavy requests so - // multi-chat deepen snaps cannot thrash the ~18k system+tools KV away. - pending_protect_ = prefer_tools_boundary && !candidates.empty() && - target_cut == candidates.front(); + // Protect the tools head pin for tool-heavy requests so multi-chat deepen + // snaps cannot thrash the ~18k system+tools KV away. PPP forced cuts are + // the stable tools/identity span and stay protected as well. + pending_protect_ = prefer_tools_boundary && + (forced || + (!candidates.empty() && target_cut == candidates.front())); int slot; if ((int)entries_.size() >= cap_) { diff --git a/server/src/server/prefix_cache.h b/server/src/server/prefix_cache.h index cfd0cc796..a4683c69d 100644 --- a/server/src/server/prefix_cache.h +++ b/server/src/server/prefix_cache.h @@ -107,11 +107,14 @@ class PrefixCache { // Prepare an inline snapshot. `restored_prefix_len` prevents reserving a // slot for a boundary already covered by the restored snapshot. // `prefer_tools_boundary` selects the system/tools head first (see - // select_inline_snapshot_boundary). Returns (slot, target_cut) or (-1, 0). + // select_inline_snapshot_boundary). When `forced_cut` > restored, that + // cut is used instead (PPP pin_end, including mid-message LCP cuts). + // Returns (slot, target_cut) or (-1, 0). std::pair prepare_inline_snap( const std::vector & prompt_ids, int restored_prefix_len = 0, - bool prefer_tools_boundary = false); + bool prefer_tools_boundary = false, + int forced_cut = 0); // Confirm after daemon successfully saved the snapshot. // `protect` marks the entry non-evictable by unprotected traffic (tool pin). diff --git a/server/test/test_server_unit.cpp b/server/test/test_server_unit.cpp index c59908a4e..1d7ed61c8 100644 --- a/server/test/test_server_unit.cpp +++ b/server/test/test_server_unit.cpp @@ -12,6 +12,7 @@ #include "server/tool_parser.h" #include "server/reasoning.h" #include "server/prefix_cache.h" +#include "server/pin_friendly_prompt.h" #include "server/disk_prefix_cache.h" #include "server/freeze_history.h" #include "server/utf8_utils.h" @@ -1810,6 +1811,118 @@ TEST_CASE(ServerUnitFixture, test_inline_snapshot_prefers_tools_boundary_until_r TEST_ASSERT(select_inline_snapshot_boundary({100}, 100, true) == 0); } +// ── Pin-Friendly Prompt Processor (PPP) ───────────────────────────────── + +TEST_CASE(ServerUnitFixture, test_ppp_lcp_and_safe_boundary) { + const std::vector a = {1, 2, 3, 4, 5, 6}; + const std::vector b = {1, 2, 3, 9, 9}; + TEST_ASSERT(PinFriendlyPrompt::longest_common_prefix_len(a, b) == 3); + TEST_ASSERT(PinFriendlyPrompt::longest_common_prefix_len(a, a) == 6); + TEST_ASSERT(PinFriendlyPrompt::longest_common_prefix_len(a, {}) == 0); + + const std::vector boundaries = {100, 240, 380}; + TEST_ASSERT(PinFriendlyPrompt::safe_boundary_cut(250, boundaries) == 240); + TEST_ASSERT(PinFriendlyPrompt::safe_boundary_cut(50, boundaries) == 0); + TEST_ASSERT(PinFriendlyPrompt::safe_boundary_cut(380, boundaries) == 380); +} + +TEST_CASE(ServerUnitFixture, test_ppp_choose_pin_end_prefers_boundary_then_mid) { + const std::vector boundaries = {100, 200}; + // LCP past a boundary → pin at that boundary. + TEST_ASSERT(PinFriendlyPrompt::choose_pin_end(150, boundaries, 50) == 100); + // LCP past first boundary but short of second → still prefer boundary. + TEST_ASSERT(PinFriendlyPrompt::choose_pin_end(175, boundaries, 50) == 100); + // No boundary ≤ LCP → mid-message cut (tools-before-system layout). + TEST_ASSERT(PinFriendlyPrompt::choose_pin_end(80, boundaries, 50) == 80); + TEST_ASSERT(PinFriendlyPrompt::choose_pin_end(40, boundaries, 50) == 0); +} + +TEST_CASE(ServerUnitFixture, test_ppp_annotate_against_recent_ring) { + // Shared tools+identity head, divergent session clock in the tail of the + // first turn (before any chat boundary at 200). + std::vector day1(180, 7); + day1.push_back(111); // date token + day1.insert(day1.end(), {8, 8, 8}); // past boundary material + std::vector day2(180, 7); + day2.push_back(222); + day2.insert(day2.end(), {8, 8, 8}); + + std::vector> ring = {day1}; + const std::vector boundaries = {200}; + const int pin = PinFriendlyPrompt::annotate_pin_end( + day2, boundaries, ring, /*window=*/4, /*min=*/50); + TEST_ASSERT(pin == 180); // mid-message LCP before date drift +} + +TEST_CASE(ServerUnitFixture, test_ppp_diff_split_finds_middle_hunk) { + const std::vector a = {1, 2, 3, 100, 4, 5}; + const std::vector b = {1, 2, 3, 999, 4, 5}; + const auto split = PinFriendlyPrompt::diff_split(a, b); + TEST_ASSERT(split.prefix_len == 3); + TEST_ASSERT(split.suffix_len == 2); + TEST_ASSERT(split.middle_begin == 3); + TEST_ASSERT(split.middle_end == 4); +} + +TEST_CASE(ServerUnitFixture, test_ppp_diff_rewrite_moves_volatile_after_stable) { + // Head: [stable…][TIME][stable_tail…][im_end] → [stable…][stable_tail…][TIME][im_end] + std::vector day1 = {7, 7, 7, 7, 111, 8, 8, 50}; // 50 = im_end + std::vector day2 = {7, 7, 7, 7, 222, 8, 8, 50}; + // Transcript after first boundary. + day2.insert(day2.end(), {9, 9}); + + ChatMarkers markers; + markers.family = "test"; + markers.end_msg_seqs = {{50}}; + + std::vector> ring = {day1}; + const std::vector boundaries = {8}; // head through im_end + auto rw = PinFriendlyPrompt::diff_make_pin_friendly( + day2, boundaries, ring, markers, + /*window=*/4, /*min_pin=*/4, /*max_ephemeral=*/16); + TEST_ASSERT(rw.rewritten); + TEST_ASSERT(rw.prefix_len == 4); + TEST_ASSERT(rw.suffix_len == 2); // {8,8} after peeling im_end trailer + TEST_ASSERT(rw.middle_len == 1); + // pin covers stable prefix+suffix; volatile then im_end follow. + TEST_ASSERT(rw.pin_end == 6); + // [7,7,7,7][8,8][222][50][9,9] + TEST_ASSERT(rw.tokens.size() == day2.size()); + TEST_ASSERT((rw.tokens[0] == 7 && rw.tokens[3] == 7)); + TEST_ASSERT(rw.tokens[4] == 8 && rw.tokens[5] == 8); + TEST_ASSERT(rw.tokens[6] == 222); + TEST_ASSERT(rw.tokens[7] == 50); + TEST_ASSERT(rw.tokens[8] == 9); +} + +TEST_CASE(ServerUnitFixture, test_ppp_split_and_rearrange_ephemeral_tail) { + const std::string system = + "You are Hermes.\n\n" + "Conversation started: Thursday, July 30, 2026 03:59 PM\n" + "Model: qwen\n"; + auto [stable, ephemeral] = + PinFriendlyPrompt::split_ephemeral_system_tail(system); + TEST_ASSERT(stable == "You are Hermes."); + TEST_ASSERT(ephemeral.find("Conversation started:") == 0); + + std::vector messages = { + {"system", system, ""}, + {"user", "hi", ""}, + }; + auto off = PinFriendlyPrompt::rearrange(messages, false); + TEST_ASSERT(!off.rearranged); + TEST_ASSERT(off.messages.size() == 2); + + auto on = PinFriendlyPrompt::rearrange(messages, true); + TEST_ASSERT(on.rearranged); + TEST_ASSERT(on.messages.size() == 3); + TEST_ASSERT(on.messages[0].role == "system"); + TEST_ASSERT(on.messages[0].content == "You are Hermes."); + TEST_ASSERT(on.messages[1].role == "system"); + TEST_ASSERT(on.messages[1].content.find("Conversation started:") == 0); + TEST_ASSERT(on.messages[2].role == "user"); +} + // ── Prefix-aware eviction policy (model-free) ─────────────────────────── TEST_CASE(ServerUnitFixture, test_evict_empty_is_zero) { From a54364ef21885433f2c3aa4b9a08d59893ea2d55 Mon Sep 17 00:00:00 2001 From: David Roth Date: Fri, 31 Jul 2026 09:22:50 -0500 Subject: [PATCH 3/5] fix(ppp): gate DiffPin token rewrite behind DFLASH_PPP_REARRANGE Token-level prefix|suffix|middle peels were still applied with rearrange=0, which can scramble tool-schema JSON and empty post-tool completions. Default path is now pin-end annotate + sticky protect only. --- server/docs/TOOL_PREFIX_CACHE.md | 10 +++--- server/src/server/http_server.cpp | 57 +++++++++++++++++++------------ 2 files changed, 41 insertions(+), 26 deletions(-) diff --git a/server/docs/TOOL_PREFIX_CACHE.md b/server/docs/TOOL_PREFIX_CACHE.md index 3b05df7f7..7a71604aa 100644 --- a/server/docs/TOOL_PREFIX_CACHE.md +++ b/server/docs/TOOL_PREFIX_CACHE.md @@ -86,7 +86,9 @@ because `main` cannot expose the restored-token counts. ## See also When volatile text (for example a session clock) sits *inside* the first system -message, the first chat boundary cannot exclude it. **DiffPin** diffs the head, -floats that volatility after the stable block, and pins the contiguous prefix. -See [PIN_FRIENDLY_PROMPT.md](./PIN_FRIENDLY_PROMPT.md). Enable/disable with -`DFLASH_PPP` (on by default). +message, the first chat boundary cannot exclude it. **DiffPin** can diff the +head, float that volatility after the stable block, and pin the contiguous +prefix. See [PIN_FRIENDLY_PROMPT.md](./PIN_FRIENDLY_PROMPT.md). `DFLASH_PPP` +(on by default) enables LCP pin-end annotation + sticky protect; +`DFLASH_PPP_REARRANGE=1` opts into the token-level float rewrite (off by +default — unconstrained peels can scramble tool JSON). diff --git a/server/src/server/http_server.cpp b/server/src/server/http_server.cpp index a0eb23b54..db5a19e58 100644 --- a/server/src/server/http_server.cpp +++ b/server/src/server/http_server.cpp @@ -2708,31 +2708,44 @@ HttpServer::GenerationCacheState HttpServer::prepare_generation_cache( const bool prefer_tools_boundary = prefer_inline_snap; int forced_cut = req.pin_end_token; - // PPP runs *before* lookup: rewrite the tools/system head into - // [shared prefix][shared suffix][volatile][end markers] so cache keys - // match across session-clock drift. + // PPP runs *before* lookup. Default (rearrange=0): annotate a sticky + // pin_end only — never mutate tokens. Token-level DiffPin rewrite + // (prefix|suffix|middle float) is opt-in via DFLASH_PPP_REARRANGE=1; + // unconstrained middle peels can scramble tool-schema JSON and yield + // empty post-tool completions. if (config_.ppp_enabled && prefer_tools_boundary) { const auto boundaries = find_all_boundaries( effective_prompt, prefix_cache_.chat_markers()); - auto rewrite = PinFriendlyPrompt::diff_make_pin_friendly( - effective_prompt, boundaries, recent_tool_prefixes_, - prefix_cache_.chat_markers(), - config_.ppp_lcp_window, config_.ppp_min_pin_tokens, - config_.ppp_max_ephemeral_tokens); - if (rewrite.rewritten) { - effective_prompt = std::move(rewrite.tokens); - generate_request.prompt = effective_prompt; - std::fprintf(stderr, - "[ppp] diff-rewrite prefix=%d suffix=%d middle=%d " - "pin_end=%d prompt=%zu\n", - rewrite.prefix_len, rewrite.suffix_len, rewrite.middle_len, - rewrite.pin_end, effective_prompt.size()); - } - if (forced_cut <= 0) forced_cut = rewrite.pin_end; - if (forced_cut > 0 && !rewrite.rewritten) { - std::fprintf(stderr, - "[ppp] pin_end=%d (no rewrite; prompt=%zu)\n", - forced_cut, effective_prompt.size()); + if (config_.ppp_rearrange) { + auto rewrite = PinFriendlyPrompt::diff_make_pin_friendly( + effective_prompt, boundaries, recent_tool_prefixes_, + prefix_cache_.chat_markers(), + config_.ppp_lcp_window, config_.ppp_min_pin_tokens, + config_.ppp_max_ephemeral_tokens); + if (rewrite.rewritten) { + effective_prompt = std::move(rewrite.tokens); + generate_request.prompt = effective_prompt; + std::fprintf(stderr, + "[ppp] diff-rewrite prefix=%d suffix=%d middle=%d " + "pin_end=%d prompt=%zu\n", + rewrite.prefix_len, rewrite.suffix_len, rewrite.middle_len, + rewrite.pin_end, effective_prompt.size()); + } + if (forced_cut <= 0) forced_cut = rewrite.pin_end; + if (forced_cut > 0 && !rewrite.rewritten) { + std::fprintf(stderr, + "[ppp] pin_end=%d (no rewrite; prompt=%zu)\n", + forced_cut, effective_prompt.size()); + } + } else if (forced_cut <= 0) { + forced_cut = PinFriendlyPrompt::annotate_pin_end( + effective_prompt, boundaries, recent_tool_prefixes_, + config_.ppp_lcp_window, config_.ppp_min_pin_tokens); + if (forced_cut > 0) { + std::fprintf(stderr, + "[ppp] pin_end=%d (pin-only; prompt=%zu)\n", + forced_cut, effective_prompt.size()); + } } const auto remember_bounds = find_all_boundaries( effective_prompt, prefix_cache_.chat_markers()); From 55bdbbee8fe84c97be782235b905dc5b7373f1a1 Mon Sep 17 00:00:00 2001 From: David Roth Date: Fri, 31 Jul 2026 13:46:04 -0500 Subject: [PATCH 4/5] fix(ppp): DiffPin head cut + full-cache key after rewrite Cut the rewrite head at the first end-of-message marker (not after the next role-start), no-op when chat boundaries are missing, and key full-prompt snapshots by the effective tokens after a DiffPin rewrite. --- server/docs/PIN_FRIENDLY_PROMPT.md | 2 + server/src/server/http_server.cpp | 36 ++++++++++++++--- server/src/server/http_server.h | 6 ++- server/src/server/pin_friendly_prompt.cpp | 40 +++++++++++++++--- server/src/server/pin_friendly_prompt.h | 21 +++++----- server/test/test_server_unit.cpp | 49 ++++++++++++++++++++++- 6 files changed, 130 insertions(+), 24 deletions(-) diff --git a/server/docs/PIN_FRIENDLY_PROMPT.md b/server/docs/PIN_FRIENDLY_PROMPT.md index 0ceb677ca..b9241672c 100644 --- a/server/docs/PIN_FRIENDLY_PROMPT.md +++ b/server/docs/PIN_FRIENDLY_PROMPT.md @@ -155,6 +155,8 @@ flowchart TD End-of-message markers (the “this turn is over” tokens) stay at the end of the head so the chat shape still reads as a coherent system turn. +The rewrite head stops at the **first end-of-message marker**, not at the PrefixCache chat boundary. Those boundaries sit *after* the next role-start; cutting there would let a floated middle cross into the user turn. With no chat boundaries (custom templates), DiffPin does not rewrite. + --- ## What you should feel in production diff --git a/server/src/server/http_server.cpp b/server/src/server/http_server.cpp index db5a19e58..ea46cc22d 100644 --- a/server/src/server/http_server.cpp +++ b/server/src/server/http_server.cpp @@ -1876,6 +1876,18 @@ bool HttpServer::route_request(SocketHandle fd, const HttpRequest & hr) { auto layout = PinFriendlyPrompt::rearrange(chat_messages, true); if (layout.rearranged) { render_messages = std::move(layout.messages); + // Keep FlowKV / response formatting aligned with the served + // layout (FlowKV re-renders from req.messages). + if (req.messages.is_array() && !req.messages.empty() && + req.messages[0].value("role", "") == "system" && + render_messages.size() >= 2) { + req.messages[0]["content"] = render_messages[0].content; + json meta = { + {"role", "system"}, + {"content", render_messages[1].content}, + }; + req.messages.insert(req.messages.begin() + 1, std::move(meta)); + } std::fprintf(stderr, "[ppp] rearranged: peeled ephemeral system tail\n"); } @@ -2713,6 +2725,7 @@ HttpServer::GenerationCacheState HttpServer::prepare_generation_cache( // (prefix|suffix|middle float) is opt-in via DFLASH_PPP_REARRANGE=1; // unconstrained middle peels can scramble tool-schema JSON and yield // empty post-tool completions. + bool ppp_rewrote = false; if (config_.ppp_enabled && prefer_tools_boundary) { const auto boundaries = find_all_boundaries( effective_prompt, prefix_cache_.chat_markers()); @@ -2725,6 +2738,11 @@ HttpServer::GenerationCacheState HttpServer::prepare_generation_cache( if (rewrite.rewritten) { effective_prompt = std::move(rewrite.tokens); generate_request.prompt = effective_prompt; + ppp_rewrote = true; + // Full-cache hits/keys from unrearranged tokens are stale. + prepared.full_cache_hit_slot = -1; + prepared.full_cache_hit_len = 0; + prepared.full_cache_served_tokens = -1; std::fprintf(stderr, "[ppp] diff-rewrite prefix=%d suffix=%d middle=%d " "pin_end=%d prompt=%zu\n", @@ -2762,11 +2780,14 @@ HttpServer::GenerationCacheState HttpServer::prepare_generation_cache( cache.prefix_len = prepared.full_cache_hit_len; cache.using_restore = cache.cache_slot >= 0; cache.disk_policy = req.disk_cache_policy; + cache.full_snap_key_effective = ppp_rewrote; - // Exact raw-prompt snapshots take priority over inline turn boundaries. + // Exact full-prompt snapshots. After a DiffPin rewrite, key by the + // tokens we actually serve (effective_prompt), not the client wire form. if (!cache.using_restore) { - auto [full_slot, full_len] = - prefix_cache_.lookup_full(req.prompt_tokens); + const auto & full_key = + ppp_rewrote ? effective_prompt : req.prompt_tokens; + auto [full_slot, full_len] = prefix_cache_.lookup_full(full_key); if (full_slot >= 0) { cache.cache_slot = full_slot; cache.prefix_len = full_len; @@ -3015,8 +3036,9 @@ HttpServer::GenerationCacheState HttpServer::prepare_generation_cache( cache.snap_cut = prepared_snapshot.second; }; auto prepare_full = [&]() { - cache.full_snap_slot = - prefix_cache_.prepare_full_snap(req.prompt_tokens); + const auto & full_key = cache.full_snap_key_effective + ? effective_prompt : req.prompt_tokens; + cache.full_snap_slot = prefix_cache_.prepare_full_snap(full_key); if (cache.full_snap_slot >= 0) { cache.full_snap_pos = (int) effective_prompt.size(); generate_request.snap_slot = cache.full_snap_slot; @@ -3093,8 +3115,10 @@ void HttpServer::finalize_generation_cache( backend_.snapshot_cur_pos(cache.full_snap_slot); if (saved_position > 0 && saved_position <= cache.full_snap_pos) { + const auto & full_key = cache.full_snap_key_effective + ? effective_prompt : req.prompt_tokens; prefix_cache_.confirm_full_snap( - cache.full_snap_slot, req.prompt_tokens, saved_position); + cache.full_snap_slot, full_key, saved_position); } else { backend_.snapshot_free(cache.full_snap_slot); prefix_cache_.abort_full_snap(cache.full_snap_slot); diff --git a/server/src/server/http_server.h b/server/src/server/http_server.h index d3aedb540..edecad920 100644 --- a/server/src/server/http_server.h +++ b/server/src/server/http_server.h @@ -63,7 +63,8 @@ struct ServerConfig { // Pin-Friendly Prompt Processor (PPP): LCP pin_end + optional rearrange. // See docs/PIN_FRIENDLY_PROMPT.md. Env: DFLASH_PPP=0|1, - // DFLASH_PPP_REARRANGE=0|1, DFLASH_PPP_LCP_WINDOW=N. + // DFLASH_PPP_REARRANGE=0|1, DFLASH_PPP_LCP_WINDOW=N, + // DFLASH_PPP_MIN_PIN_TOKENS=N, DFLASH_PPP_MAX_EPHEMERAL=N. bool ppp_enabled = true; bool ppp_rearrange = false; int ppp_lcp_window = 8; @@ -331,6 +332,9 @@ class HttpServer { int full_snap_slot = -1; int full_snap_pos = 0; bool full_snap_prepared = false; + // When DiffPin rewrote tokens, full-cache keys must use + // prepared.tokens (effective), not req.prompt_tokens. + bool full_snap_key_effective = false; int snap_slot = -1; int snap_cut = 0; bool snap_prepared = false; diff --git a/server/src/server/pin_friendly_prompt.cpp b/server/src/server/pin_friendly_prompt.cpp index 0f82fc5fb..82b6fc2c0 100644 --- a/server/src/server/pin_friendly_prompt.cpp +++ b/server/src/server/pin_friendly_prompt.cpp @@ -108,6 +108,34 @@ int PinFriendlyPrompt::trailing_end_marker_len( return best; } +int PinFriendlyPrompt::tools_system_head_end( + const std::vector & tokens, + const ChatMarkers & markers) { + if (tokens.empty() || markers.end_msg_seqs.empty()) return 0; + int best = -1; + int best_len = 0; + for (const auto & seq : markers.end_msg_seqs) { + if (seq.empty() || (int)seq.size() > (int)tokens.size()) continue; + for (int i = 0; i + (int)seq.size() <= (int)tokens.size(); ++i) { + bool match = true; + for (int k = 0; k < (int)seq.size(); ++k) { + if (tokens[(size_t)(i + k)] != seq[(size_t)k]) { + match = false; + break; + } + } + if (!match) continue; + if (best < 0 || i < best) { + best = i; + best_len = (int)seq.size(); + } + break; // first occurrence of this seq; keep scanning other seqs + } + } + if (best < 0) return 0; + return best + best_len; +} + PinFriendlyRewrite PinFriendlyPrompt::diff_make_pin_friendly( const std::vector & tokens, const std::vector & boundaries, @@ -121,12 +149,14 @@ PinFriendlyRewrite PinFriendlyPrompt::diff_make_pin_friendly( if (tokens.empty() || recent_tool_prefixes.empty() || window <= 0) { return out; } + // Custom / unstructured templates: do not rewrite the whole prompt. + if (boundaries.empty()) return out; - // Only rewrite the tools/system head; leave transcript untouched. - const int head_end = boundaries.empty() - ? (int)tokens.size() - : boundaries.front(); - if (head_end < min_pin_tokens) return out; + // Only rewrite through the first end-of-message marker. find_all_boundaries + // returns cuts *after* the next role-start; using those would let the + // volatile middle float past user/assistant markers. + const int head_end = tools_system_head_end(tokens, markers); + if (head_end <= 0 || head_end < min_pin_tokens) return out; std::vector head(tokens.begin(), tokens.begin() + head_end); const std::vector rest(tokens.begin() + head_end, tokens.end()); diff --git a/server/src/server/pin_friendly_prompt.h b/server/src/server/pin_friendly_prompt.h index 05c54fedf..37db62e4b 100644 --- a/server/src/server/pin_friendly_prompt.h +++ b/server/src/server/pin_friendly_prompt.h @@ -1,6 +1,6 @@ // Pin-Friendly Prompt Processor (PPP) // -// Diffes the tools/system head against recent traffic, isolates the volatile +// Diffs the tools/system head against recent traffic, isolates the volatile // span, and rewrites tokens to: // [shared prefix][shared suffix][volatile middle][end markers] // so PrefixCache can pin the contiguous stable blob. See docs/PIN_FRIENDLY_PROMPT.md. @@ -17,14 +17,6 @@ namespace dflash::common { -struct PinFriendlyPromptConfig { - bool enabled = true; - bool rearrange = false; // optional text-level peel (legacy / explicit) - int lcp_window = 8; // recent tool-bearing prefixes to compare - int min_pin_tokens = 512; - int max_ephemeral_tokens = 256; // only relocate small volatile hunks -}; - struct PinFriendlyLayout { std::vector messages; int pin_end_token = 0; @@ -79,10 +71,17 @@ class PinFriendlyPrompt { int window, int min_pin_tokens); + // Exclusive end of the first tools/system message (through end-of-message + // marker). Chat boundaries from find_all_boundaries() sit *after* the next + // role-start and must not be used as the DiffPin rewrite head. + static int tools_system_head_end(const std::vector & tokens, + const ChatMarkers & markers); + // Diff the tools/system head against recent prefixes and rewrite: // [tokentokentoken] with a mid clock → [tokentoken][token][time][im_end] - // pin_end covers the contiguous stable blob. No-op when history is empty, - // the volatile hunk is too large, or nothing moves. + // pin_end covers the contiguous stable blob. No-op when boundaries are + // empty (custom templates), history is empty, the volatile hunk is too + // large, or nothing moves. static PinFriendlyRewrite diff_make_pin_friendly( const std::vector & tokens, const std::vector & boundaries, diff --git a/server/test/test_server_unit.cpp b/server/test/test_server_unit.cpp index 1d7ed61c8..aa394f3a3 100644 --- a/server/test/test_server_unit.cpp +++ b/server/test/test_server_unit.cpp @@ -1876,7 +1876,9 @@ TEST_CASE(ServerUnitFixture, test_ppp_diff_rewrite_moves_volatile_after_stable) markers.end_msg_seqs = {{50}}; std::vector> ring = {day1}; - const std::vector boundaries = {8}; // head through im_end + // Chat boundaries sit after the next role-start; DiffPin must still cut + // the rewrite head at the first im_end (index 8), not at boundaries.front(). + const std::vector boundaries = {10}; auto rw = PinFriendlyPrompt::diff_make_pin_friendly( day2, boundaries, ring, markers, /*window=*/4, /*min_pin=*/4, /*max_ephemeral=*/16); @@ -1895,6 +1897,51 @@ TEST_CASE(ServerUnitFixture, test_ppp_diff_rewrite_moves_volatile_after_stable) TEST_ASSERT(rw.tokens[8] == 9); } +TEST_CASE(ServerUnitFixture, test_ppp_diff_rewrite_noop_without_boundaries) { + std::vector day1 = {7, 7, 7, 7, 111, 8, 8, 50}; + std::vector day2 = {7, 7, 7, 7, 222, 8, 8, 50, 9, 9}; + ChatMarkers markers; + markers.end_msg_seqs = {{50}}; + std::vector> ring = {day1}; + auto rw = PinFriendlyPrompt::diff_make_pin_friendly( + day2, /*boundaries=*/{}, ring, markers, + /*window=*/4, /*min_pin=*/4, /*max_ephemeral=*/16); + TEST_ASSERT(!rw.rewritten); + TEST_ASSERT(rw.tokens == day2); +} + +TEST_CASE(ServerUnitFixture, test_ppp_diff_rewrite_stops_before_next_role) { + // Realistic boundary: after user role-start (token 90), past im_end (50). + // Volatile middle must not float into the user turn. + std::vector day1 = {7, 7, 7, 7, 111, 8, 8, 50}; + std::vector day2 = {7, 7, 7, 7, 222, 8, 8, 50, 90, 91, 92}; + ChatMarkers markers; + markers.end_msg_seqs = {{50}}; + std::vector> ring = {day1}; + const std::vector boundaries = {11}; // after user role start + auto rw = PinFriendlyPrompt::diff_make_pin_friendly( + day2, boundaries, ring, markers, + /*window=*/4, /*min_pin=*/4, /*max_ephemeral=*/16); + TEST_ASSERT(rw.rewritten); + TEST_ASSERT(rw.tokens.size() == day2.size()); + // Head rewritten; user role tokens untouched at the end. + TEST_ASSERT(rw.tokens[rw.tokens.size() - 3] == 90); + TEST_ASSERT(rw.tokens[rw.tokens.size() - 2] == 91); + TEST_ASSERT(rw.tokens[rw.tokens.size() - 1] == 92); + TEST_ASSERT(rw.tokens[6] == 222); + TEST_ASSERT(rw.tokens[7] == 50); +} + +TEST_CASE(ServerUnitFixture, test_ppp_tools_system_head_end) { + ChatMarkers markers; + markers.end_msg_seqs = {{50}, {51, 52}}; + std::vector ids = {1, 2, 50, 90, 91}; + TEST_ASSERT(PinFriendlyPrompt::tools_system_head_end(ids, markers) == 3); + ids = {1, 2, 51, 52, 90}; + TEST_ASSERT(PinFriendlyPrompt::tools_system_head_end(ids, markers) == 4); + TEST_ASSERT(PinFriendlyPrompt::tools_system_head_end({1, 2, 3}, markers) == 0); +} + TEST_CASE(ServerUnitFixture, test_ppp_split_and_rearrange_ephemeral_tail) { const std::string system = "You are Hermes.\n\n" From 880caccc9e302757504a6a160f28c5bafea35ef0 Mon Sep 17 00:00:00 2001 From: mrciffa <49000955+davide221@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:19:45 +0200 Subject: [PATCH 5/5] fix(ppp): honor master toggle for sticky tool pins --- server/src/server/http_server.cpp | 7 ++++++- server/src/server/http_server.h | 4 ++++ server/test/test_server_unit.cpp | 7 +++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/server/src/server/http_server.cpp b/server/src/server/http_server.cpp index ea46cc22d..60d62cc66 100644 --- a/server/src/server/http_server.cpp +++ b/server/src/server/http_server.cpp @@ -400,6 +400,10 @@ int resolve_max_output_tokens(const json & body, int default_max_tokens) { return default_max_tokens; } +bool ppp_prefers_tools_boundary(bool ppp_enabled, bool has_tools) { + return ppp_enabled && has_tools; +} + // Sampler parameters. When the request omits a value, fall back to the // model card's sampling defaults (spec §3.3); when the card doesn't // supply one either, use the hard-coded default. @@ -2717,7 +2721,8 @@ HttpServer::GenerationCacheState HttpServer::prepare_generation_cache( auto & effective_prompt = prepared.tokens; // Tool-heavy requests prefer the reusable system/tool boundary under eviction. const bool prefer_inline_snap = !req.tools.empty(); - const bool prefer_tools_boundary = prefer_inline_snap; + const bool prefer_tools_boundary = + ppp_prefers_tools_boundary(config_.ppp_enabled, prefer_inline_snap); int forced_cut = req.pin_end_token; // PPP runs *before* lookup. Default (rearrange=0): annotate a sticky diff --git a/server/src/server/http_server.h b/server/src/server/http_server.h index edecad920..dc6654ff5 100644 --- a/server/src/server/http_server.h +++ b/server/src/server/http_server.h @@ -255,6 +255,10 @@ json require_messages_array(const json & body); // selected field is parsed, so malformed lower-priority aliases are ignored. int resolve_max_output_tokens(const json & body, int default_max_tokens); +// Sticky tools-boundary pinning is part of PPP and must follow its master +// toggle. Kept as a small policy helper so the disabled path is testable. +bool ppp_prefers_tools_boundary(bool ppp_enabled, bool has_tools); + // Build the /props response body. Exposed (non-static) so unit tests // can assert on its shape without spinning up a real socket. See // docs/specs/props-endpoint.md for the wire contract. diff --git a/server/test/test_server_unit.cpp b/server/test/test_server_unit.cpp index aa394f3a3..68c07b703 100644 --- a/server/test/test_server_unit.cpp +++ b/server/test/test_server_unit.cpp @@ -1811,6 +1811,13 @@ TEST_CASE(ServerUnitFixture, test_inline_snapshot_prefers_tools_boundary_until_r TEST_ASSERT(select_inline_snapshot_boundary({100}, 100, true) == 0); } +TEST_CASE(ServerUnitFixture, test_ppp_master_toggle_gates_tools_boundary_pinning) { + TEST_ASSERT(ppp_prefers_tools_boundary(true, true)); + TEST_ASSERT(!ppp_prefers_tools_boundary(false, true)); + TEST_ASSERT(!ppp_prefers_tools_boundary(true, false)); + TEST_ASSERT(!ppp_prefers_tools_boundary(false, false)); +} + // ── Pin-Friendly Prompt Processor (PPP) ───────────────────────────────── TEST_CASE(ServerUnitFixture, test_ppp_lcp_and_safe_boundary) {