Skip to content

feat(pflash): Qwen3.5-0.8B drafter scorer, segment probe and strict budget selection - #746

Draft
Graffioh wants to merge 27 commits into
Luce-Org:mainfrom
Graffioh:codex/pflash-segment-probe-experiment
Draft

Graffioh wants to merge 27 commits into
Luce-Org:mainfrom
Graffioh:codex/pflash-segment-probe-experiment

Conversation

@Graffioh

@Graffioh Graffioh commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

Draft. Runtime changes for PFlash on the Qwen3.5-0.8B drafter: a strict token-budget selector over variable-length segments, a segment probe and an optional scoring head loaded from GGUF, and a chat-first scorer query derived from the rendered prompt. The previous Qwen3-0.6B path is untouched and remains the default.

What changes

Qwen3.5-0.8B drafter (qwen35_drafter.cpp/h, qwen35_loader.cpp, new; qwen3_drafter.cpp keeps the Qwen3-0.6B path and dispatches on DrafterArch; shared helpers in qwen3_drafter_common.cpp/h)

  • --prefill-drafter accepts a Qwen3.5-0.8B GGUF (selected by qwen3.5/qwen35 in the file name). The drafter runs only its first fifteen blocks and scores the context with block 15's NoPE Q/K projections — the same attention-mass scorer the Qwen3-0.6B block-13 head uses — on the model's native 262K context, so inputs beyond the 0.6B drafter's 32K window can be scored.
  • PFLASH_SCORING_HEAD_GGUF loads a replacement block-15 Q/K head (GGUF architecture pflash_scoring_head, keys scoringhead.*, schema qwen3_5_0_8b_nope_qk_mass_v1); the file is bound to the drafter by sha256 and refused otherwise.
  • PFLASH_QWEN35_LEGACY_SCORER=1 keeps the all-layer running-max scorer available on this drafter.
  • Drafter compute errors and non-finite probabilities fail closed: the request errors instead of serving an unscored prompt.

Strict budget selection (pflash_selection.cpp/h, new)

  • PFLASH_SELECT_MODE=budget_only|top_p fills an exact token budget from the keep ratio / --prefill-curve (or a cumulative-mass cutoff), over fixed chunks (PFLASH_SELECT_CHUNK_SIZE, scheduled by input length when unset) or probe segments.
  • PFLASH_SELECT_SCORE=auto|sum|density ranks candidates by total mass or mass density; auto picks density with probe segments, sum with fixed chunks.
  • The query span and required instruction spans are mandatory and always retained; the selector reports stop=budget_reached|mandatory_query_exceeds_budget|… and the re-encode is retried when the rendered prompt overshoots the ceiling.
  • Two-scorer split budget (PFLASH_SELECT_SCORER=head|legacy|split, PFLASH_SELECT_SPLIT): the head ranks first, the all-layer scorer fills the remainder.

Segment probe (PFLASH_SEGMENT_PROBE_GGUF)

  • A 264K-parameter network on the block-14 tap scores every token for "a new unit of text starts here" (schema qwen3_5_0_8b_segment_probe_v1; threshold, min/max segment length and conv taps come from the GGUF metadata, sub-unit head for the oversize split rule). The context is cut at boundaries above threshold plus the query start and instruction-span edges, and the selector ranks whole functions, classes, files or paragraphs instead of fixed windows, skipping segments that do not fit the remaining budget. Falls back to fixed chunks when fewer than four boundaries are found. PFLASH_SELECT_SEGMENTS=auto|fixed|probe overrides.

Chat-first scorer query (http_server.cpp/h, prefix_cache.cpp/h markers)

  • Production PFlash is chat-first. The scorer query is the prompt's last token: the end of the generation prompt, where the model starts answering, having read the whole request. Nothing is parsed out of the user's text, so the question can sit anywhere in the message (before a pasted document, in the middle of it, among the user's own sentences). Turns are found by the rendered prompt's own control markers (Qwen <|im_start|>…<|im_end|>, DeepSeek <|User|>/<|Assistant|>, Gemma <|turn>…<turn|>, Laguna <user>…</user>) rather than by message bookkeeping. Marker strings are resolved on the target tokenizer and searched in the decoded prompt, so a drafter whose vocabulary lacks the control tokens still maps correctly.
  • An assistant turn left open at the prompt end is the generation prompt, whatever think or channel prefix the template adds (<|im_start|>assistant\n<think>\n, <|Assistant|></think>, <|turn>model\n<|channel>thought…). It is never the query. Tool output wrapped in a user turn (<tool_response>, <tool_result>) doesn't count as the user's turn.
  • The tail (PFLASH_SELECT_QUERY_TOKENS) of the latest user turn scores as a second query window at the same weight: the last token reads the whole request, the user's own tokens match literal strings (an identifier, a described function) the last token does not carry. On SCBench's multi-turn chats (36 chats, 171 turns, Jev/SCBench metrics) this answers 135 turns against 112 for the last token alone, 127 with SCBench's question sent as pflash_query, and 140 for full prefill.
  • The generation prompt and the latest user turn's role header (<|im_start|>user\n) are kept; the latest user turn follows the same skeleton rule as every other turn (whole when short, otherwise scored). A prompt without chat markers scores the tail (PFLASH_SELECT_QUERY_TOKENS, default 8) of its content.
  • Agent loops: assistant and tool turns after the user's turn are ordinary context, scored like the rest of the conversation; only the generation prompt stays pinned.
  • Measured on the dev chats re-laid out with the question first, in the middle, among the user's own sentences, two questions, and an email thread (252 turns, Jev-judged): 197/252 against 155/252 for the previous tail query and 231/252 for full prefill; turn 1 36/42 vs 20/42. The original layout is unchanged (37/42).
  • PFLASH_SELECT_QUERY_PARSER=arbitrary_tail is the default; latest_user remains selectable for benchmark comparisons. Marker-less prompts and latest_user still find the boundary through sentinel re-renders.
  • pflash_query stays a benchmark-only override. It replaces the derived query, is mapped against the decoded prompt text (BPE merges such as What stay inside the span), and is kept whole, with the scorer consuming only its bounded tail. pflash_required spans are kept verbatim under compression.
  • Tests build their prompts with render_chat_template for all four families, with thinking on and off, instead of hand-written strings.
  • Compression trace (PFLASH_TRACE_PATH, JSONL): input ids, segments, per-candidate scores, selected chunks, forced chunks, budget, mass, parser record (input kind, selection rule, parser name, fingerprints, content/query bounds).

Budget over droppable tokens

  • The strict budget is kept + keep_ratio × (input − kept). Kept means the system and developer messages, tool definitions, the query with its turn's envelope, and the generation prompt, counted with the selector's own mandatory-chunk rule. Both the selector budget and the target-token ceiling follow it. Before, a system prompt larger than keep_ratio × prompt failed the request.
  • Auto mode compares --prefill-threshold with the droppable tokens.

Multi-turn ownership and views

  • With a strict-selection environment, PFlash owns every turn instead of failing closed or routing to FlowKV. Only request-scoped FlowKV disk compression (disk_cache_policy.compress) is still rejected. Without a selection environment the legacy path is unchanged: continuations go to FlowKV and full prefix-cache restores stay enabled.
  • Views. The server remembers the prompt it served for each conversation, matched by raw prompt prefix up to the old generation prompt, since most clients send no session id. The next turn serves that view plus the new turns, and the target restores its prefix-cache snapshot of the view.
  • Snapshot hint. The view asks for its snapshot at the start of its generation prompt (PreparedPrompt::snapshot_cut); the default second-to-last boundary lands on the system prompt of a first turn.
  • Recall. The fresh compression still runs each turn. Segments it keeps for the new question that the view lacks (CompressResult::kept_spans) are recalled as excerpts at the start of the new user turn, after everything cached, so the view always contains the fresh selection. Agent steps (assistant call plus tool output) append without recalling.
  • Recall gate. Recall takes every out-of-view segment the new question attends to at lift >= PFLASH_CHAT_RECALL_MIN_LIFT (default 2; lift = mass per token relative to uniform attention); there is no token cap. A question that needs more than a third of a fresh selection rebuilds the view from it instead. On the development chats (Jev-judged): gate 2 212/252 against 200 at 4 and 197 at 8 on the query-layout chats (full prefill 231), 37 against 37 of 42 on the original chats, ~190 recalled tokens per turn, later-turn TTFT 1.01 s (full prefill 1.42 s). Non-finite head scores (seen once, not reproduced) rescore the prompt from scratch before failing.
  • Rebuild. The view is rebuilt from the fresh prompt when it grows past twice the fresh prompt or past the context. PFLASH_CHAT_VIEW=0 serves the fresh compression every turn.
  • Live on the R9700 (27B target, 0.8B drafter, keep 0.1, 20.6K-token conversation, three turns): all answers correct. Turn 2 recalls the segment turn 1 dropped. Target prefill falls from 7.7 s to 2.8 s on turn 2 and from 2.7 s to 0.9 s on turn 3. The drafter still reads the whole history each turn.

Dialogue skeleton and history queries

  • Under strict selection every other turn keeps its role header, and user turns and assistant answers up to PFLASH_CHAT_SKELETON_TOKENS (default 256) stay whole. Like developer text, the skeleton is scored as context when it alone would not fit.
  • The tails of the last PFLASH_CHAT_HISTORY_QUERIES (default 3) earlier user turns score the context alongside the query (CompressRequest::history_query_spans), their masses mixed in at weights 1/2, 1/4, 1/8. Drafter sessions keep the rows of up to 8 recent query windows.
  • Live (20.6K tokens, 3 turns): turn 2 recalls 64 tokens instead of 1472 and takes 1.0 s instead of 3.9 s; all answers correct.

usage.timings.pflash

  • Compressed responses report compress time, drafter input, kept and compressed tokens, effective keep ratio, query rule, history-query count, drafter session resume, new tokens and forward time, and the view outcome (fresh, continue, rebuild or repeat, with served, reused, delta, recalled and fresh tokens).

Drafter scoring sessions

  • The strict scorer keeps up to PFLASH_DRAFTER_SESSIONS (default 2) per-conversation sessions: a blocks-0..14 cache, the block-15 NoPE keys, the probe's raw logits, the last query window's rows, and a recurrent-state checkpoint 64 tokens before the prompt end.
  • A prompt sharing a session's prefix runs only its new tokens through the drafter and scores against every stored key.
  • Sessions live with the loaded drafter, so they need --draft-residency persistent (plus --prefill-skip-park where both models fit).
  • Live: with 20.6K tokens and three turns, drafter forward goes 3.9 s → 0.04 s on turn 2 and 3.3 s → 0.13 s on turn 3. Selections, served prompts and answers match scoring from scratch, and turns take 3.9 s and 3.3 s instead of 8.8 s and 6.2 s. An agent step reuses the stored query rows and runs only the new tool output.

Adaptive keep ratio (adaptive_keep_ratio.h): a new session starts from the configured ratio for its prompt length (--prefill-keep-ratio / --prefill-curve) instead of a fixed 10 %; the DFlash-acceptance controller then moves it within 2.5–20 %.

IPC daemon (pflash_drafter_ipc*.cpp/h, pflash_drafter_ipc_daemon.cpp, pflash_types.h, model_backend.h): drafter architecture, probe and head paths and the selection config travel over the drafter IPC; pflash_daemon runs the drafter standalone.

Tests: test_pflash_selection.cpp (selection modes, mandatory spans, density vs sum, probe segments, fallback, parser defaults), test_server_unit.cpp (chat-tail span extraction for Qwen/DeepSeek/open-ended/marker-less prompts, query mapping, required text, strict continuation ownership, FlowKV fallback, trace), test_pflash_drafter_ipc.cpp (config transport), test_bandit_integration.cpp.

Not in this PR

  • No model files: the drafter, probe and head GGUFs are loaded from paths given on the command line / environment.
  • The Qwen3.5 attention runs dense through ggml_flash_attn_ext; the block-sparse FlashPrefill kernels still dispatch head dimension 128 only.

Known limitations

  • The scorer query width is no longer capped at 8 tokens on the drafter IPC (the previous validator); widths 1–512 round-trip, 0 and negatives fail closed.
  • PFlash never compresses the system prompt: a system prompt that alone does not fit the context fails the request, and one that fits is kept whole (a document pasted into it is not compressed). Developer messages and tool definitions that alone would not fit lose their pin and are scored like any other context.
  • An explicit pflash_query far from the end of its own user turn makes the kept suffix exceed the budget, and the request fails the same way.
  • The remote drafter IPC and the running-max scorer (alone or in the split) keep the old kept-suffix contract, so agent-loop turns after the query are not scored there. The remote drafter reports no kept spans, so multi-turn views are off with it.
  • Multi-turn: a large new tool output that would more than double the view triggers a rebuild, so it is compressed on arrival instead of kept verbatim. Drafter sessions are freed with the drafter under the default residency.
  • The document-mass prior and forced document headers from earlier commits were reverted: PFlash scores the prompt against the query and nothing else.

🤖 Generated with Claude Code

Graffioh and others added 3 commits September 19, 2026 14:08
…ing head

Teach the PFlash drafter to score with a Qwen3.5-0.8B hybrid prefix instead
of only the Qwen3-0.6B tail-attention scorer. The new drafter lives in
qwen35_drafter.cpp behind qwen35_drafter_score_and_compress, its GGUF and
optional companion files load through qwen35_loader.cpp, and the pieces both
architectures share moved into qwen3_drafter_common.cpp. An optional scoring
head replaces the block-15 Q/K projections and an optional segment probe
proposes variable-length candidates instead of fixed chunks; both are GGUF
files validated against an explicit contract and fail closed when it is not
met. pflash_selection.{cpp,h} turns the PFLASH_SELECT_* environment into a
strict budget selector that ranks candidates, honours structurally required
spans and stops at the token budget.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ression trace

Wire the request path to the strict selector. A request may now carry an
explicit `pflash_query`, mapped to drafter tokens by the parser rules, and
`required_text` spans that must survive compression; tool definitions and the
instruction structure of the chat template are mapped the same way and passed
to the drafter as required spans. Compression failures fail closed instead of
silently falling back, and a trace records the resolved config, the mapped
spans and the retained budget. The adaptive keep-ratio controller now seeds a
new session from the configured curve for its prompt length rather than a
fixed default. The drafter IPC gains compress2/compress3 so a remote drafter
receives the unquantized keep ratio, the query window and the required spans,
with one formatter and one parser shared by both ends. The server unit tests
move with the query-mapping API they exercise, since the older helpers this
replaces had no other callers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Cover the strict selector's budget, ordering and required-span rules in
test_pflash_selection.cpp, and the compress2/compress3 wire format — its
round trips, the legacy quantized parser and its malformed inputs — in
test_pflash_drafter_ipc.cpp, including a case that shows a remote drafter
selects exactly what the local one would. Add parser coverage for the
explicit `pflash_query` request field at both the top level and inside
extra_body. Document the Qwen3.5-0.8B drafter, the two optional GGUF files
and the PFLASH_SELECT_* contract in the server README.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Graffioh and others added 24 commits September 20, 2026 10:43
… scorer

src/qwen3/ had become a catch-all mixing three things: the Qwen3-0.6B
standalone inference backend, the legacy Qwen3-0.6B PFlash drafter, and
the Qwen3.5-0.8B PFlash scorer that replaced it. Split along the real
boundary:

- src/pflash/ now owns the whole PFlash pipeline: pflash_drafter (was
  qwen3_drafter), pflash_compress (was qwen3_drafter_common),
  pflash_selection, qwen35_drafter/qwen35_loader, kvflash_drafter_scorer
  (was qwen3_kvflash_scorer), anchor_scan/anchor_params.
- src/qwen3/ keeps only standalone Qwen3-0.6B inference, with the
  drafter terminology removed: qwen3_model.h (Qwen3Weights,
  load/free_qwen3_model).
- The Qwen3-0.6B PFlash path is deleted outright: qwen3_graph.cpp,
  qwen3_buffer_plan.h, score_range.h, DrafterArch dispatch, the 0.6B
  scoring-head loader, and the tests that covered them. Qwen3.5-0.8B is
  now the only scorer; the optional drafter_arch command arg is still
  accepted but ignored.
- PFlash selection symbols move from dflash::qwen3 to dflash::pflash.
- score_query_end < 0 ("legacy tail window") is still on the
  CompressRequest/IPC wire formats; backends and the IPC daemon now
  translate it to input_ids.size() at the boundary since the qwen35
  scorer requires an explicit end.
- Default/located drafter paths, help text, docs and scripts point at
  Qwen3.5-0.8B-BF16.gguf.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
PFlash is the whole compression concept; pflash_drafter is the scorer
model behind it — Qwen3.5-0.8B for now, not permanently. Make the seam
explicit in the file headers instead of implying the coupling is fixed.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The budget selector fills a fixed fraction of the prompt by descending
segment score, but on compact-evidence traffic the evidence sits in the
first few ranks, so a rank rule reaches it for far fewer tokens. TopK
keeps the mandatory candidates and then the K highest-scoring optional
ones in score order, with the token budget still a hard ceiling and
oversized candidates skipped exactly as budget_only does, and stops with
top_k_reached when K binds before the budget. PFLASH_SELECT_TOPK is
required and validated in that mode, the config line and the compression
trace both carry the K that applied, and split selection rejects the mode
rather than quietly keeping up to 2K segments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Per-segment density ranking throws away what the head already knows at
document level: on BRIGHT the gold document ranks first by aggregated
mass in 16 of 28 prompts while its median gold segment ranks 68 of 215.
PFLASH_SELECT_DOC_PRIOR scales an optional candidate's ranking score by
its document's share of the prompt's total mass raised to the exponent,
so a document the head likes as a whole lifts its own segments. Zero,
the default, leaves the ranking untouched, and the prior applies in
every mode, so it composes with top_k.

Documents come from a new pflash_documents request field or, absent it,
from the served prompt's own "Document <n>:" and [DOC-<n>] markers;
fewer than three documents makes the prior a no-op, which the trace
records with the exponent and the document count. The drafter IPC
compress protocol carries no document spans, so the prior is inert on
that path until the wire format gains them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keeping a document's body while dropping the header that names it leaves
the model unable to cite what it is quoting, and the score ranking drops
headers first because they are short and low density.
PFLASH_SELECT_FORCE_DOC_HEADS keeps the first segment of each of the D
highest-mass documents, charged after the structurally required spans
and before the fill, so a header can never starve a mandatory span and a
header that no longer fits is dropped rather than fatal. It reuses the
document detection the prior added and composes with the prior and with
top_k. The trace and the selector log record the configured D and the
headers actually forced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Production PFlash is now chat-only: the scorer query is the last N
tokens of the final message's content, located by the rendered
prompt's own control markers (ChatMarkers) instead of benchmark
message bookkeeping. Template machinery ("<|im_start|>", role names,
"<|im_end|>", generation prompts) is never scored, and the last
turn's role header is pinned mandatory so compression keeps the role
envelope. Marker-less prompts and the latest_user parser keep the
sentinel-render path; pflash_query stays a benchmark-only override,
mapped against decoded text and pinned in full while the scorer
consumes its bounded tail.

Strict selection now owns multi-turn chit-chat: continuations run
whole-prompt PFlash on the full history plus current turn instead of
failing closed or routing to FlowKV. Request-scoped FlowKV disk
compression remains the only rejected combination; unconfigured
requests keep the legacy FlowKV continuation path.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
PFlash scores the input prompt against the query and nothing else: no
document detection, no client pflash_documents ranges, no mass prior
and no forced document headers. Reverts 2455efb and 739a8c3; the
decoded-offset helpers they introduced stay because the chat-tail
query mapping uses them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every chat template appends a think or channel prefix after the
assistant generation marker ("<|im_start|>assistant\n<think>\n",
"<|Assistant|></think>", "<|turn>model\n<|channel>thought..."). The
marker-based tail extractor required only whitespace there, so on real
prompts it took the generation prompt as the last message: the scorer
query became "<think>", the assistant header was pinned instead of the
user's, and pflash_required failed to map. The legacy unconfigured
path used the same span and regressed with it.

The extractor now scans the rendered prompt's turns: an assistant turn
left open at the end is the generation prompt whatever follows its
marker, tool output wrapped in a user turn does not count, and the
query comes from the latest user turn (else the latest turn with
content). The derived query then takes the explicit pflash_query path:
its span is the scorer window, pinned under strict selection together
with the turn's role header; an explicit pflash_query still replaces
it. Tests render prompts through render_chat_template for Qwen, Gemma,
DeepSeek and Laguna with thinking on and off, instead of hand-written
strings that omitted the think prefix. The stale default-parser
assertion is updated, and the BPE tokenizer fixture now writes
per-process paths so parallel ctest runs stop overwriting each other.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
In an agent loop the latest user turn is followed by assistant and tool
turns. Selection treated every token after the query window as a kept
suffix and the head scorer masked those keys, so the tool output was
pinned whole: a 2K-token tool result against a 0.3 keep ratio failed
with mandatory_query_exceeds_budget.

The prompt minus the query is now the candidate pool whatever side of
the query it sits on. When turns follow the query's turn, the server
pins only the rest of that turn through its closing marker and the
generation prompt, and sets CompressRequest::query_suffix_candidates.
The block-15 head then scores keys after the query window too (NoPE:
no position term) and the selector stops treating that suffix as
structural. A query in the final user turn keeps the old contract, so
single-turn and plain multi-turn requests select exactly as before.
The running-max scorer, alone or in the split, and the remote drafter
IPC keep the kept-suffix contract.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Strict selection keeps system and developer messages, tool definitions,
the query with its turn's envelope and the generation prompt, and it
charged them against keep_ratio x the whole prompt. A 15K system prompt
under the default 5% ratio exhausted the budget of anything shorter
than 300K tokens and the request failed with
mandatory_query_exceeds_budget.

The server now counts the kept tokens with the selector's own
mandatory-chunk rule and passes an effective ratio,
(kept + keep_ratio x (input - kept) + 1) / input, so the selector budget
and the target-token ceiling both add the kept part on top of the
ratio's share of the rest. Auto mode compares the threshold with the
droppable tokens, so a large system prompt plus a short chat is served
as is. Instructions that alone would not fit the context (a document
pasted into the system prompt) lose their pin and compete for the
budget against the query.

Live on the R9700 (27B target, 0.8B drafter, keep 0.3, 16K context): a
2K system-prompt document answers instead of failing; a 19K one
compresses to 5.8K and answers correctly; the four chat shapes still
answer, keeping their kept tokens on top (2237 -> 701 instead of 637).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A system prompt that alone does not fit the context now fails the
request instead of losing its pin: PFlash does not compress system
prompts. Developer messages and tool definitions that would not fit
still lose their pin and are scored like any other context.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Strict selection re-selected the whole history on every turn, so each
turn's compressed prompt diverged from the last early on and the target
prefilled it from scratch; evidence the model had just used could also
vanish between turns.

The server now remembers the prompt it served for each conversation,
matched by raw prompt prefix up to the old generation prompt (most
clients send no session id). The next turn serves that view plus the
new turns. The fresh compression still runs: whatever it keeps for the
new question that the view lacks is recalled as excerpts at the start
of the new user turn, after everything the target cached, so the view
always holds the fresh selection. The view is rebuilt from the fresh
prompt when it outgrows twice the fresh prompt or the context. The
selector reports its kept spans (CompressResult::kept_spans) for this,
and the view asks for its prefix-cache snapshot at the start of its
generation prompt, where the next turn branches off; the default
second-to-last boundary lands on the system prompt of a first turn.
PFLASH_CHAT_VIEW=0 turns views off.

Live on the R9700 (27B target, 0.8B drafter, keep 0.1, 20.6K-token
conversation): three turns answer correctly, turn 2 recalls the
segment turn 1 dropped, and target prefill drops from 7.7 s to 2.8 s
(turn 2, 2048 tokens restored) and from 2.7 s to 0.9 s (turn 3, 3584
restored). The drafter still reads the whole history each turn.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Qwen3.5 drafter re-read the whole prompt on every call, so every turn
of a conversation paid a forward over its entire history (about 4 s at
20K tokens on the R9700) even when only the last few hundred tokens were
new.

Blocks 0..14 read left to right, so the cache state and the block-15 keys
of a shared prefix never change, and NoPE keys carry no position. The
strict scorer now keeps up to PFLASH_DRAFTER_SESSIONS (default 2) scoring
sessions: a blocks-0..14 cache with headroom, the block-15 keys, the
probe's raw logits, the last query window's block-14 rows, and a
recurrent-state checkpoint 64 tokens before the prompt's end. A prompt
that shares a session's prefix resumes from the session's end or, when it
diverged before it (the previous turn's generation prompt), from the
checkpoint, runs only its new tokens, and scores the query against every
stored key. The query rows are reused while the query stays put (an
agent step appends tool output after the same user turn). Anything else
starts the least recently used session over; any failure forgets the
session's prompt. Sessions live with the loaded drafter.

Live on the R9700 (27B target, 0.8B drafter resident, keep 0.1, 20.6K
tokens, three turns): drafter forward 3.9 s -> 0.04 s on turn 2 (resumed
at 20571, 90 new) and 3.3 s -> 0.13 s on turn 3, with the same selections,
served prompts and answers as scoring from scratch; turns take 3.9 s and
3.3 s instead of 8.8 s and 6.2 s. An agent step resumes at 10993 with the
stored query rows and runs only the 8435-token tool output (1.9 s).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A rebuild (and every turn's fresh selection) scored the conversation
against the latest question alone and could drop the dialogue itself:
earlier questions, the model's own short answers, role headers.

The chat-turn scan now reports every turn. Under strict selection every
other turn keeps its role header, and user turns and assistant answers
up to PFLASH_CHAT_SKELETON_TOKENS (default 256; 0 keeps headers) stay
whole; like developer text and tool definitions, the skeleton is scored
as context when it alone would not fit. The tails of the last
PFLASH_CHAT_HISTORY_QUERIES (default 3) earlier user turns score the
context alongside the current query (CompressRequest::history_query_spans):
the head scores each window against the query's key set and mixes the
masses at weights 1, 1/2, 1/4, 1/8. The drafter session keeps the block-14
rows of up to 8 recent windows, so a new turn's history windows come from
the turns that computed them.

Live on the R9700 (20.6K-token conversation, sessions on): turn 2 recalls
64 tokens instead of 1472 and completes in 1.0 s instead of 3.9 s; turn 3
in 3.0 s; all three answers correct. Mixing in old questions trades the
new question's share of the budget, so both knobs are ablations for the
multi-turn evaluation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Benchmarks could only see PFlash's multi-turn behaviour by scraping
server logs. usage.timings now carries a pflash object on compressed
requests: compress time, drafter input, kept and compressed tokens, the
effective keep ratio, the query rule, the history-query count, the
drafter session's resume point, new tokens and forward time, and the
view outcome (fresh, continue, rebuild or repeat, with served, reused,
delta, recalled and fresh token counts).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…race

A continuing turn appended all its new material verbatim, whatever its
size, and a large paste or tool output only got compressed indirectly,
by outgrowing the view and forcing a rebuild of everything.

What a turn adds is now appended verbatim below
PFLASH_CHAT_COMPRESS_NEW_TOKENS (default 16384), the way full prefill
appends a follow-up; from there on only the parts the fresh selection
keeps of the new material are appended, and the view before it stays
cached (view mode "continue-compressed"). PFLASH_CHAT_RECALL=0 turns
recall off, and then a small follow-up (like a repeated prompt) is served
before any compression runs: no drafter, no scoring, exactly full
prefill's cost on top of the cached view. The view logic is one function
the server calls before compressing (for the turns that need no scoring)
and after.

PFLASH_VIEW_TRACE_PATH appends every compressed request's served prompt
text with its PFlash details as JSONL, so an evaluation can tell evidence
the selection dropped from answers the model got wrong.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On the multi-turn development chats every PFlash failure still had its
gold evidence in the served prompt; the losses came from what surrounded
it. For a content-free follow-up ("which documents support that
answer?") recall filled its budget with 20-odd low-relevance documents
right beside the question. Segment lifts from the head (mass per token
relative to uniform attention) separate evidence (typically 20-160x)
from background (90th percentile about 1x).

The selector now reports every candidate's lift
(CompressResult::candidate_lifts), and recall takes only segments the
view lacks with a lift of at least PFLASH_CHAT_RECALL_MIN_LIFT (default
8), strongest first, up to PFLASH_CHAT_RECALL_TOKENS (default 2048
drafter tokens); without lifts it keeps the previous rule.

PFLASH_SELECT_PARAGRAPH_JOIN=1 (off by default, under test) rebuilds
the compressed text from the kept spans with a paragraph break between
pieces that were not adjacent ("...other bands.Document 1:" otherwise),
for fresh selections and compressed follow-ups; the breaks do not count
against the target-token ceiling.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t-probe-experiment

# Conflicts:
#	server/src/deepseek4/deepseek4_backend.h
#	server/src/qwen35/qwen35_backend.h
On the multi-turn development chats (7 non-held-out 32K sessions, 42
turns) the paragraph join recovered every citation turn (6/6, like full
prefill, against 5/6 without it) and was never worse elsewhere;
PFLASH_SELECT_PARAGRAPH_JOIN=0 turns it off. Single-turn compressed
prompts change accordingly: the article's single-turn numbers were
measured without it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The chat scorer query was the tail of the latest user turn, so a question
before or inside a pasted document was missed. The query is now the
prompt's last token -- where the model starts answering, having read the
whole request -- and nothing is parsed out of the user's text: the latest
turn follows the skeleton rule like the others, the generation prompt is
pinned, and history queries are the last token of the header of the reply
that followed each earlier user turn. Agent tool turns after the user's
become ordinary context. Marker-less prompts keep the content tail;
pflash_query keeps its explicit span.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The 2048-token recall cap never bound once a lift gate was on (at most
~960 tokens on the dev chats). On the development chats (Jev-judged), a gate of 2 against
the previous 8: 212 vs 197 of 252 turns on the query-layout chats (question
first, in the middle, among the user's sentences, two questions, emails;
full prefill 231), 37 vs 37 of 42 on the original chats, at ~190 recalled
tokens per turn and 1.01 s vs 0.81 s median later-turn TTFT (full prefill
1.42 s). Recall now takes every out-of-view segment at lift >= 2
(PFLASH_CHAT_RECALL_MIN_LIFT); PFLASH_CHAT_RECALL_TOKENS is gone. A question
that needs more than a third of a fresh selection rebuilds the view from it
instead: past that, the fresh prompt costs about the same and keeps order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
One request in ~500 of the recall-gate development run failed with
"non-finite Qwen3.5 scoring-head scores" on a fresh chat whose prompt the
other arm scored cleanly; it did not reproduce. The failure already forgets
the scoring session, so score the prompt once more from scratch (one drafter
forward) before failing the request with a 500.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On SCBench's multi-turn chats the prompt-end query answered 112 of 171
turns, the benchmark's explicit question 127: the gap is literal lookups
(an identifier, a described function) the last token does not carry.
Replaying those turns through the drafter, adding the latest user turn's
tail (PFLASH_SELECT_QUERY_TOKENS) as a second query window at full weight
serves the gold passage as often as the explicit query (kv follow-ups 31/32
recallable vs 5/32 from the last token alone; repoqa 40/48 vs 41/48 explicit,
28/48 last token). The last token still reads questions placed anywhere in
the message.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Graffioh added a commit to Graffioh/lucebox-hub that referenced this pull request Sep 24, 2026
…devin/pflash-skip-park-auto

Luce-Org#747 is stacked on Luce-Org#746, which has since moved on 24 commits and already
merged main through the dflash->luce rename. Merging its head first keeps
the stack consistent and reuses Luce-Org#746's resolution of the rename.

Conflicts: the Qwen3 compress retry lambda now calls the Qwen3.5 scorer
with Luce-Org#746's query arguments; the skip-park estimator, guard tests and
server_main strings take the luce:: namespace and LUCE_* env names; the
README and usage keep Luce-Org#746's Qwen3.5-0.8B drafter line next to the
tri-state --prefill-skip-park row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Graffioh added a commit to Graffioh/lucebox-hub that referenced this pull request Sep 24, 2026
Brings the branch up to main 85a25c4 (vision batching, omp harness
test fix, gfx1151 sparse MIX tiling). No conflicts on top of the Luce-Org#746
merge.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant