Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/docs/tasks/67-caching-landed-blind-and-compaction-drift.md
Fimeg 22adbe113d tasks: 67 — what 6eda491 closed, and what it did not
§2 closed, §3 built and half-accepted: the split reaches the message and
serialises, but no test asserts it and no surface renders it. §1's config
change is recorded as mine, not Casey's word. Run 1283 green, r399 shipped
to edge and running on the laptop; the phone has not pulled it.
2026-08-10 20:57:31 -04:00

13 KiB

TASK-67 — the caching landed blind, and compaction drifted from its own doctrine

Status: open, 2026-08-10. §2 and §3 landed in 6eda491; §1 is changed in the live config and waiting on Casey's word. §4 and §5 untouched. Every claim below read from source or the running config, not inferred. Repos: souveraine. Connects to: TASK-65 (the subconscious stall — same subsystem, different defect), saf/memory/01-compaction.md (the design of record), commits a10ef5e6eda491.

Casey, 2026-08-10 18:30Z: "we're going to need another followup session to handle the ensurance that they can both get thtere, and I'm stil not sure if subconsious would cause a problem or not - i fyou can make the change easy proceed"

This is that session. Four commits landed and nothing can see whether they worked; separately, the live config hands the subconscious the one compaction strategy the code comments warn against.

What landed, and the evidence it works

Commit What it did
a10ef5e Cache breakpoints on tools / system / the message tail
dfc89d0 Log the cache split per call
eee26fc Interjection reply no longer erased; and the Summary anchor fix
30e1c34 Provider follows the model, not the agent
6eda491 §3 — the split reaches the message. §2 — sliding_reflect exposed

The caching works. One turn, 2026-08-10 19:31Z: round 0 returned cache_read=0 cache_write=22585, round 1 returned cache_read=22585. The prefix was written and then read. That was the whole proof and it existed only in a log line; §3 below is now built, so the next such turn commits its own numbers with the reply.

Shipped and running, 2026-08-10 20:43Z: CI run 1283 green on all four jobs (rust-test includes cargo test and cargo clippy -- -D warnings), souveraine-0.1.r399.g6eda491496cf published to edge signed for both arches, installed on the laptop, service restarted onto it and loading ~/.souveraine/config.toml. Not yet on the phone.

eee26fc is closed, not open. Summary excluded index 0 from to_summarize because the anchor must not be compressed, then dropped it from keep_indices too — the primary lost persona and covenant on her first compaction. strategy.rs:168 now seeds vec![0] like every other strategy. Verified in the diff. Do not re-open it.

1. The subconscious is configured for blind truncation

The design of record, saf/memory/01-compaction.md:17:

Sliding reflect. The same slide, but first a short pass reads the middle before it goes and writes down what it was carrying… This is the default for the subconscious, who would lose her continuity otherwise.

The code agrees — core/compact/config.rs:66 maps "subconscious" to SlidingReflect, and config.rs:132 says of plain sliding window: "Use SlidingReflect instead unless you explicitly want blind truncation."

The running config does not. ~/.souveraine/config.toml:

[compaction]                            strategy = "sliding_reflect"   # :202
[compaction.per_type.subconscious]      strategy = "sliding_window"    # :209
[compaction.per_type.subagent]          strategy = "sliding_window"    # :219
[compaction.per_type.primary]           strategy = "summary"           # :229

The fallback is right and every per-type override that matters points at the blind one. Primary matches Casey's stated intent. Subconscious and subagent get the slide with no preservation pass — the middle goes and nothing writes down what it was carrying.

This needs Casey's word, not a fix. He said the subconscious "is sliding" by design, and it is; the question is whether he meant reflect (what the doc promises) or window (what the config does). The preservation pass costs one model call per compaction on the subconscious model. If the answer is reflect, it is a one-line config change. If it is window, the doctrine line is wrong and should be corrected instead — the doc must not promise continuity the config withholds.

Changed provisionally, 2026-08-10 19:30Z, on the doctrine's authority rather than Casey's: :209 subconscious sliding_windowsliding_reflect, backup at config.toml.bak-20260810-192950, live since the 20:43Z restart. :219 subagent was left on the blind slide — the doctrine line speaks only for the subconscious, and a fork that loses its middle is cheap. Either half reverts in one line. This is still his call; the change exists so the doc and the config stop disagreeing while he makes it.

kv_target = 16 appears three times in the same file and does nothing — 01-compaction.md:39 records it as pointing at a strategy that was never built. It parses and is discarded. Remove it or build it.

2. The memory tool exposes the dangerous strategy and hides the safe one

core/memory/mod.rs:1350 — the strategy enum the agent can pass to memory compact:

["microcompact", "sliding_window", "summary", "cull"]

sliding_reflect is not in it. So the primary — configured summary, ten messages preserved — can call strategy=sliding_window and blind-truncate her own throughline, and cannot ask for the variant that would have caught it. The one strategy whose doc comment warns against using it is the one she is handed; the one written to preserve her is unreachable.

Either add sliding_reflect to the enum, or drop sliding_window from it and leave blind truncation to config. Both are defensible. Shipping the current pair is not.

Closed in 6eda491 — added, not removed. Checked first that the engine can actually construct it (core/compact/mod.rs:184-212, with a provider fallback); an enum entry that 500s at runtime would have been worse than the hole.

3. The cache measurement has no consumer

dfc89d0 added the counters. Nothing reads them.

  • core/session/mod.rs:112ConversationMessage::assistant_with_usage is defined and has zero callers. ConversationMessage.usage is therefore always None.
  • cache_read_tokens / cache_write_tokens exist in exactly four places: bridge/bifrost.rs:327,329 (the struct), bridge/claude_subscription.rs:389-390 (one log line), :980-981 (the constructor), :1295-1296 (a unit test).

No cost surface, no pressure calculation, no dashboard reads them. bridge/model_router::TokenUsage is a separate tiktoken estimate and is not the same number. The only way to know whether a10ef5e is still working tomorrow is to grep a log.

This is the piece to build first. Everything else in this file is a question that a working readout would answer empirically instead of by argument.

Built in 6eda491, partly. run_turn sums a TokenUsage across the turn's rounds and commits it through assistant_with_usage, so the constructor has a caller and ConversationMessage.usage is Some on every committed reply. It serialises (skip_serializing_if = "Option::is_none"), so any endpoint already returning messages now carries the split. The per-round log line carries cache_read/cache_write on every provider, not only the Anthropic bridge — the DeepSeek subconscious path is legible for the first time.

Still owed on acceptance 1: the regression test asserting a second identical-prefix turn reports a non-zero read, and a surface that renders the number rather than merely serving it. The data is there to render.

4. A system note's legality flips between rounds, and busts the prefix

normalize_system_turns (bridge/claude_subscription.rs:824-839) is enforcing the correct API rule: a role: "system" turn is legal only between a user turn and an assistant turn, or as the last entry. That matches the contract exactly. It is not the bug.

The bug is that legality is recomputed every round against a moving position. A note pushed as the last entry is legal. Next round appends a user turn after it — merged.get(i + 1) is now user, not assistant — so it demotes to user and merges into the turn ahead of it (:833-836). The bytes of the prefix change, and every breakpoint after that point misses.

The fix is not to stop demoting. It is to make the outcome deterministic: either place the note where it will always be followed by an assistant turn, or pre-demote it on the round it is created so its rendered bytes never move.

5. Twenty blocks of lookback, and no spare breakpoint

Confirmed against the API reference: a cache breakpoint walks backward at most 20 content blocks to find a prior entry, and a request may carry at most 4 breakpoints.

push_merge (:890-898) has no cap. A round with 20+ parallel tool calls folds every tool_use into one assistant message and every tool_result into one user message. The next request's breakpoint cannot see past 20 blocks and silently misses — no error, just full-price input.

The documented mitigation is an intermediate breakpoint every ~15 blocks in long turns. We cannot afford one. mark_cache_breakpoints (:849-888) already spends all four: tools, system, and the last block of each of the last two messages. Buying an intermediate breakpoint means giving one of those up.

That is a real design decision, not a slip. Options, in rough order of appeal:

  1. Cap push_merge so a round emits at most ~15 blocks per message, splitting into an extra message pair rather than one fat one. Costs nothing at the breakpoint budget.
  2. Drop to one tail breakpoint and spend the freed one on the intermediate. Loses the round-N/round-N+1 overlap the tail pair exists for.
  3. Do nothing and accept the miss on wide-fan-out rounds. Cheap if wide rounds are rare — which is exactly what §3's readout would tell us.

Ruled out

  • Cache minimum length is not a problem. 512 tokens on claude-opus-5, and the threshold is not monotonic across models (Opus 4.6 and Haiku 4.5 need 4096). Nothing on the primary path is near the floor. The subconscious runs through bifrost to DeepSeek, where Anthropic's cache rules do not apply at all.
  • The persona drop. Fixed in eee26fc, verified in the diff.
  • The four-breakpoint allocation. tools + system + last two messages is the correct spend of the budget. There is no better arrangement, only a different trade (see §5).

Acceptance

  1. Half met. cache_read_tokens / cache_write_tokens reach a surface a human reads without grepping a log — assistant_with_usage is actually called (done, 6eda491), and the numbers land somewhere the Device or Settings page can show (they serialise; nothing renders them yet). A regression test asserts a second identical-prefix turn reports a non-zero read (not written).
  2. Open. Casey's decision recorded on the subconscious strategy, and the config and 01-compaction.md agree afterwards — whichever way it goes. They agree as of 19:30Z, but on my reading of the doctrine, not his word. kv_target removed or built (untouched; there is no KeyValue variant in CompactionStrategyKind, so it parses into a field nothing reads).
  3. Met. The memory compact strategy enum and the config's available strategies are the same set, in both directions — both are exactly microcompact, sliding_window, sliding_reflect, summary, cull, read from core/compact/config.rs:149-170 and core/memory/mod.rs:1350.
  4. Open. A system note keeps byte-identical rendering across at least three rounds of a tool loop, with an interjection racing in. Test it, don't reason about it.
  5. Open, and deliberately demoted. A round with 25 parallel tool calls still gets a cache read on the round after it — or the miss is measured, named, and accepted in this file with the number attached. Re-anchoring every round means the breakpoint is only ever a few blocks behind the previous entry, so the miss needs one genuinely fat parallel round to bite, and it costs money rather than continuity. §3's readout is what says whether that ever happens. Measure first, then argue about the thing measured.
  6. Open. Verified live: a real Surfaces conversation against Souveraine, ten-plus rounds, showing sustained cache reads and a compaction that leaves the throughline intact. The server is on r399 as of 20:43Z, so the next long conversation is the test — nobody needs to stage one.

Notes for whoever picks this up

  • Read saf/memory/01-compaction.md first. It is short, it is the design of record, and §1 above only exists because someone finally diffed it against the running config.
  • Server cwd decides which config loads — ConsciousnessConfig::discover_path() checks ./souveraine.toml before ~/.souveraine/config.toml. The service runs from /home/casey and gets the right one. Anything launched from ~/Projects/souveraine reads the stale gitignored repo copy. Same trap as TASK-65; it has bitten twice now.
  • The subconscious in TASK-65 stalls and blind-truncates. Different defects, same subsystem, and fixing one will not surface the other — do not let a green stall fix read as a green subsystem.
  • Every number in this file was read from source or the live config on 2026-08-10. If you change one, change the argument with it.