MLX
Joblib
Safetensors
English
reasoning
chain-of-thought
context-compression
soft-prompt
apple-silicon
Instructions to use baya1116/hypernet-sp-distill with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- MLX
How to use baya1116/hypernet-sp-distill with MLX:
# Download the model from the Hub pip install huggingface_hub[hf_xet] huggingface-cli download --local-dir hypernet-sp-distill baya1116/hypernet-sp-distill
- Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- LM Studio
- Atomic Chat
| # 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-`</think>` 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 | |
| `</think>` 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-<think>, 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-</think> shown; <think> 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-`<think>`, 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 `</think>` after N tokens). | |
| - **2-pass chat answer (`_gen_once`)** β pass 0 thinks freely; if the post-`</think>` 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* `<think>` 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 `</think>`). | |
| 3. **DO NOT re-add `<think>`-compaction via re-tokenization.** An attempt to strip prior `<think>` 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-`<think>`*, then second-guesses itself over ~4000 tokens into a wrong | |
| `\boxed{1000}` (and the box lands *inside* `</think>`, so the post-`</think>` 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 `</think>`, timeout** (deterministic over-think | |
| rut); **temp 0.9 β 4843 tok, no `</think>`, timeout + wrong `\boxed{220}`** (wanders); **temp 0.6 β | |
| 1730 tok, closes `</think>` 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) | |