# App-level handoff — composite operation of the SP model as a local assistant
This is the **operational** handoff: how the bounded-memory soft-prompt (SP) reasoner is wired into a
usable on-device app, what was built, what's verified, and where the edges are. For the *research*
handoff (training, deepKL, architecture) see [`HANDOFF.md`](HANDOFF.md); for the runbook see
[`OPERATING.md`](OPERATING.md); for the portfolio writeup [`README.md`](README.md).
> One line: a 1.5B reasoning distill runs with **O(1) bounded KV** (distant context → 32 SP vectors);
> a **tiered retrieval + memory** layer (same-session → prior-session → web) feeds verbatim facts into
> the raw window so the lossy SP is bypassed, with a **groundedness guard** that resamples contaminated
> answers. Runs at ~36 tok/s on an 8 GB Mac (MLX 4-bit).
---
## 0. FINAL SPEC (current behaviour, after all iterations)
What `tiered_rag_mlx.ChatSession.turn()` does for each user message, end to end:
**A. Classify the turn** (`intent_of`) — ONE **learned 6-class router**: a LogisticRegression on a **pure
BGE-small embedding** (`evals/intent_clf.joblib`, ~95% CV) routes each turn to exactly one action:
- **recall** — a question about the USER → personal memory only (L1/L2), never web.
- **lookup** — a general/WORLD question → web (so "who is the emperor of Japan" goes to web, not personal L1).
- **math** — a self-contained problem → solve.
- **command** — "recompute the total" → recall-log + compute.
- **fact** — a stated value/attribute → log.
- **chitchat** — greeting / conversational remark / open-ended request → respond.
This *replaces all the regex gates* (`_is_question`, `_is_factlike`, `_looks_mathy`, `compute_ref`) — one
embedding model does all routing. Training = hand-labelled core + **open-model-generated** examples
(Qwen2.5-3B-Instruct-4bit, `evals/intent_gen.py`). Regex gates are only a low-confidence/no-model fallback.
(History: regex → BGE+syntactic hybrid for small data → pure BGE once data grew → split `question`→`recall`/
`lookup` after a realistic session showed world questions hitting personal memory and hallucinating. The
**recall/lookup split was the key fix** for that — a personal vs world question is a routing distinction,
not a retrieval-threshold one. `intent_features.py` is deprecated.)
**B. Write to memory** (so future turns can recall it):
- explicit save ("remember my … is X") → **persist to disk (L2)** + reply instantly "Got it — saved." (no generation).
- a **fact-like statement** (asserts a value/attribute: "8 guests", "paint it blue") → **auto-log to L1** (same-session).
- chit-chat / knowledge-requests / questions → **NOT logged** (logging them poisons recall).
- **specific info stated INSIDE a non-recall turn** (e.g. "$120/day" inside a *math* question) → **pinned to
working memory** by the independent specificity head (see B′), even though the turn isn't a logged fact.
**B′. Specificity head (independent, pin working memory)** — closes the gap where a specific VALUE is stated
inside a question/math turn ("if I spend **$120** a day…") and is therefore never logged, so a later
"recompute" / "what was the rate" misses it. The head is **one more linear probe on the SAME BGE vector**
already computed for routing/retrieval (`evals/specificity_clf.joblib`, 5-fold CV **AUC 0.996**), chosen via
a bake-off (`evals/specificity_probe.py`) where it beat tokenizer-fragmentation/tpc (AUC 0.87) and a
form-frequency surprisal proxy (0.30 — dead). `specific_spans(text)` enumerates cheap candidates (money,
codes, times, measures, proper-noun runs) and keeps those the probe scores ≥0.6. In `turn()`: for any intent
**except recall/lookup** (there the specific span is what's being *asked about*, e.g. "who is Naruhito" — not
user info), a turn with specific spans is **pinned** (`TieredMemory.pins`, rolling cap 12). Pins feed:
**command/compute** as primary context (the recompute needs the value), and **recall/lookup** as a
**strict (sim≥0.5) fallback only** — genuine logged facts win, so a pinned "$120 a day for 3 days" can't
derail "how long am I staying". Verified `evals/pin_test.py` (detector 9/9; pin→recall flow pass) and the
realistic session: `recompute the food total` now = **480** (was un-recoverable before, the $120 lived only
in a math turn). Train/extend: `python3.12 evals/specificity_train.py` (+ optional `specificity_gen.jsonl`).
**Follow-up `math` turns also get WM pins injected** (excluding the current turn's own pin) so a multi-turn
question that references earlier context ("she pays with $50, how much change" needs turn-1's $24, which
scrolled out of the raw window) can recover it — this was a real bug (the designed RAG didn't fire on `math`
follow-ups, only on `command`).
**The recall/lookup pin-fallback intercepts only pins NOT already logged in the session** (`p not in session`)
— a LOGGED fact ("going to Kyoto") must not shadow a genuine world question ("emperor of *Japan*", which is
geographically near "Kyoto"); logged facts are recall-reachable anyway. **POLICY COUPLING (important):** this
"working-memory-only" filter assumes the app logs ONLY facts to the session — i.e. non-fact turns are called
with `store="none"` (cf. `app_session.py` / `app_eval2.py`). If a math/chitchat turn were logged to the
session, its pinned value would stop being WM-only and the lookup→pins safety net could not fire for it.
Evaluated end-to-end in `evals/app_eval2.py` (PHASE 1 architecture scorecard **15/15**: routing + pin +
retrieval; PHASE 2 real generation). PHASE 1 caught the Kyoto/Japan over-interception bug above.
**C. Retrieve context** (only for questions / referential imperatives) — `_match` = **BGE-small semantic
cosine** (`runtime/rag.py`, 33M, CPU), lexical-overlap fallback if BGE unavailable. Handles paraphrase
("which spot did I leave the **car**?" ≈ "**parked** in bay 12", "how many **people**?" ≈ "**guests**"):
1. **L1 same-session** — semantic top-K (cap 4) of the logged facts, kept within 0.12 cosine of the best
(a strong single match drops distractors), **ties broken by recency** so a correction wins.
2. **L2 prior-session** — same semantic match over the on-disk profile (survives a fresh process).
3. **self-contained math** (has digits) → solve it, **don't retrieve**.
4. **referential imperative** ("recompute the total") → inject the recent fact log + compute.
5. else → **L3 web** (DuckDuckGo → Wikipedia, keyless).
**D′. Recall = clean-context quote** — for a recall turn that retrieved a fact chunk, the answer is generated
by `ChatSession._clean_quote()` from an **ISOLATED context (the retrieved chunk + question only, temp 0.2, no
soft prompt, no conversation history)**, with the groundedness re-roll. Why: in a long multi-turn chat the
distractor turns' CoTs compressed into the SP can hijack a recall into computing/confabulating (e.g. quoting a
distractor's number); an isolated context removes that bias. Validated: deep recall after 6 distractors went
from a stochastic 0/3–3/3 to a reliable **3/3 across repeats** (`evals/recall_distract.py`). The retrieval
(pins/L1) was already firing; this fixed the recall *generation discipline*. (Raw SP alone confabulates 0/3,
and even full-KV base fails to self-recall — so the external retrieval injection is essential, not a crutch.)
**D. Inject + instruct** — matched facts go verbatim into the raw window:
- recall → *"quote the value verbatim; if it was corrected, use the most recent."*
- compute → *"use the fact(s) and compute."* (and the groundedness check is skipped for math).
**E. Generate** — **free generation** (no think-budget), **temp 0.6**, **rw 1024**; bounded only by a
**240 s wall-clock timeout + 2000-token per-turn cap + a 6-identical-token loop guard**.
**F. Make a real chat answer (2-pass)** — think freely; if the post-`` answer is **weak**
(empty / a "…:" preamble / just a `\boxed{}`), inject **"Final answer:"** and continue, so a clean
answer always surfaces (e.g. "4000 square meters.", not an empty reply).
**G. Quality gate** — if the answer is empty/timed-out, or (for a recall turn) **not grounded** in the
retrieved context, **delete the CoT and re-roll** (`retries=2`; cheap — the hypernet recomputes the SP).
**Memory tiers:** L1 same-session (in-RAM facts) → L2 prior-session (disk jsonl) → L3 web. **Verified:**
app_demo recall 3/3, routing 5/5, web+math hit; 5- and 10-rally consistency (corrections persist; the
oldest fact fades after ~many turns). **Knobs:** `temp 0.6`, `rw 1024`, `retries 2`, `TIMEOUT_S 240`,
`MAX_NEW 2000`. **Limits:** bounded-recency (turn-1 facts fade in a long chat — the specificity head pins
*specific* values but a rolling cap-12 buffer still ages out; broad summary-memory not built), and the 1.5B
base ceiling (mis-reads/mis-computes hard problems).
---
## 0.5 Performance vs the full-KV base (GSM8K, 2026-06-10)
Does bounded SP-evict compression cost accuracy vs keeping the whole CoT in KV? Measured base (rw=8000,
full-KV) vs SP-evict (rw=1024, the shipping window) on GSM8K, interleaved, identical decoding (`evals/
gsm8k_eval.py`, `evals/gsm8k_multiturn.py`). Yardstick = the base model's OWN accuracy in-harness (NOT 100%).
- **Single-turn (n=12):** base 6/12 vs SP 8/12; **base-only losses = 0** (SP never lost a problem to base).
SP CoT is shorter (med 1378 vs 1894 tok) and hits the 2000-tok cap less (2 vs 6) → compression keeps
generation focused. (Caveat: short problems fit the 1024 window → SP barely compresses → not a hard test.)
- **Multi-turn (n=10, turn-2 depends on turn-1 which scrolled out of the window → the real compression test):**
base 6/10 vs SP 6/10 (**TIED**) once the math-turn pin injection is in. **9/10 failures are `None` =
TERMINATION** (240s timeout / 2000-tok cap hit mid-CoT) — and they hit BASE (4×) as much as SP (3×). base
has full-KV verbatim context and fails the SAME way → the bottleneck is generation TERMINATION, NOT
compression. Captured `None` cases re-run correctly → the model can solve them; it just often doesn't stop
in budget (R1 CoT is long).
- **Verdict:** on the fair in-harness comparison there is **no clear accuracy loss from compression** — SP ≈
base; what's left is the 1.5B's own ceiling + a termination artifact shared by base. (Low absolute % is
the cap + 4-bit, not an SP deficiency.)
- **Termination fix** (`_gen_once`, 2026-06-10): on a mid-CoT timeout/cap with no usable answer, close
`` and force "Final answer:" on a small own budget (64 tok / 45 s) to salvage an answer from the
partial CoT instead of returning None. Helps base & SP equally.
## 1. The composite pipeline (per user turn)
```
user turn
│
▼
classify(intent) ──► chit-chat ───────────────► answer (no retrieval)
│ fact ──────────────────────► save (instant "Got it — saved."), persist?→disk
▼ question
TieredMemory.retrieve (first tier over the bar wins)
├─ L1 same-session (in-memory turns)
├─ L2 prior-session (disk jsonl — survives a fresh process)
├─ self-contained math? → solve, DON'T web-search
└─ L3 web (DuckDuckGo → Wikipedia, urllib+bs4, no headless browser)
│
▼ inject matched text verbatim into the raw window ("answer from Context only")
bounded SP-evict generation (force-, FREE gen; 2000-tok cap + 240s timeout + loop guard — see §6.5)
│
▼ answer empty / timed-out / (grounded) contaminated? ── yes ──► delete CoT, re-roll (retries=2)
│ (gen/kept hold the convo; SP recomputed each step)
▼ no
answer (only post- shown; scratchpad hidden)
```
## 2. App-level file map
| file | role |
|---|---|
| `tiered_rag_mlx.py` | **operational core** — `TieredMemory` (L1/L2/L3 cascade + **`pins` working memory**, fuzzy/semantic match), `ChatSession` (bounded SP-evict gen + injection + groundedness retry), learned router (`intent_of`), **specificity head (`specific_spans`, pin gate)**, `is_grounded`. Run directly → L1/L2/L3 composite test. |
| `evals/specificity_train.py` · `evals/specificity_clf.joblib` | **independent specificity head** — train + the ~20 KB BGE linear probe that detects pin-worthy specific/proper info (CV AUC 0.996). |
| `evals/specificity_probe.py` | the **bake-off** that selected BGE-probe over tpc-fragmentation / surprisal-proxy for specificity detection. |
| `evals/pin_test.py` | operational test for the specificity head + pinning (detector unit 9/9; value-in-question → later-recall flow). |
| `app_demo_mlx.py` | **two-session application simulation** + readiness report (onboarding→disk, then a fresh "today" session over a mixed 8-turn conversation). |
| `chat_mlx2.py` | recommended **interactive chat** (recipe: temp 0.6, no system prompt, force-``, free generation + empty-answer re-roll, large `rw`). |
| `mem_rag_mlx.py` | conversation-memory RAG A/B (recall a fact the SP compressed away; 0/2 → 2/2). |
| `rag_web_mlx.py` | web-grounded answer (DuckDuckGo → Wikipedia) windowed-RAG. |
| `runtime/web_search.py` | keyless search backends — `DuckDuckGoSearch` (urllib+bs4, **recommended**), `WikipediaSearch`, `PlaywrightBingSearch` (brittle, optional), `Tavily`/`Brave` (API). |
| `pooler_mlx.py` · `gen_mlx.py` · `sp_mlx.py` · `mt_mlx.py` · `build_fft_hf.py` | MLX port of the pooler + SP-evict rollout, single-turn / battery / multi-turn runners, FFT→HF builder. |
## 3. Run it
```bash
pip install mlx mlx-lm transformers torch huggingface_hub beautifulsoup4 lxml # no Playwright needed
python build_fft_hf.py && python -m mlx_lm convert --hf-path ./fft_hf --mlx-path ./fft_mlx4 -q --q-bits 4 --q-group-size 64
python app_demo_mlx.py # full two-session app simulation + readiness report
python tiered_rag_mlx.py # L1/L2/L3 tier-routing composite test
python chat_mlx2.py 1000 # interactive chat
```
Minimal integration (the whole app loop):
```python
import tiered_rag_mlx as T
mem = T.TieredMemory("user_profile.jsonl")
chat = T.ChatSession(mem, rw=512)
def on_message(text):
if T.classify(text) == "fact": # store + instant ack, no generation
return chat.turn(text, store="persist" if T.wants_persist(text) else "session", ack_only=True)[0]
return chat.turn(text, store="none")[0] # free gen; turn() retries empty/contaminated
```
## 4. What's verified (composite tests)
| test | result |
|---|---|
| **Tier routing** (`app_demo`, `tiered_rag`) | **5/5 deterministic** — chit-chat / fact-save / math / L1 / L2 / L3 all dispatch correctly, every run |
| **MEMORY recall hit-rate** (L1/L2, `app_demo`) — *the architecture's metric* | **reliable** — the SP/memory's job (hold & surface a fact across turns) lands consistently |
| web (L3) / math — *orthogonal, reported for reference* | stochastic: web = retrieval quality + base extraction; **math = pure base-1.5B arithmetic with the query fully visible (no compression) — NOT a memory test, out of scope** |
> **Scoring note (don't repeat my mistake):** the old "answer correctness X/5" lumped three unrelated
> things. What this system is measured by is **MEMORY recall** (L1/L2). A math miss is the base model's
> arithmetic, not an architecture failure — the math query is fully visible, nothing is compressed/recalled.
> Also: "HIT" = gold substring present (a *hit-rate*, a proxy), not a verified correctness rate; and any
> single `app_demo` run is 1-sample/turn (2–4/5 swing) — use N-run averages for real numbers.
| same-session recall (L1) | ✅ "B3, spot 47" |
| prior-session recall (L2, from disk, fresh process) | ✅ "POL-55821", "Aki" |
| web grounding (L3) | ✅ "Sam Altman" (after contamination retry); Mt Fuji "3,776.24 m" |
| conversation-memory RAG (`mem_rag`) | ✅ **0/2 → 2/2** (recover SP-compressed facts) |
| reasoning correctness (5 Dolphin-R1 word problems) | **SP-evict ≈ 3/5 vs base/full-KV ≈ 4/5** — same ballpark; the ceiling is the **base 1.5B**, not compression (§6.5 #7). Chat-answer fix surfaces clean answers ("4000 square meters.", "2 loads.") |
| typical miss | base-model mis-read / mis-compute, or verbose / weak instruction-following (not a memory or compression failure) |
## 5. Engineering findings & fixes (the bugs the composite tests caught)
1. **Headless Bing is brittle** → it serves a JS shell / dictionary bot-detection page. Switched the
default web backend to **DuckDuckGo via plain `urllib`+`bs4`** (server-rendered results, no Chromium).
2. **Non-questions web-searched & contaminated** ("my room is 1408" → "1408 Film Locations" → "140813")
→ **question-gate**: only information-seeking turns retrieve.
3. **Self-contained math got web-searched** (would contaminate the calc) → **math-gate**: solve, don't search.
4. **`park` ≠ `parked`, 1-word questions missed** → **fuzzy prefix overlap** + trusted-local threshold.
5. **Fact-saves produced rambly "(no answer)"** → **instant ack** (store without generating).
6. **Recency bleed** ("Aki" answered for the CEO) → inject *"answer from the Context block only, ignore
earlier conversation"* + a **groundedness guard** (`is_grounded`) that resamples when the answer's
salient *novel* token isn't in the retrieved context (catches out-of-context / empty / question-echo).
## 6.5 Generation control — the long investigation (read before touching `_gen_once`)
How to bound/terminate generation was the hardest sub-problem. Final state and the reasoning, so the
next person doesn't re-run the same dead ends:
**Current committed behaviour (`tiered_rag_mlx.ChatSession`):**
- **Free generation** — no in-loop "think-budget" (we do *not* force `` after N tokens).
- **2-pass chat answer (`_gen_once`)** — pass 0 thinks freely; if the post-`` answer is **weak**
(empty / ends in ':' / just a `\boxed{}`), pass 1 injects **"Final answer: "** and continues, forcing a
clean chat answer. This fixed the dominant chat failure: the R1-distill routinely traps its answer
*inside* `` or emits only a preamble, so the user saw an empty reply even when the answer was
computed. After the fix, math word-problems surface clean answers ("4000 square meters.", "2 loads.",
"72 female guests…") instead of empties. `_extract_answer` is the fallback (surfaces a boxed-in-think).
- **Backstops only:** a **2000-token per-turn output cap** (state hygiene, below) + a **240 s wall-clock
timeout** + a **6-identical-token degeneration guard**.
- **Delete-CoT re-roll** (`turn(retries=2)`): if an answer is empty / timed-out / (for grounded turns)
contaminated, roll back the turn's tokens and resample. Cheap because the hypernet recomputes the SP
from the token list each step — no KV surgery.
**What we learned (and retracted):**
1. **Termination is temperature-stochastic, NOT a model incapacity.** The same prompt sometimes EOSes in
~365 tokens, sometimes rambles. So delete-CoT re-roll genuinely works for short-reasoning turns
(recall/chat): a fresh sample usually terminates.
2. **The per-turn output cap is load-bearing for multi-turn.** Fully-uncapped free-gen lets one runaway
turn (a *greeting* hit 3000+ tokens) flood the bounded window/SP and **poison later turns** (recall
bled wrong values, app dropped to 1/5). The 2000 cap stops that. It is **state hygiene, not
think-shaping** (it doesn't force ``).
3. **DO NOT re-add ``-compaction via re-tokenization.** An attempt to strip prior `` spans
from history by `decode → regex → re-encode` each turn **corrupted the context and caused reliable
non-termination** (recall turns went 13–52 s HIT → 70–153 s FAIL). It was reverted. If you want clean
history, track think-span token ranges at generation time and slice ids — never round-trip through text.
4. **Hard math fails by OVER-thinking, not non-termination.** On "$1000 at 5% compound, 3 yrs" the model
*computes the correct 1157 mid-``*, then second-guesses itself over ~4000 tokens into a wrong
`\boxed{1000}` (and the box lands *inside* ``, so the post-`` answer is empty). The
2000 cap also truncates it before it closes. So: short-reasoning → free+re-roll is right; long-reasoning
→ the model self-degrades.
5. **Temperature is load-bearing — and 0.4 was too low.** A controlled single-turn sweep of the
compound-interest problem: **temp 0.3 → 5632 tok, no ``, timeout** (deterministic over-think
rut); **temp 0.9 → 4843 tok, no ``, timeout + wrong `\boxed{220}`** (wanders); **temp 0.6 →
1730 tok, closes `` in 71 s, correct `\boxed{1157.63}`**. Both extremes over-think; **0.6
(DeepSeek-R1's recommended 0.5–0.7) is the sweet spot.** `TEMP` was 0.4 (set for "faithful copying"),
which contributed to the math over-thinking — now **0.6**. So #4's "math self-degrades" was partly a
too-low-temp artifact, like the compaction bug. (Caveat: this is the *isolated* result; the multi-turn
`app_demo` score is high-variance run-to-run — 2–4/5 — and temp is not a silver bullet there.)
6. **No clean automatic fix for the long-reasoning tail.** A fixed think-budget would catch the
early-correct answer — but you **can't tell a priori** whether a long chain is over-thinking or genuinely
necessary, so a global budget would harm problems that truly need long reasoning. Left as free-gen.
Real fix = a **calculator/tool** for arithmetic, or a better base.
7. **The accuracy ceiling is the base 1.5B, NOT the SP compression.** Eval on 5 Dolphin-R1 reasoning
problems (grade-school word problems), 2-pass chat-answer on: **SP-evict (rw=512, compressed) ≈ 3/5**
vs **base (rw=8000, nothing compressed, full-KV) ≈ 4/5** — same ballpark, within 1-sample noise. So the
modest ~60-80% correctness is the **1.5B's own level on this corpus**, not a compression artifact;
misses are the base model's mis-reads / mis-computes, not lost context. (Single sample/turn — use N-run
averages for a real gap. The chat-answer fix in `_gen_once` is what makes both surface clean answers.)
## 6.7 Organic multi-turn consistency (`_matches`, auto-log, compute-vs-quote)
A flowing conversation with dependencies and a correction — "bookshelf, 5 shelves" → "each 80 cm, total
width?" → "paint it blue" → "make it 6 shelves, redo the width" → "remind me the colour and shelf count" —
**totally failed before** (the model lost the specifics to the SP and hallucinated: "75 books", "gray",
"12 m wall", "15 shelves"). Root cause: organic conversational state flows only through the lossy
SP-compressed KV; it was never written to the retrievable memory. Fix (all in `tiered_rag_mlx`):
1. **Auto-log statements** — `turn()` writes every non-question user turn to `mem.session` (L1) even when
`store="none"`, so the conversation's facts become retrievable verbatim.
2. **Multi-fact retrieval** — `_matches` returns the **top-`cap` (≈3) near-top-scoring chunks** (not just
the single best), chronological, so a 2-fact question ("colour AND count") gets BOTH, and a later
correction ("6 shelves instead of 5") is read after the original. A strong single match (a unique
code) still drops weak distractors (relative-score threshold).
3. **Compute-vs-quote** — a math turn that pulls a fact from context gets a *"use the fact and compute"*
instruction (and skips the groundedness check), while a recall turn keeps *"quote verbatim, use the
most recent if corrected"*.
4. **rw default 1024** — wider window keeps more recent dialogue verbatim.
After the fix the same conversation: width computed from the recalled count (400, then **480** after the
5→6 correction), and the colour recalled correctly (**blue**, vs "gray" before). Verified **no app_demo
regression** (routing 5/5, recall/web/math hold). Residual glitches (a trivial statement that over-thinks
to timeout; "how many shelves" answered as "480 cm") are 1.5B response-quality quirks, not the memory.
**Scaling to MANY rallies (10-turn party conversation, 2 corrections).** Pushing further surfaced the
final design — and two dead ends to avoid:
- **`_is_factlike` gate** — only log statements that *assert* a value/attribute. Logging questions or
chit-chat ("explain compound interest") **poisons recall**: it bled "compound interest"/"1157.63" into
"where did I park?". Gate it out.
- **Lexical top-K, NOT full-log injection.** Injecting the whole recent log on a recall makes the context
large and the model **recency-bleeds** the most recent number (park → "1157.63"). Keep the injected
context small (`_matches`, cap≈4) **with ties broken by recency** so corrections still win.
- **`compute_ref`** — a referential imperative ("recompute the total", "redo it") isn't a question and has
no lexical anchor → inject the recent log + a compute instruction.
- Result on the 10-rally party: a mid-conversation 2-fact check is perfect ("chocolate, 10 guests"); the
final 4-fact check is **3/4** — both corrections held (**theme→dinosaur, guests 8→10**, cake→chocolate)
and **only the oldest fact (turn-1 "Mia") faded**. That tail is the fundamental **bounded-recency
limit**: very old facts drop after ~many turns; fixing it needs fact-pinning / summarisation (not done).
Verified **no app_demo regression** (recall 3/3, web+math hold).
## 6. Known limits (the app-quality boundary — intentionally not chased further)
- **Math / long reasoning** — the model over-thinks and self-degrades on hard chains (§6.5); pair with a
calculator/tool. Short reasoning is fine.
- **Groundedness is necessary-not-sufficient** — rejects out-of-context / empty / echo answers, but
can't catch a *wrong in-context span* (right page injected, wrong fact quoted). Needs a span-constrained
extractor or NLI/LLM judge.
- **Retrieval is fuzzy-lexical** — paraphrased questions with no shared words can miss; swap in a BGE
sentence encoder (`runtime/`) for semantic matching.
- **Latency** — ~10–25 s/turn (always thinks); fact-saves instant; web +1–5 s. Free scrapers rate-limit
bursts (back off + Wikipedia fallback; use an API key for production).
## 7. Extension points (if picked up later)
- **Semantic retrieval**: replace `_best`/`_overlap` with a BGE sentence encoder (the `runtime/` pipeline
already has BGE rerank + a verify/refuse gate — promote it into `TieredMemory`).
- **Tool use**: a calculator tool for the math turns; a date-guard for "today/now" web queries.
- **Stronger contamination judge**: span-constrained decoding or a tiny NLI check for wrong-span answers.
- **iPhone**: retrieval ports directly (`URLSession` + `SwiftSoup`); the model needs the pooler + SP-evict
rollout re-implemented on `mlx-swift` (numerics proven; it's a port).
## 8. Related docs
- [`OPERATING.md`](OPERATING.md) — full runbook (install, recipe table, runners, tiered stack, contamination guard)
- [`README.md`](README.md) — portfolio writeup (idea, models, RAG, results)
- [`RESULTS.md`](RESULTS.md) — honest verification numbers
- [`HANDOFF.md`](HANDOFF.md) — research handoff (training, deepKL, architecture)