A local research assistant built on a hardware aware inference engine, targeting Intel Lunar Lake. All inference happens on device.
Status: complete through Phase 14 of 14. The engine measures the machine, predicts what it will do with a workload, places work accordingly and says how much to trust the prediction. The application ingests documents, embeds them on the NPU, answers questions with checked citations, serves it over HTTP and opens in a browser. Fourteen phases of measurements are in
docs/findings/, and the ones that refuted the design are in there too. Seedocs/state.mdfor what exists today and what is broken.
Two layers, deliberately separated.
The engine (src/ridgeline/) places inference work across CPU, integrated GPU
and NPU from measured hardware characteristics rather than guesswork. It measures
the machine, derives a roofline model, and decides placement from it.
The application (src/reader/) is a research assistant. Documents go in,
questions come out with citations, nothing leaves the machine.
The application depends on interfaces in ridgeline/ports.py, never on an engine
implementation, so the engine stays portable and both halves are testable in
isolation. A fake engine backs the whole application test suite, which is how 838
tests run in CI on a Linux box with no Intel device in it.
This section is first on purpose. Every phase of this project ended in a measurement, and the interesting half of those measurements contradicted something the project had assumed. A README that led with the wins and buried these would be describing a different piece of software.
More context makes answers worse, and prefill being cheap does not save it. The premise of the design is that reading is nearly free, so the machine should read whole documents rather than fragments. Reading is indeed nearly free. It does not help.
| Documents supplied | Expected document reached the model | Answer cited the right one | Never answered at all | Median |
|---|---|---|---|---|
| 1 | 0.676 | 0.432 | 0.07 | 8.8 s |
| 2 | 0.838 | 0.446 | 0.01 | 14.8 s |
| 4 | 0.865 | 0.216 | 0.14 | 35.5 s |
| 8 | 1.000 | 0.108 | 0.11 | 53.1 s |
| 16 | 1.000 | 0.027 | 0.41 | 64.7 s |
| 30 | 1.000 | 0.054 | 0.32 | 71.3 s |
By eight documents an expected document is in front of the model on every single question and the answer cites the right one eight times less often. Fourteen times the prompt costs six times the wall clock. The binding constraint is the model's attention and its output budget, not the machine's bandwidth. The default is two documents. See the findings.
Speculative decoding loses on this machine by a factor of four, and the roofline said it would win. Draft on the CPU, target verifying on the integrated GPU: proposals are accepted 42% to 49% of the time up to k=3, which is a healthy acceptance rate, and every draft length is slower than not doing it at all.
| k | decode tok/s | accepted | against the control | what a proposal costs |
|---|---|---|---|---|
| control | 47.41 | 1.00x | ||
| 1 | 12.63 | 42.1% | 0.27x | 1.40x a verification |
| 3 | 8.44 | 49.0% | 0.18x | 1.55x a verification |
| 8 | 3.93 | 27.0% | 0.08x | 1.50x a verification |
The draft model costs more per proposal than the target costs per verification, so it has nothing to sell at any acceptance rate. The prediction that said otherwise was wrong by a factor of five, and it is the first time a bad prediction in this project produced a bad design decision rather than a bad number. Verification itself is output lossless, which was checked. See the findings.
The cost model needs a second term and the second term is not there. Effective decode bandwidth is not a property of a device: on the integrated GPU it rises from 47 GB/s at Qwen3-0.6B to 75 at Qwen2.5-1.5B and falls back to 52 at Qwen3-4B. A fixed cost per token explains the rise and cannot explain the fall. Fitted to all four models the intercept goes negative and the guards refuse it. Held out one model at a time, the two term fit scores 94.6% mean absolute error against 49.8% for the single figure it was meant to replace.
So predictions come from one effective bandwidth per engine whose held out error is 49.8%, and every prediction says which models it rested on and how far outside them it reached. It is good enough to rank engines and not good enough to quote. See ADR 0024 and the findings.
The one change that makes the system four times faster costs quality, so it is
off by default. Generation is 98.5% of a request and the model's reasoning is
87.3% of the generation. Telling the model not to think aloud takes the median
question from 13.64 s to 3.33 s and 401 generated tokens to 57. It also raises
citation compliance from 0.486 to 0.541, which is the opposite of what was
predicted. And it costs 0.176 of phrase recall and one question of correct
citation, which is a regression by the definition this project wrote in Phase 9
precisely so that Phase 13 could not argue its way past it. It ships as
--suppress-reasoning and it is not the default. See
ADR 0028.
Every design decision follows from measurement on the target machine, an Intel Core Ultra 5 238V with an Arc 130V integrated GPU and an AI Boost NPU.
| Observation | Measured | Design consequence |
|---|---|---|
| Prefill throughput (integrated GPU) | 7,638 tok/s | Reading is cheap. Favour large context. |
| Decode throughput | ~55 tok/s | Generation is expensive. Keep answers short. |
| Host memory bandwidth | 82.8 GB/s of 136.5 theoretical | Bytes read per token sets speed, not parameter count. |
| Bandwidth achieved during decode | 46.7 to 75.0 GB/s on the integrated GPU, by model | Predict from what the engine achieves, not from one figure per device. |
| Decode arithmetic intensity | 271x below the compute roof | Compute is nearly free. Spend it to save bandwidth. |
| Two engines on one workload | Slower than one | Give engines different jobs, never the same job. |
| Python per token loop | 33% slower than native | Never iterate per token in Python. |
| Generation, as a share of a request | 98.5% | Nothing on the host side is worth optimising. |
The raw measurements are in docs/findings/ and the scripts from
the prior investigation that produced the first of them are in bench/.
- Windows on Intel Lunar Lake, for the full engine
- Python 3.12
- OpenVINO 2026.3 and OpenVINO GenAI 2026.3, for anything touching a device
The engine core and its unit suite install and run without any of the above, which is how CI works on Linux.
pip install "ridgeline[reader,engine] @ git+https://github.com/Protonicwave/ridgeline"Not on PyPI. It is one machine's worth of evidence and a version number of 0.1.0, and claiming a package name is a promise this cannot keep yet.
The extras are separate because they fail differently. The base install is
numpy and nothing else, so the engine's logic and its tests run anywhere.
[reader] adds document parsing and the HTTP server, 24 packages. [engine]
adds OpenVINO, which needs an Intel device to be useful. Asking for a command
whose extra is absent gets one line naming what to install, not an import
traceback.
From a checkout:
git clone https://github.com/Protonicwave/ridgeline
cd ridgeline
python -m venv .venv
.venv/Scripts/python.exe -m pip install -e ".[dev,reader,engine]"Run the checks:
.venv/Scripts/python.exe -m ruff check .
.venv/Scripts/python.exe -m mypy
.venv/Scripts/python.exe -m pytest -m "not integration" # 838 tests, no hardware, 10 s
.venv/Scripts/python.exe -m pytest -m integration # 55 tests, requires an Intel deviceDescribe the machine:
ridgeline probe # devices, adapters, supply state
ridgeline probe --idle-seconds 10 # and the idle floorThe report is written to docs/findings/results/machine.json. It names every
OpenVINO device with its reported peak compute and memory, the performance
counter adapters discovered on the machine, and which adapter serves which
device. On a machine with no Intel device the command says what is missing rather
than failing obscurely.
Model the machine:
ridgeline roofline --theoretical-gbs 136.5 # measure and predict
ridgeline roofline --machine docs/findings/results/machine.jsonThis measures memory bandwidth with a threaded STREAM benchmark, calibrates it
against decode rates measured on the machine, and reports the ridge point and
achievable throughput per engine. With --machine it rebuilds the roofline from
a saved report, which works on a machine with no Intel device present.
Two bandwidth figures are kept apart and never blended, because predicting the accelerators from the host figure was 65% to 114% optimistic. See ADR 0005.
List the models and what the roofline expects of each pairing, before loading any of them:
ridgeline models # geometry, cache cost, predictions
ridgeline models --verify # and hash each weight blobGenerate, benchmark, and let placement choose an engine:
ridgeline generate --model qwen3-0.6b-int4-ov --prompt "..."
ridgeline generate --device NPU --prompt "..." # or name one
ridgeline benchmark --prompt-tokens 128,512,1024 --max-new 32 # predict, measure, record
ridgeline speculative # sweep the draft lengthEach pairing is predicted before the model is loaded, then measured, and one
record per model is written to docs/findings/results/. Those records are what
the calibration reads, so the loop between measurement and prediction is closed
and the ordering is enforced by the code rather than by discipline.
Select a configuration for a machine, without compiling anything:
ridgeline autotune # model, engine and context budget from measurement alone
ridgeline autotune --validate # score every candidate predictor, held out by modelEvery pairing is priced, including the ones that cannot run, and each carries its reason. An engine with no calibration is refused rather than ranked against one that has.
Model weights are never committed. They are fetched on demand and the registry records identifiers and checksums.
reader ingest ~/Documents/papers --corpus corpus.db
reader index --corpus corpus.db # embed, in batches
reader search "what limits decode?" --corpus corpus.db
reader ask "what limits decode?" --corpus corpus.db
reader status --corpus corpus.dbPDF, Markdown and plain text are parsed with their page and section structure kept, split on real structural boundaries, and stored with the offsets that map any span back to where it came from. Re-running is a no-op for documents that have not changed, and a document is addressed by its content and by the pipeline that processed it, so changing a parser or a chunk window reprocesses the corpus while a preserved modification time cannot hide an edit.
Embedding runs on whichever engine placement chooses, which on this machine is the NPU, leaving the integrated GPU free to serve queries. Over the fixed question set, fusing a dense scan with a term index beats either alone:
| Retriever | hit@1 | MRR | recall@3 | Median latency |
|---|---|---|---|---|
| Fused | 0.80 | 0.873 | 0.93 | 30 ms |
| Dense only | 0.67 | 0.807 | 0.90 | 24 ms |
| Lexical only | 0.60 | 0.761 | 0.87 | 2 ms |
Asking a question fills the generator's context budget with whole documents, streams a short answer, and checks every citation and quotation in it against what was actually supplied. A marker naming a document that was never in the context is reported as fabricated; a quoted phrase the context does not contain is reported as not found; an answer that cites nothing is reported as unchecked rather than shown as though it had been verified. What is not checked is whether the sentence follows from the document, which would be entailment, and ADR 0011 says so rather than implying otherwise.
Or serve it:
reader serve --corpus corpus.db # loopback, port 8000, interface at /
curl -N "http://127.0.0.1:8000/query/stream?question=what+limits+decode"Both models are compiled at startup and held for the life of the process, because compiling one costs 4.3 s warm and up to four minutes cold, and one request uses them at a time. The HTTP layer itself is free, adding 4.1 ms to a request that takes eight seconds. Answers stream as server sent events with the model's reasoning on a separate event from the answer, because on a real question the first reasoning token arrives at 3.3 s and the first token of the answer only at 8.9 s of a 10.1 s request.
The interface is documented in docs/api.md and published as
docs/openapi.json, which is generated from the application
and checked by a test, so it cannot drift from what the server serves.
http://127.0.0.1:8000/ is the same process. Plain ES modules and hand written
CSS, no build step, nothing fetched from another host, served from the same
origin so no cross origin policy exists to get wrong. See
ADR 0016.
Measured in Phase 14, in Chrome, against the server on loopback:
| Cold | Warm | |
|---|---|---|
| Interactive | 892 ms | 8 ms |
| First contentful paint | 1,088 ms | 28 ms |
| Engines named in the masthead | 971 ms | 22 ms |
| Transferred | 96.9 KB over 13 requests | 21.1 KB |
The health poll costs 2.0 ms a call at four second intervals, so an eight hour session is 7,200 calls and 1.26 MB. The document viewer renders 44,485 characters in 22 ms and has no virtualisation, so it is linear in document size and reaches 8.1 s at 16 MB, which is far larger than anything this corpus holds.
Three things it shows that a chat window does not.
The model's reasoning, as reasoning. It fills a block while the model thinks and collapses at the first token of the answer. On a measured question the first reasoning token arrived at 3.75 s and the first token of the answer at 14.13 s of a 15.28 s request, 424 reasoning events against 48. Showing it as the answer would be a lie and hiding it would be nine seconds of blank screen.
Where the request sat on the roofline. Bytes read per token against tokens per second, log on both axes, each calibrated engine's roof as a line, and this request as a point beneath the roof of the engine that served it. The predicted rate is drawn beside the measured one, because the gap is the number the project is trying to close. An engine with no calibration gets no roof and no prediction rather than a guess.
What every claim rests on. Each [n] opens the document the answer was
checked against, with any verified quotation highlighted in place. A marker naming
a document nobody supplied is drawn as a fabrication and is not clickable.
Quality comes before speed, which is why the harness was built before any of the optimisation phases.
reader pin --ref 30c2c7c --into .eval-corpus # the corpus, at a commit
reader evaluate --corpus eval.db # quality and cost
reader compare BEFORE.json AFTER.json # what moved, and whether it may be compared
reader study --corpus eval.db # sweep one condition on purpose
reader profile --corpus eval.db # where the milliseconds wentThirty seven questions over this repository's own documentation, pinned to a commit so that writing documentation cannot change the corpus underneath a measurement. Retrieval scores hit@1 0.68, hit@10 0.97 and MRR 0.771. Nothing was fabricated in 37 answers. Citation compliance is 0.49, which is the largest quality gap the harness measures and is reported as such.
Three properties make the numbers worth something. Every prompt carries a
distinct opening and each run spends two extra generations proving that the
opening actually defeated a live cache, because a control that cannot fail is not
a control. Host bandwidth is measured either side of the question set and a run
on a machine that has drifted refuses itself, which it has done more than once.
And compare refuses two runs whose conditions differ, except for the one
intervention under test, which it reports at the top instead.
Two runs of one commit produce byte identical answers on all 37 questions, so the harness has no noise of its own. What does move a result is the cache defeating salt each prompt carries, by up to four questions, and that is the floor a sweep is judged against.
src/ridgeline/ The engine
hardware/ Device discovery and capability probing
telemetry/ Power and per engine utilisation sampling
roofline/ Bandwidth measurement, ridge model, cost fit, prediction
runtime/ Registry, pipelines, budget, placement, compatibility
autotune.py Selecting a configuration from measurement alone
ports.py The interfaces the application depends on
src/reader/ The application
ingest/ Folder watching, parsing, chunking, the corpus database
index/ retrieve/ answer/
server/ The HTTP API, and the second composition root
web/ The browser interface: assets, no build step
evaluate/ The question set, the conditions a run must prove, the profiler
tests/
unit/ 838 tests, no hardware required
integration/ 55 tests, requires OpenVINO and a device
bench/ Measurement harnesses from the prior investigation, unchanged
docs/ Architecture, state, 29 decisions, 12 findings
CLAUDE.md, conventions and orientation, read firstdocs/architecture.md, design and module boundariesdocs/state.md, current status and every open issuedocs/findings/, the measurement writeupsdocs/decisions/, architecture decision recordsdocs/api.md, the HTTP interface and its error modeldocs/implementation-plan.md, the full build plan
Stated plainly, and there are more of them than there are features.
The engine has run on exactly one machine. That is the central limitation and
it is the claim the autotuner exists to support. Everything needed is in place:
no hardware figure anywhere in src/ is a constant, the roofline can be rebuilt
from a saved report on a machine with no Intel device, and a suite of tests drives
the whole selection chain over synthetic machines with no NPU, no integrated GPU,
one engine, or no calibration at all, checking that each degrades to a named
constraint rather than a crash. What has never happened is a model loading on
other silicon. See ADR 0029.
Prediction carries 49.8% held out mean absolute error. Good enough to rank engines, not good enough to quote as a number, and it says so on every prediction.
Citation compliance is 0.49. The model omits its citation marker on a third to a half of answers depending on how much context it is given. It is reported as unchecked rather than papered over, and nothing found so far raises it except suppressing the reasoning, which costs more elsewhere than it gains here.
A grounded verdict means the references are real, not that the answer is
right. Asked for the capital of Peru the model answered The capital of Peru is Lima [1], cited a document about HTTP endpoints, and passed every check, exactly
as designed. The badge says "References check out" for that reason.
Two of the five models cannot be used the way the plan assumed. The NPU refuses both Qwen3 exports in the VPU compiler, and the 30B mixture of experts gets no integrated GPU at all, since 15.6 GiB of weights against 17.7 GB exposed leaves nothing for the cache. Placement now declines an engine known to refuse a model rather than discovering it at load.
CPU decode of a model above roughly 2 GB is not reproducible on this machine. The same cell measured 9.46 and then 2.05 tok/s in two runs of one command. Those cells are flagged in the reports rather than dropped, and they are the worst cells in the error distribution, so the headline prediction error is pessimistic by an amount this machine cannot quantify.
A machine warmed by generation takes longer to recover than anyone guesses. Twenty five minutes of idle was not enough after two hours of sweeping. Quality is immune, because generation is deterministic and thermal state changes the clock rather than the text, but any cost comparison has to budget for it and nothing schedules that wait.
Watching is a walk rather than a watch. The server re-walks its folders on an interval; nothing subscribes to filesystem notifications, and an ingest run cannot be cancelled once started.
The server serves one request at a time and has no authentication. Both are
deliberate: decode is bandwidth bound so concurrent requests would queue in the
memory system anyway, and it binds to loopback, so an auth scheme would be a claim
about a threat model nobody has stated. The queue is visible on /health.
Nothing bounds request size or rate. Same non decision as the line above, and cheap to add the moment there is a reason to.
The browser interface has no compiler checking it against the schema. That is the price of having no build step: a renamed response field reaches the page as an undefined rather than as a failed build.
Telemetry is Windows specific. Per engine utilisation comes from the PDH API and power from the battery interface, which only reports while unplugged, and has never been exercised under discharge because every run so far has been on mains.
Embedding and generation overlap, but not for free. Generation retains 69.7% of its rate while the NPU embeds, so indexing belongs at ingest and not alongside an answer.
Apache-2.0. See LICENSE.