Skip to content

Fix the blank page reported in #1 and #2, and add tests - #4

Merged
punnerud merged 6 commits into
mainfrom
fix/blank-page-and-tests
Aug 10, 2026
Merged

Fix the blank page reported in #1 and #2, and add tests#4
punnerud merged 6 commits into
mainfrom
fix/blank-page-and-tests

Conversation

@punnerud

Copy link
Copy Markdown
Owner

Both open issues report the same symptom — the page stays blank — from two different causes. This fixes both, and adds the tests that would have caught them.

Issue #1: HTTP 500 instead of a stream

query() embedded the user's text before opening the SSE stream, and get_embedding raised on any non-200 response. A missing model therefore produced an HTTP 500, and the front end only had console.error in EventSource.onerror — so the browser showed nothing at all.

Reproduced against the code on main:

ISSUE #1: model missing -> what does the browser get?
  HTTP status : 500
  body bytes  : 265
  SSE events  : 0

Issue #2: the loop never terminated

Two retry branches used continue without incrementing step_count. A model that kept answering over 700 characters, or kept trying to finish before the minimum step count, held the loop forever without yielding anything.

Reproduced against the code on main:

ISSUE #2: model always answers over 700 chars -> does the loop end?
Step 1 content exceeded 700 characters. Retrying...
... (repeated indefinitely)
  STILL RUNNING after 15 seconds, having yielded 0 events

That "content exceeded 700 characters. Retrying..." line is the one quoted in #2.

Both branches are now bounded, and every path out of the reasoning loop ends in a final or an error event that the page renders visibly. There is also a /health route that names the exact ollama pull command needed.

Other bugs found while in there

Where Problem
find_similar Hardcoded 4096 dimensions while build_annoy_index took it as a parameter, so changing embedding model broke search silently
find_similar Annoy's angular distance (sqrt(2(1-cos)), range [0,2]) was reported as cosine similarity, and could go negative
calculate_strongest_path Accumulated cost - weight, i.e. negative weights, which invalidates Dijkstra's greedy choice — it returned the first path it reached, not the strongest, and called it a "weighted average"
calculate_strongest_path Hardcoded start_node = 'Step1'; a graph without it raised NetworkXError, which the except NetworkXNoPath did not catch
serialize_graph_data Computed edge length and then dropped it, so the similarity-proportional spring length never reached vis.js
stream_api_call max_tokens and temperature were sent as top-level keys, where Ollama ignores them; they belong under options as num_predict/temperature
stream_api_call .replace("'", '') stripped every apostrophe from the model's text
generate_response The final answer was drawn as a second node holding the same text as the last step, producing a spurious 1.00 edge between them
index.html Model-generated markdown went into innerHTML unsanitised
app.py debug=True on 0.0.0.0 exposed the Werkzeug console to the network
index.html Enter did not submit; no busy state; a second submit left the first EventSource running

Structure and tests

app.py is split into backends.py (chat/embedding backends, discovery, health), reasoning.py (the loop), graph.py (similarity and paths) and store.py (SQLite + search). The backends are injectable, so 56 tests run with no model and no network, including twelve that drive a real browser and read the graph back out of vis.js. vis-network and marked are vendored locally so those tests do not depend on a CDN.

annoy and scikit-learn are gone. An approximate index only starts paying for itself around a hundred thousand vectors and a query here produces about ten, so similarity search is now an exact numpy scan — which also removed the angular-distance bug and the one dependency that needs a C++ toolchain.

Measured, not asserted

make measure records how much the drawn edge weights actually vary, over four unrelated six-step reasoning chains:

Model Dim Mean edge weight Coefficient of variation
all-minilm 384 0.48 ± 0.07 0.28 ± 0.11
nomic-embed-text 768 0.67 ± 0.05 0.13 ± 0.03

The bigger model draws the less informative graph: under nomic-embed-text nearly every pair of steps scores about 0.67, so the edge labels stop distinguishing anything. Ordinary distance concentration, but worth knowing before picking a default.

scripts/check_numbers.py ties every measured claim in the README to a value in data/claims/ and runs in the same gate as lint and tests, so a claim that stops holding fails the build rather than quietly becoming wrong. Some of its checks are ground truths computed from arithmetic rather than from a previous run.

Download PNG

Includes the feature from #3, with the export moved to the client: vis.js already draws to a canvas, so canvas.toBlob() gives the layout the user is actually looking at. The server-side version re-plotted the graph with spring_layout, so the downloaded image did not match the screen, and it kept the graph in a module-level global shared between concurrent users. Credited via Co-authored-by.

Verification

make all          # ruff + 56 tests + the documented numbers
make test-ollama  # 12 more against a live Ollama

Checked end to end against llama3.2:3b with both all-minilm (384d) and nomic-embed-text (768d). example.png is regenerated from a real run.

Both open issues report the same symptom from two different causes.

Issue #1: query() embedded the user's text before opening the stream, and
get_embedding raised on any non-200. A missing model therefore produced an
HTTP 500 instead of an event stream, and the front end only had
console.error in EventSource.onerror -- so the page stayed blank and silent.
Reproduced against the original code: HTTP 500, zero SSE events.

Issue #2: two retry branches used `continue` without incrementing
step_count, so a model that kept answering over 700 characters, or kept
trying to finish before the minimum step count, held the loop forever
without yielding anything. Reproduced against the original code: still
running after 15 seconds having emitted zero events, printing the exact
"content exceeded 700 characters. Retrying..." line quoted in the issue.

Both are now bounded, and every path out of the reasoning loop ends in a
final or an error event that the page renders visibly.

Also fixed along the way:

- find_similar hardcoded 4096 dimensions while build_annoy_index took it as
  a parameter, so changing embedding model broke search silently. The
  dimension is now discovered from the backend and stored per row.
- Annoy's angular distance was reported as though it were cosine
  similarity, which could go negative. Replaced with an exact numpy scan;
  an approximate index only pays off around a hundred thousand vectors and
  a query here produces about ten.
- The strongest-path search accumulated negated weights, which invalidates
  Dijkstra's greedy choice, so it returned the first path it reached rather
  than the strongest. It now minimises -log(similarity), which is
  non-negative and therefore exactly optimal, and reports the geometric
  mean.
- Edge `length` was computed and then dropped during serialisation, so the
  similarity-proportional spring length never reached vis.js.
- max_tokens and temperature were sent as top-level keys, where Ollama
  ignores them. They belong under options as num_predict and temperature.
- The response stream stripped every apostrophe from the model's text.
- The final answer was drawn as a second node holding the same text as the
  last step, producing a spurious 1.00 edge between them.
- Model-generated markdown went into innerHTML unsanitised.
- debug=True on 0.0.0.0 exposed the Werkzeug console to the network.
- Enter did not submit, the button gave no busy state, and a second submit
  left the first EventSource running.

New: /health explains what is missing and names the ollama pull command.
Backends are injectable, so 56 tests run with no model and no network,
including twelve that drive a real browser and read the graph back out of
vis.js. vis-network and marked are vendored so those tests do not depend on
a CDN. scripts/check_numbers.py verifies the README's measured claims
against data/claims/ and runs in the same gate as lint and tests.

Download PNG now exports the canvas the user is actually looking at rather
than re-plotting the graph server-side with a different layout engine.

Co-authored-by: blueprintparadise <rhiray03@gmail.com>
The store was wiped at the start of every request, which made "Related
Questions and Answers" structurally unable to show anything except the
current run's own steps. It now persists across questions, so the panel does
what its heading says. LKG_RESET_DB=1 restores the old behaviour.

That raises a fair question -- if the store grows into the hundreds or
beyond, does the search need an approximate index? Measured rather than
argued, in scripts/bench_search.py, at 768 dimensions:

  vectors   exact scan   annoy query   annoy build (per insert)
      100      0.007 ms      0.031 ms          3.5 ms
    1 000      0.017 ms      0.032 ms           36 ms
   10 000      0.30  ms      0.031 ms          366 ms
  100 000      3.3   ms      0.032 ms        4 020 ms

An exact scan over a thousand vectors costs 0.017 ms against an LLM call
that takes seconds, and an Annoy index is immutable once built -- this app
appends after every reasoning step, so the whole index would be rebuilt each
time. That last column is worse than the exact scan at every size measured.

Separately, and decisively: on a current numpy the index returns wrong
answers. With annoy 1.17.3 and numpy 2.5.2 on Python 3.12,
get_nns_by_item(7, 5) returns [1] -- one result instead of five, and not the
vector itself, which must always be its own nearest neighbour at distance
zero. Reproduced in a clean environment built from the old requirements.txt,
so the shipped "Related Questions" panel was returning a single arbitrary
row. Pinned as a falsified-prediction guard: if a future build starts
behaving, the check fails and the decision gets revisited.

Search now keeps the matrix in memory instead of decoding every blob out of
SQLite per query, and uses argpartition rather than a full sort. The
decoding, not the arithmetic, was what would have made a growing store slow.

tests/test_store.py asserts exactness directly rather than assuming it: a
vector is its own nearest neighbour, and the ranking matches a full
brute-force sort. Plus cache invalidation on write, dimension and model
isolation, and growth across successive questions.
@punnerud

Copy link
Copy Markdown
Owner Author

Follow-up commit: the store now accumulates across questions instead of being wiped at the start of every request. That wipe made "Related Questions and Answers" structurally unable to show anything except the current run's own steps, so the panel never did what its heading says. LKG_RESET_DB=1 restores the old behaviour.

That makes it fair to ask whether the search needs an approximate index once the store grows. Measured rather than argued (scripts/bench_search.py, 768 dimensions):

vectors exact scan annoy query annoy build, per insert
100 0.007 ms 0.031 ms 3.5 ms
1 000 0.017 ms 0.032 ms 36 ms
10 000 0.30 ms 0.031 ms 366 ms
100 000 3.3 ms 0.032 ms 4 020 ms

An Annoy index is immutable once built, and this app inserts after every reasoning step, so the whole index gets rebuilt each time — that last column is worse than the exact scan at every size measured.

Separately, and decisively: on a current numpy the index returns wrong answers. With annoy 1.17.3 and numpy 2.5.2 on Python 3.12:

>>> idx.get_nns_by_item(7, 5)
[1]

One result instead of five, and not item 7 — a vector is always its own nearest neighbour at distance zero. Reproduced in a clean venv built from the requirements.txt on main, so the shipped "Related Questions" panel has been returning a single arbitrary row for anyone on numpy 2.x. This is pinned as a falsified-prediction guard: if a future build starts behaving, the check fails and the decision gets revisited rather than inherited.

Search now also keeps the matrix in memory rather than decoding every blob out of SQLite per query, and uses argpartition instead of a full sort. The decoding, not the arithmetic, is what would have made a growing store slow.

tests/test_store.py asserts exactness directly instead of assuming it — a vector is its own nearest neighbour, and the ranking matches a full brute-force sort — plus cache invalidation on write, dimension/model isolation, and growth across successive questions. 69 tests now run without a model.

An embedding API hands back one pooled vector from the top of the stack.
layers.py taps a chosen point inside a local model instead, which also makes
a model with no embedding endpoint usable, since a forward pass is all that
is required:

  LKG_EMBED_BACKEND=hf LKG_HF_MODEL=HuggingFaceTB/SmolLM2-135M \
  LKG_HF_LAYER=blocks.-1 python app.py

Verified end to end: the graph is built from layer -1 inside SmolLM2-135M
(576 dimensions) with llama3.2:3b doing the reasoning.

Layers are addressed structurally rather than by a per-architecture path.
blocks.0, blocks.12, blocks.-1, blocks.-1.mlp, or any explicit dotted path.
The block stack is found by taking the longest nn.ModuleList whose children
share one class, which covers Llama, Qwen, Mistral, Gemma, Phi, GPT-2,
GPT-NeoX, Falcon, BERT, ViT and CLIP with no lookup table.

Three things here are load-bearing and are the usual sources of a silently
wrong layer comparison:

- Hooks that pool inside the hook, not output_hidden_states=True. The flag
  materialises (n_layers+1, batch, tokens, hidden) before pooling, which is
  several gigabytes on an 8B model at a realistic batch and context.
- Intermediate blocks emit the raw residual stream while the model's own
  last hidden state has already been through the final norm. That norm is
  applied to every layer, otherwise two layers are compared across two
  different spaces and the result looks plausible rather than broken.
- Last-token pooling has to skip padding, on either side. Pooling the pad
  instead of the last real token is the classic version of this bug.

make sweep reports how well each layer separates one topic from another,
over the same four-topic corpus used for the embedding-model table. On
SmolLM2-135M separation grows roughly seventyfold with depth, from 0.002 at
blocks.0 to 0.148 at blocks.29. The first block is the control: it sees each
token before any context is mixed in, so it cannot tell the topics apart,
and its near-zero score is what says the deeper numbers are real rather than
an artefact of the metric. All four claims are pinned in check_numbers.

24 new tests. The addressing and pooling logic runs against synthetic
modules so it needs no download; the tests that need real weights are marked
hf. One of them asserts that two different layers do not return the same
answer, because if they did the hook would not be tapping where it claims.
pip install mpe-lkg
mpe-lkg

Published: https://pypi.org/project/mpe-lkg/0.2.0/

The modules move to src/mpe_lkg/ so there is a real package to install, with
templates and the vendored JS inside the wheel. Root app.py stays as a shim:
this has been a clone-and-run-app.py project since 2024 and the README said
so for two years, so `python app.py` keeps working from a clone whether or
not the package is installed.

New entry points:

  mpe-lkg           start the app
  mpe-lkg doctor    say what is missing and the exact ollama pull command,
                    exiting non-zero so it works in a script
  python -m mpe_lkg
  from mpe_lkg import create_app, health

Pure Python, so the artefact is a single py3-none-any wheel: one file for
Linux, macOS and Windows on every supported interpreter, nothing compiled at
install time.

On the question of writing this in Rust for the Python binding: it would not
be easier here. PyO3 turns one universal wheel into a matrix of per-OS,
per-architecture, per-ABI wheels needing cibuildwheel, and the codebase is
Flask, numpy and networkx -- a rewrite means reimplementing an HTTP server,
SSE streaming and a graph library to get identical behaviour. The WASM
argument is real but it is a different project: running the embedding model
in the browser needs the model itself compiled to WASM, which is not a
binding question. Green on three platforms does not require it, as this
commit shows.

CI is three workflows, one per platform, so each carries its own badge:

- Linux, the primary gate: Python 3.10 to 3.13, browser tests, the
  documented-numbers check, and a fresh-venv install of the built wheel.
- macOS and Windows: one Python version each. The platform axis is what
  these are for; the version axis is already covered on Linux.

All three cache pip via setup-python, and cache the Playwright browsers
under a key that includes the Playwright version -- roughly 100 MB per job
that would otherwise be downloaded every run.

Windows is the one most likely to find a real bug here, and the comment in
that workflow says why: an open SQLite handle cannot be deleted, path
separators differ, and pytest's temporary directories are cleaned up
differently.

Verified before spending Actions minutes: the suite passes locally on Python
3.10, 3.11, 3.12 and 3.13, the wheel installs into a clean venv and serves
its templates, and `pip install mpe-lkg` from PyPI works end to end.

pypi.yml publishes on a v* tag, after checking that the tag matches the
version in pyproject.
@punnerud

Copy link
Copy Markdown
Owner Author

Now packaged and tested on all three platforms.

pip install mpe-lkg

Published: https://pypi.org/project/mpe-lkg/0.2.0/ — verified end to end from a clean environment.

pip install mpe-lkg
mpe-lkg           # starts the app
mpe-lkg doctor    # what is missing, and the exact ollama pull command; exits non-zero

The modules move to src/mpe_lkg/ so there is a real package, with templates and the vendored JS inside the wheel. Root app.py stays as a shim — this has been a clone-and-run-app.py project since 2024 and the README said so for two years, so that keeps working from a clone whether or not the package is installed.

Green on Linux, macOS and Windows

Result
Linux, Python 3.10 / 3.11 / 3.12 / 3.13 69 passed, 12 deselected
macOS 69 passed
Windows 69 passed

Three workflows so each carries its own badge. Browser render tests run on all three — that is the point of the platform axis. pip is cached via setup-python, and the Playwright browsers are cached under a key containing the Playwright version, which is ~100 MB per job that would otherwise be re-downloaded every run.

Verified locally on 3.10, 3.11, 3.12 and 3.13 before spending any Actions minutes, and all three platforms passed on the first run.

On doing this in Rust

Asked and answered honestly, since the question was conditional ("if it is easier"): it is not easier here. Pure Python gives one py3-none-any wheel covering Linux, macOS and Windows on every supported interpreter, with nothing compiled at install time. PyO3 turns that into a per-OS × per-architecture × per-ABI matrix needing cibuildwheel. And the codebase is Flask, numpy and networkx — a rewrite means reimplementing an HTTP server, SSE streaming and a graph library to reach identical behaviour.

The WASM argument is real but it is a different project: running the embedding model in the browser needs the model itself compiled to WASM, which is not a binding question. Green on three platforms does not require Rust, as this PR shows.

Three workflows each had a job called "test", so they produced three check
contexts called "test". Branch protection identifies a required check by its
context name, and cannot distinguish two identically named ones -- so requiring
"macOS passed" was not expressible.

The checks are now Linux 3.10 .. 3.13, macOS, and Windows.
People screenshot a project's GitHub page and share it, so the file listing is
part of the first impression. The root is now five directories and four files:

  .github/ docs/ scripts/ src/ tests/
  .gitignore Makefile README.md pyproject.toml

- .DS_Store was tracked. Removed from the index and from disk.
- example.png moves to docs/, alongside docs/claims/ (was data/claims/).
- requirements.txt and requirements-dev.txt are gone. pyproject.toml already
  declares the same dependencies and the [dev] extra, and two files listing the
  same thing is one file that will drift.
@punnerud
punnerud merged commit 722ec5c into main Aug 10, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant