lmr · Technical ManualEdition 1.0 · 2026-10-15 · github.com/davidghatley/local-mobile-research
Plate I · Anatomy of one answerMeasured · desktop CPU, 6 threads
Corpus · pinned 297 articles 9,979 paragraphs pinned revisions confusable sets: Mercury ×6 · Python ×5 Apple · Jaguar · … BM2510.7 MB indextop 500 · ~17 ms Rerank top 100ettin 17M70.8 MB · ~1.6 s Pack≤ 1,000 tokens≤ 3 per article The packet · ~3 paragraphs 01,000 tok Qwen2.5 1.5B · Q4_K_Mllama.cpp, CPU, localhost0.99 GB · ~4 s per answergreedy, fixed prompt Answer+ cited ids+ every stage shown in UI Evaluate206 public Qsgold paragraphs116 sealed Qs(off-repo) 1 2 3 4 5 6
Key
  1. Corpusrebuilt from pinned revisions, sha-checked
  2. Findalready holds 96% of the evidence
  3. Reorderthe one model that earned its weight
  4. Fitwhere the evidence used to get lost
  5. Writethe weakest link today
  6. Measureevery claim has a split and a threshold

Fig. 0 — One question, four stages: about five seconds once warm on a desktop CPU (11.6 s for a cold one-off run), with no network. The interesting stage is ④: the evidence was usually found; it just didn't fit.

lmr

Technical Manual

An offline research assistant, and the experiment that decided how to build it. It answers questions from 297 Wikipedia articles using only what runs on this machine: a keyword index, one 17-million-parameter reranker and a 1.5-billion-parameter language model. The research behind it asked whether better retrieval matters on a device. The answer was no, but better packing does.

Code: lmr/ (app) · substrate/, eval/, experiments/ (research) · 518 tests
Runs on: Linux x86-64, Python ≥ 3.11, ~2.3 GB disk, no GPU · Built for poidh bounty #31 (see §1.2)

Part I

Orientation

What the tool does, how to run it, and what it knows.

1

What it is for

A lookup tool for when there is no network, and a measured answer to which parts of one are worth building.

1.1 The tool

Ask a question in a terminal (lmr ask) or a local web page (lmr serve). The assistant searches its corpus, picks the paragraphs that fit a fixed budget, and has a local model write an answer that cites them. Nothing leaves the machine: the language model is a llama-server bound to 127.0.0.1, and the reranker runs on the CPU in-process.

The web page shows every stage of an answer, not just the result: which paragraphs keyword search found, how the reranker reordered them, which ones fit the 1,000-token budget, and which the model cited (Fig. 2.2).

1.2 Where it came from

The project started as a response to a public bounty: “Build the Best Offline AI Research App for Android” (poidh #31, about 1.5 ETH, closing 2026-10-31). Its bar is a phone app under 12 GB of RAM that is “more than 50% as good as internet plus frontier models”, with no network at all.

Instead of building an app first, this project asked a narrower question that any such app has to answer: on a phone-sized model, does retrieval quality actually change the answer, and what is the cheapest design that captures the difference? The generator was held fixed on purpose, so that if evidence quality barely moved the answer, the project could say so.

Scope

Every measurement in this book was taken on a desktop CPU. None of it is a claim about Android or GrapheneOS performance. The Android app is not built (Chapter 10).

2

Running it

Three commands from a fresh clone. The first one downloads 1.2 GB and rebuilds the corpus; after that nothing touches the network.

# Python 3.11+, uv. CPU only. git clone https://github.com/davidghatley/local-mobile-research && cd local-mobile-research uv venv && uv pip install -e ".[rerank]" .venv/bin/lmr setup # llama.cpp b11227 (CPU) + Qwen2.5-1.5B GGUF, sha-checked; corpus from 297 pinned revisions .venv/bin/lmr ask "Which planet is closest to the Sun, and how long is its year?" .venv/bin/lmr serve # http://127.0.0.1:8765
Fig. 2.1 · What lmr setup puts whereStructure · sizes measured
repositorytracked in git lmr/the app: fetch · runtime · pipeline · cli · web substrate/ eval/frozen research contracts and scoring experiments/BM25, packer, baselines, round-1 results corpus/provenance.json297 revids + wikitext sha256 corpus/corpus.jsonl43.9 MB · built by setup, not tracked 113,951 nodes: article → section → paragraph → sentence ~/.cache/lmr$LMR_HOME llama.cpp/llama-b11227/CPU build Qwen2.5-1.5B-Instruct-Q4_K_M.gguf0.99 GB ~/.cache/huggingface cross-encoder/ettin-reranker-17m-v170.8 MB revision 9e4aa35 · loaded on CPU

Fig. 2.1 — Code and pins live in git; weights and the built corpus live in caches. Every download is checked against a hash recorded in the repository before it is used.

Fig. 2.2 · One real answer, every stagelmr ask --json · bm25 18 ms · rerank 1482 ms · generate 3.3 s

Q. Which planet is closest to the Sun, and how long is its year?
A. Mercury is the closest planet to the Sun, and its sidereal year is 88 Earth days.

Packet · 692 of 1000 tokens · 133 skipped by the per-article cap692BM25 rank → reranked order (top 8)#71. Mercury (planet) · (lead)in packet#592. Venus · (lead)#243. Venus · Venus and early modern astronomy#14. Mercury (planet) · Orbit, rotation, and longit#65. Venus · Orbit in respect to Earth#206. Mercury (planet) · Observation#367. Solar eclipse · Eclipses and transits#28. Mars · Orbital motion

Fig. 2.2 — The web UI draws this live for every question. Source: docs/manual/data/example_answer.json.

Pitfall · first run

The reranker is fetched from Hugging Face the first time the pipeline loads (about 70 MB). Run lmr ask once while online after lmr setup. Pass --no-rerank to run on BM25 alone with no Python dependencies beyond the standard library.

3

The corpus

297 articles chosen before any question was written, weighted towards names that mean several things at once.

Fig. 3.1 · Articles per topic clusterCounted from corpus/corpus.jsonl
0255075100 confusable107 geography44 science37 biology29 longtail26 history16 culture15 medicine14 misc9
Why “confusable”
  1. Same nameMercury the planet, element, god, car, label and singer
  2. Same wordskeyword search scores them alike
  3. Same groupkept in one split, so no answer leaks across

Fig. 3.1 — More than a third of the corpus is deliberately ambiguous, which is where a reranker should earn its keep.

3.1 Pinned, then rebuilt

corpus/provenance.json records, for each article, the Wikipedia revision id and the sha256 of its wikitext. lmr setup fetches those exact revisions (not today’s pages), refuses any whose bytes differ, and runs the original offline converter. All 297 matched on 2026-10-15.

Verify

The rebuild reproduces the research numbers exactly: BM25 packet completion is 54/73 on dev and 80/122 on shadow_dev, the same as the frozen round-1 results, and all 235 gold evidence ids resolve. One known difference: article-level cross-links came from a live API list at the original fetch and are re-derived from wikitext here, so the file hash differs. Retrieval does not read them.

3.2 The questions

SplitQuestionsUseWhere
train60Reference only; never used to fit anything herecorpus/questions.json
dev59Tuning and quick checkssame
shadow_dev87One frozen confirmation run per systemsame
private test116Sealed; never scored againstoutside the repo, hash only

Types across the public splits: factoid 128, multihop 33, comparison 17, ambiguous 15, unanswerable 13. Each answerable question records the exact paragraphs that are sufficient to answer it; that is what “required evidence” means in Part II.

Part II

The finding

Retrieval was already good. The evidence got lost on the way to the model.

4

Found is not shown

BM25 found almost every paragraph a question needed. A quarter of them never reached the model.

Fig. 4.1 · Where required evidence goes · dev, BM2573 required paragraphs · loss_attribution_B1_bm25_dev_core.json
Required 73 paragraphs the gold answers need In BM25's top 500 70 found · 95.9% 3 rank Shown to the model 54 packed · 74.0% 10 cap 6 budget lost after being found: 16 (21.9%) 123
Key
  1. Searchheadroom here is 4.1%
  2. Per-article capthree paragraphs per article, max
  3. Token budget1,000 tokens ≈ 3 paragraphs

Fig. 4.1 — The problem is allocation, not recall. A paragraph averages about 280 tokens, so the budget holds roughly three; which three is the whole game.

Lifting the per-article cap only moved completion from 54 to 56 of 73: the loss migrated from the cap to the budget. The two limits are substitutes. What helps is putting the right three paragraphs first.

5

The reranker is an allocator

A 17M cross-encoder found nothing new. It reordered what BM25 already had, and 13 points more of the needed evidence reached the model.

Fig. 5.1 · Packet completion, BM25 vs the 17M rerankerFrozen round-1 runs · Wilson 95% intervals
50%60%70%80%90%100% dev · 73 74.087.7 shadow_dev · 122 65.678.7 pooled · 195 68.7 [61.9–74.8]82.1 [76.1–86.8] gap
Key
  1. BM25keyword search, no model
  2. Rerankerettin-17m on BM25's top 500
  3. Pooled+26 paragraphs vs an 18-item threshold

Fig. 5.1 — The only result in round 1 to clear its preregistered threshold. On dev alone (+10 vs a 13-item threshold) it would have been recorded as a null.

Every reranking stage read exactly the same search survival as BM25 (70/73): a reranker cannot add a paragraph BM25 did not return. The gain came entirely from the order, and it arrived using fewer tokens (740 vs 798 per packet on shadow_dev).

5.1 How deep does the reranker need to look?

The research reranked all 500 candidates, which cost 40 s per question on one thread. The app reranks only the top k. Measured with lmr bench on this machine:

Fig. 5.2 · Completion and latency against rerank depthMeasured · lmr bench · desktop CPU
  • dev · 73 required
  • shadow_dev · 122 required
Required evidence in the packet60%70%80%90%default54/7363/7364/7365/7364/7380/12291/12293/12295/12296/122BM252050100500rerank depth kMedian retrieval time · log scale10 ms100 ms1 s10 s17 ms562 ms1.1 s1.5 s5.4 sBM252050100500rerank depth k · both splits overlap

Fig. 5.2 — Most of the gain arrives by k = 20; k = 100 matches the full 500 within one paragraph per split at about a quarter of the time. Retrieval only (BM25 + rerank); generation adds about 4 s. Source: docs/manual/data/bench_*.json.

Decision

The app defaults to --rerank-k 100. Pooled over both splits it keeps the full-depth result (160/195 required paragraphs) at about a quarter of the latency; k = 50 gives 157/195 in two-thirds of that time. The app and the frozen research system agree exactly at k = 500 on both splits (64/73, 96/122), which is a second check that the rebuilt corpus is faithful.

6

What each model costs

Bigger was not better. The 68M reranker beat the 17M by one paragraph at four times the size.

Fig. 6.1 · Evidence completion against weight · devR1_ANALYSIS.md §2 · 73 required paragraphs
050100150200250MB of weights 70%75%80%85%90% BM25 · 74.0% static union · 72.6% ettin 17M · 87.7% ettin 68M · 89.0% +1 paragraph for 3.9× the weights
Latency, research setup
  1. BM2536 ms
  2. Static union119 ms · 166.6 MB incl. cache
  3. ettin 17M39.6 s (all 500, 1 thread)
  4. ettin 68M77.4 s · 1.9 GB peak RSS

Fig. 6.1 — The cheap semantic channel (static embeddings) added nothing, as predicted in advance. The 17M reranker captured almost the whole available gain.

7

The generator ceiling

The 1.5B model can tell good evidence from bad, but even perfect evidence only gets it to about one answer in seven.

Fig. 7.1 · Exact-match accuracy by evidence givenGENERATOR_FLOOR_REPORT.md · 137 analysable questions
05%10%15%20% gold evidence14.6% · 20 BM25 evidence11.7% · 16 wrong evidence0.7% · 1 4 items of headroom
Reading it
  1. It listensgold beats wrong evidence on all 19 discordant items, p = 3.8×10⁻⁶
  2. It caps outperfect retrieval buys 4 answers; detecting a difference needs 18

Fig. 7.1 — No retrieval change can show up in final answers on this corpus with this model. That is a property of the generator, found before any architecture was compared.

Pitfall · citations

The fixed prompt asks for bracketed citations. In practice the 1.5B model rarely emits them (none in six sample questions on 2026-10-15) and adds facts that are not in its evidence. Asked how Freddie Mercury got his stage name, it answered that the stage name was “Farrokh Bulsara”, which is his birth name. Asked to compare Mercury the planet and the element, it claimed both have atomic number 80. The UI shows which packet paragraphs were cited, so this is visible rather than hidden.

This is the clearest next step: fine-tune a small model to write short, cited answers from a packet (Chapter 10).

Part III

Method

How the results above were protected from wishful thinking.

8

How the experiment was run

Thresholds first, systems second, the confirmation split once.

Fig. 8.1 · Smallest difference each split can detectexperiments/comparability/CONTRACT.md §4
0510152025 pp dev · 5922.0 shadow_dev · 8717.2 dev + shadow · 14612.3 private · 11613.8 all public · 20610.2 reranker effect 13.3

Fig. 8.1 — A gap shorter than its bar is recorded as a null result, never a win. The reranker clears only the pooled bar it was tested against.

9

What the reviews caught

Eight independent freeze reviews. Seven said “repair required”. Each defect was invisible to the checks written by the same team.

DefectWhy the existing check passed
A partition census that agreed with itselfIt was derived from the data it was meant to check
A truncated partitionIt passed three consecutive stages because each stage was internally consistent
A fix defeated by editing the data and its census togetherBoth sides moved; the trust root had to move outside the repo
A stage that compared nothing and printed PASSVacuous success: no test asserted the stage had inputs
A leakage gate that could never pass, then one that leakedOnce repaired it worked as a membership oracle and had to be re-scoped
A preregistration anchor pointing at a commit without the policy fileNobody resolved the anchor
A minimum-difference figure wrong by about 2×Refuted by its own worked example three lines earlier
Lesson

An internal consistency check is necessary and never sufficient. The same failure shape appeared for a single item, a whole partition and the entire freeze. Every one of them was found by a fresh reader. The full account is in docs/ROUND0_CLOSURE.md.

Part IV

Reference

Limits, credits and sources.

10

Limits and next steps

ItemStateNext step
Answer qualityweak 1.5B model, rare citationsFine-tune a 1.7–4B model for cited answers from a packet (QLoRA with Soup on a 12 GB GPU) and score it on the same gold/BM25/wrong arms
Androidnot builtllama.cpp ships Android arm64 builds; the reranker needs an ONNX export and an int8 parity gate first
Private testlost on this machineThe sealed store went missing on 2026-10-04 (docs/CUSTODY_INCIDENT_2026-10-04.md); it was never scored, so no result depends on it
Corpus size297 articlesA 940-article distractor tier exists (corpus/scale/); the app does not load it yet
Your own documentsnot yetThe pipeline only needs paragraphs with ids; a folder importer is the obvious feature
A

Who did what

This project was built with AI agents. Here is where the line sits.

David

  • Chose the bounty and reframed it as a falsifiable question
  • Set the rules the agents worked under: free models for every subagent, independent reviewers, preregistration before code, nothing tuned on shadow_dev
  • Decided what shipped: the app, the default rerank depth, this manual

Agents

  • Wrote most of the code, the question drafts and the review reports, under that protocol
  • Each defect in Chapter 9 was caught by an agent reviewing another agent's work
  • Every number in this book is read from a file in the repository, not from an agent's summary
B

Sources

C

Changelog

1.0 · 2026-10-15
First edition: the lmr app (corpus rebuild from pinned revisions, top-k reranking, CLI and web UI) and the round-0/round-1 findings.
lmr · Technical Manual · Edition 1.0 · 2026-10-15