Skip to content

perf(model): cache all ollama completions in one bounded client middleware (JEF-362) - #189

Merged
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-362-cache-all-ollama-calls-in-a-bounded-model-client-middleware
Jul 6, 2026
Merged

perf(model): cache all ollama completions in one bounded client middleware (JEF-362)#189
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-362-cache-all-ollama-calls-in-a-bounded-model-client-middleware

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

Closes JEF-362

What

Moves completion caching from the per-consumer verdict cache (JEF-350) to the model-client boundary in engine/src/engine/model.rs, so every model consumer is covered by ONE bounded mechanism instead of a cache per consumer. chat() now checks a shared cache before the HTTP call and stores only successful completions.

Middleware shape + key

  • Key = DefaultHasher (SipHash) over endpoint + the request body, canonicalized with sorted object keys (array/message order preserved). The body carries everything that determines the response: model name, messages/prompt, temperature, max_tokens. So only a byte-identical request hits.
  • Hit → return the cached completion, NO HTTP. Miss → call ollama, store, return.

Bounding + env

  • Bounded in-memory Mutex<LruCache<u64, String>>, capacity default 512, overridable via PROTECTOR_MODEL_CACHE_ENTRIES (unset / unparseable / 0 → default).
  • The lock is a tiny sync critical section in cache::get/cache::put; it is never held across the HTTP await, so the cache sits in front of the JEF-337 concurrent dispatch without reintroducing serialization.

Cache successes only

Transport errors, non-success statuses (500/502/503), over-cap bodies, and unparseable/shapeless replies all short-circuit to None before the store — they are never cached and retry next pass.

keep_warm bypass

keep_warm does not route through the cache (it never calls chat), so a keep-alive ping always hits the wire — caching it would let the model unload. Covered by a test.

Kept intact

JEF-350's deterministic verdict prompt and the decisive-verdict journal persistence (restart survival) are untouched; this is the in-memory general cache, not a replacement for the durable journal.

Scope note (JEF-363)

Per coordinator direction, the model-hypothesis stage is being removed by JEF-363, leaving adjudication as the only ollama consumer. This middleware is intentionally consumer-agnostic — it now primarily serves adjudication and backstops any future consumer. All edits here are confined to model.rs + model/cache.rs; no changes to hypothesis.rs, run_loop.rs, mod.rs, or reason/proof.

Tests

New tests (12): request-canonicalization key stability + sensitivity (prompt/model/temperature/endpoint/message-order), env parsing, LRU eviction at cap, and end-to-end through chat against a connection-counting localhost server — identical request served from cache with zero HTTP, distinct request misses, error not cached (retries), keep_warm bypass (always hits), and concurrent identical requests don't deadlock.

cargo fmt / cargo clippy --all-targets (clean) / cargo nextest run → 739 passed, 1 skipped (incl. the file-size guard; no file over 1000 lines).

🤖 Generated with Claude Code

https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP

…eware (JEF-362)

Move completion caching from the per-consumer verdict cache (JEF-350) to the
model-client boundary, so EVERY model consumer is covered by one mechanism. The
hypothesis stage (ModelHypothesizer) previously called ollama every pass,
uncached; routing chat() through a shared bounded cache fixes it once.

- Key = stable hash of the full request that determines the response (endpoint +
  model + messages + temperature + max_tokens), canonicalized with sorted object
  keys (array order preserved) so a byte-stable request always hits.
- Bounded in-memory LRU, cap default 512, env PROTECTOR_MODEL_CACHE_ENTRIES.
  Mutex<LruCache>; the lock is never held across the HTTP await (get/put are tiny
  sync critical sections), so the cache sits in front of the JEF-337 concurrent
  dispatch without reintroducing serialization.
- Cache successes only: transport errors, non-success statuses, over-cap bodies,
  and unparseable replies are never cached — they retry next pass.
- keep_warm bypasses the cache by construction (caching a keep-alive ping would
  let the model unload).
- JEF-350's deterministic verdict prompt + decisive-verdict journal persistence
  are kept intact for restart survival; this is the in-memory general cache.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
@thejefflarson
thejefflarson enabled auto-merge (squash) July 6, 2026 15:24
@thejefflarson
thejefflarson merged commit 5713e62 into main Jul 6, 2026
7 of 8 checks passed
@thejefflarson
thejefflarson deleted the thejefflarson/jef-362-cache-all-ollama-calls-in-a-bounded-model-client-middleware branch July 6, 2026 15:24
thejefflarson added a commit that referenced this pull request Jul 6, 2026
…in-pinning hazard (JEF-364) (#191)

JEF-362/#189 added a bounded LRU over `chat()`. After JEF-363 removed the
model-backed hypothesis stage, the adjudicator is the sole `chat()` caller
(keep_warm bypasses). The JEF-350 verdict cache already keys on the exact
prompt hash, so the LRU is redundant by construction: a verdict-cache HIT
means `chat()` is never called, and a MISS means the prompt changed so the
LRU misses too. Worse, it was a live correctness hazard — the verdict store
deliberately never caches `Uncertain` (must retry, JEF-234 backoff), but the
LRU cached any 200 including replies that parse to `Uncertain`, pinning the
entry to a stale completion until evidence changed.

- Delete engine/src/engine/model/cache.rs and its tests.
- Remove the mod/imports and cache::get/put calls from model.rs; `chat()`
  calls ollama directly again.
- Remove the now-unused `lru` dependency.
- Fix the stale module doc (no more "hypothesis source" / "frontier gateway";
  the adjudicator is the sole consumer, keep_warm is a bypass ping).
- Add a test that a transient `Uncertain` reply does not stick: an identical
  prompt is re-sent to the endpoint next pass (no cached completion
  short-circuits the retry).

Verdict cache + journal (JEF-350) and keep_warm behavior untouched.

Closes JEF-364


Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
thejefflarson added a commit that referenced this pull request Aug 8, 2026
…#332)

Process/review discipline, no engine code: when a PR removes a consumer
of a shared layer or abstraction, its description states the layer's
remaining live-consumer count in one line; a drop-to-one is flagged in
review as a refactor smell, not merged silently.

Concrete precedent recorded as Context: PR #189 added a shared
model-completion cache "so EVERY model consumer is covered"; PR #190
merged eight minutes later and deleted one of its two consumers (the
hypothesis stage); the conjunction — invisible to either single-PR
review — left a redundant, Uncertain-pinning cache that PR #191 had
to revert less than an hour later. Extends ADR-0024's single-PR
redundant-by-construction rule across a two-PR conjunction.

Adds a one-line CLAUDE.md Workflow checklist entry pointing at the
ADR, and a docs/adr/README.md index row.


Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP

Co-authored-by: Claude Opus 4.8 <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