hypernet-sp-distill / APP_HANDOFF.md
baya1116's picture
Super-squash branch 'main' using huggingface_hub
b5989f0
|
Raw
History Blame Contribute Delete
27.6 kB

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; for the runbook see OPERATING.md; for the portfolio writeup 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

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):

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 β€” full runbook (install, recipe table, runners, tiered stack, contamination guard)
  • README.md β€” portfolio writeup (idea, models, RAG, results)
  • RESULTS.md β€” honest verification numbers
  • HANDOFF.md β€” research handoff (training, deepKL, architecture)