Repository navigation
Conversation
… it stays Replaces the glass/violet UI with an archival, structure-led system in two fully-designed themes: `cabinet` (dark, default) and `paper` (light), switched by one `[data-theme]` attribute. Colour. Every value measured with WCAG 2.1 against the real surface; the lowest ratio in the system is 5.58. Surfaces are opaque on purpose — translucency made every previous figure best-case and backdrop-dependent. `warning`, the subsystem-fault indicator, was 2.65 and failing at every size at 10px; it is now 8.18 on cabinet and 6.82 on paper at 12px. Two border tokens, because WCAG 1.4.11 wants 3:1 for a control boundary and the first values failed it everywhere: `--rule` is decorative, `--edge` is the measured minimum that clears. Brass cannot be both text and fill, so `primary` is the text-safe accent and filled controls use `plate`/`on-plate`. Structure. The metaphor is carried by layout, not paint: the case is the lighter material and content is recessed into it, inverting the usual dark-app depth. The five-item hover-expand icon rail becomes a catalogue index of drawer fronts with permanent label slips. Three stacked banners collapse into one severity-ordered region. Settings gains a sub-nav so Providers and Diagnostics stop navigating away from it, and SettingsPage.tsx drops from 1341 lines to 136 plus eight section modules. Answer sources leave the paragraph and become a ruled provenance margin carrying exactly what QuerySource provides — file, folder tag, chunk id, score. Guard. scripts/check-utilities.mjs diffs every utility used in src against the built stylesheet, and runs in both run_ci_checks.bat and ci.yml. The audit found 58 dead usages; this found more that its colour-prefix regex could not see — `prose prose-invert prose-sm` emitted nothing at all because @tailwindcss/typography is not a dependency, so the model's answer had no typographic styling whatsoever; `clip-triangle` was never defined, so every "triangle" provider dot drew a square; plus `no-scrollbar`, `animate-in`, `fade-in`, `zoom-in`, `slide-in-from-top-2` and `animate-fade-in-right`. Now zero. Also: all ten `!important` removed, radius 8 values to 4 and shadows remapped at token level (retroactive across ~190 existing utilities), daisyUI removed (zero component classes were in use), fonts self-hosted via @fontsource with a document.fonts.ready gate so the serif cannot pop in, renderer re-graded from a violet aurora to a lamp-lit vitrine through renderer/palette.ts with bloom at 0.35, `focus-visible` and `prefers-reduced-motion` added where there were zero of each, and aria attributes 8 -> 28 including live regions for the streaming answer and indexing progress. Verified: run_ci_checks.bat green (894 passed, 1 skipped), vitest 121/121, Playwright 4/4, utility diff clean. Not done, deliberately: Explorer, Providers, Diagnostics and Setup have the systematic treatment but no per-page design pass, and eight of the new primitives in components/ui are not yet imported anywhere. This commit also carries the UI/UX audit work that was already uncommitted in the working tree when this began — cloud-consent gating, the Diagnostics and NotFound routes, the provider endpoint save fix, and the reembed/retrieval changes. It is interleaved with the above in shared files and could not be separated cleanly.
Reference document for the frontend design system, in the vault's numbered series and stamped against 3e186e9. Carries the measured contrast tables for both themes (lowest ratio 5.58), the brass ramp and why the plate inverts per theme, the structural doctrine, the utility guard and what it found, and — deliberately — the list of what is NOT done, so the next person does not have to rediscover it. Two sections exist because the information is expensive to re-derive: - "Constraints that bite" — the h-[560px] ResizeObserver loop on Insights, the no-inline-script CSP, the real shape of FileEntry and QuerySource, and the fact that the 3D view is a filter control rather than an ornament. - "Retractions" — four claims asserted before they were checked, including a background command's exit code reporting the shell line's status rather than the script's, which produced a false green on the CI gate. Added to the reading order in 01_PROJECT_CONTEXT.md.
…ntirely The vitrine still rendered a bright violet nebula after the palette tokenisation, on the tier most machines actually fall back to. WebGL2Renderer.ts carries an inline GLSL sky shader in a template literal with its own hardcoded gradient — horizon/mid/zenith, two nebula stops and a star tint. The earlier verification searched for `0x......` and `new THREE.Color(...)` in the TypeScript, and for `vec3<f32>` under shaders/. GLSL inside backticks matched neither, so eleven three.js literals were retokenised, the tier was declared done, and the thing that actually paints the sky was left alone. The claim "no violet literals remain" was false. Found by capturing screenshots, not by any check that existed. The six sites now template from VITRINE through a new `glslVec3`. Verified by re-capture: warm ink ground, no purple. Also adds scripts/capture-screens.mjs, which writes the app's surfaces to PNG in both themes using the Playwright already in devDependencies. Output goes to `PMA Obsidian/files/`, which is gitignored — screenshots do not belong in history. It waits on document.fonts.ready and the fade-in before each shot; without that the captures come out half-faded. Verified: run_ci_checks.bat green (894 passed, 1 skipped), vitest 121/121, utility diff clean.
Adds a karpathy/autoresearch-shaped loop over the retrieval pipeline: propose a
configuration, run it, score it against labelled answers, keep or discard, append
to a journal. Consolidates five workstreams that had accumulated uncommitted.
The loop's first real finding is a shipped defect four sessions of hand-tuning
had walked past. `_format_snippets` gives each of the first three snippets 34% of
the REMAINING budget, so three snippets can consume at most 1 - 0.66^3 = 71.2% of
it, ever. `3b_local` has exactly three slots, so 29% of its context budget was
unreachable by construction: predicted 1,789 tokens, measured 1,796. Raising the
share to 0.7 takes gemma2-2b answer-recall 0.317 -> 0.618 (+0.301, about 5x the
measured 0.06 detection threshold) while the 7b control stays flat at 0.706 ->
0.708. Two sweeps found nothing first -- `max_chunks` and `max_per_file` are both
non-binding -- and the nulls are what forced reading the allocation code instead
of sweeping harder.
Shipped behaviour is UNCHANGED. `context_snippet_head_share`,
`context_max_chunks_small` and `context_max_per_file_small` are settings now, with
their previous literals as defaults, so they can be swept at all. Changing the
head share is a product decision and is not taken here.
Also settles `chunk_size=2048` on generation quality rather than a delivery
proxy (gemma4-local 0.563 -> 0.729 across 512/1024/2048, decisive; gemma2-2b
0.267 -> 0.328, ~3 sd), and records the first externally comparable number:
BEIR SciFact nDCG@10 = 0.70 with the reranker on, against published
bge-small-en-v1.5 SciFact scores of 0.695 and 0.713. PMA matches its embedder;
the reranker contributes +0.03. That number validates the embedder, fusion and
reranker, and says nothing about chunking, because BEIR max-pools chunk
differences away.
Four corrections to the record, all measured rather than argued:
- The eval called `build_context` without a `model_class`, so it took the
default, "cloud". Every delivery number behind the 2048 decision described
`max_chunks=15` and an 8000-token budget, which a local 3B never receives.
- Delivered coverage is NOT a general proxy for generation quality. It is valid
for a knob that changes how MUCH text is delivered and invalid for one that
changes WHAT the text is; on `chunk_size` it ranked `3b_local` in the opposite
order from generation.
- Three builds resolves nothing below ~0.06 answer-recall. Measured from nine
runs of a configuration the knob under test could not change: sd 0.036 per
build, ~0.021 on a three-build mean.
- A `max_chunks` sweep looked like a clean monotonic win until the control arm,
which cannot read that setting, moved further than the treatment.
Workstreams consolidated here:
* provider dedup -- groq/lm_studio/nvidia_nim/openai/openai_compatible collapse
onto the pre-existing openai_compat.py
* chunking, CLAUDE.md 8.7-8.7e -- corpus_large and span ground truth, the code
chunker dispatch and knowledge graph, parent windows, the BGE query prefix,
reranker RRF fusion, the blank-citation fix
* canvas keyboard navigation, 8.1f -- keymap, AccessibleTree, ShortcutOverlay
* housekeeping -- rust-toolchain.toml pin, local/CI parity, dead script removal
* autoresearch, 8.7f -- this work
Deliberately not split by workstream: seven files carry interleaved changes from
two or more of them, so a hunk-level split would produce intermediate commits
that no gate ever verified. This tree is verified as a whole, which is the
stronger guarantee.
New tests are negative-controlled -- reverting each change fails its test,
restoring it passes. Two of those controls found defects review had not: `_key`
ignored whether a generation arm ran, so `--resume` would let a cheap screening
run satisfy an expensive one, and `answer_token_recall` silently computed
precision.
…a bigger one
`context_snippet_head_share` was raised to 0.7 globally on the strength of a
sweep that stopped at 0.7. Extending it to 0.8 broke the control and showed that
value to be one step from a cliff, so it is now split per class.
gemma4-local (7b_local) is flat at 0.706 / 0.718 / 0.708 through 0.7 and
collapses to 0.428 at 0.8 - a 0.28 drop, about 4.7x the measured 0.06 detection
threshold. The mechanism is the tail. Snippets surviving a 15-candidate pool at
an 8,520-token budget:
share | gemma2-2b | gemma4-local | 7b snippets
0.34 | 0.317 | 0.706 | 15 of 15
0.50 | 0.520 | 0.718 | 15 of 15
0.70 | 0.618 | 0.708 | 6 of 15
0.80 | 0.581 | 0.428 | 4 of 15
At 0.7 the 7b class had already lost 60% of its snippets and scored flat ONLY
because this corpus ranks well enough that the answer sat inside the survivors.
A global 0.7 was therefore protected by a property of an 8-query fixture rather
than by design.
The split is structural, not a compromise. 3b_local has max_chunks=3, so every
snippet is in the head branch and there is no tail to starve - more share is
strictly more evidence, up to its measured optimum. 7b_local and cloud get 15
slots and lose theirs. So:
* context_snippet_head_share_small = 0.7 - the small class's optimum
* context_snippet_head_share = 0.34 - every other class, the largest
value that provably loses no snippet
Measured end to end at these defaults: 3b_local delivered coverage 0.319 ->
0.703 and 1,680 -> 2,357 tokens; 7b_local and cloud unchanged at 1.000.
Also in the runner, all three found by a real failure rather than by review:
* a crashed build was journalled as done, so --resume never retried it and the
arm stayed one build short, silently, because the leaderboard drops failed
rows and just reports a smaller n
* a generation arm that was requested and errored on every query was recorded
as a successful run. Ollama died mid-sweep and three builds "succeeded" in
69 s each with 8 errors per model. eval_chunking was right to refuse to call
that recall 0.0; the runner was wrong to call it done
* the same two models passed in a different CLI order produced a different
resume key, and the leaderboard grouped delivery-only and generation runs
together, reporting n=6 for a 3-build arm
Adds --gen-provider so the generation arm can run against a cloud provider.
Cloud keys come from the keyring the app already writes, and the provider is
built directly rather than through LLMClient._resolve_provider_by_id: that path
enforces llm.cloud_privacy_consent, which guards the PRODUCT, and flipping a
persisted privacy flag as a side effect of a measurement is the wrong trade. The
corpus is generated fiction. Model class comes from get_model_class, so a NIM
tag is scored as cloud rather than having "70b" parsed out of its name and being
handed a 7b_local-shaped budget.
The test guarding the head share was vacuous twice before it was real, and the
negative control caught it both times. Asserting on delivered token totals cannot
discriminate - _format_snippets always fills the budget - and snippets shorter
than the per-snippet budget never trigger truncation at all. What moves is how
many snippets survive; the test counts those.
Gate: run_ci_checks.bat green, 951 passed, 1 skipped, 6 deselected.
… to a dead model Two defects, the second found while fixing the first. `OpenAICompatibleProvider.validate()` stops at a GET of the provider's models endpoint. For NIM that is https://integrate.api.nvidia.com/v1/models, which **NVIDIA serves publicly**: measured HTTP 200 and 82 models with no Authorization header at all. So validation reported ok=True for a fabricated `nvapi-zzzz...` key exactly as for a real one, and the user only found out when a query returned 403 from /chat/completions. Scoped by probing every cloud spec unauthenticated - anthropic 401, gemini 403, groq 403, openai 401, nvidia_nim 200. NIM alone. `ProviderSpec.models_endpoint_authenticates` (default True, False for NIM) marks the case, and a second probe sends the smallest possible completion, max_tokens=1, purely to make the server read the key. **The probe is one-directional: only 401/403 may overturn a passing validation.** A timeout, a 429, a 500, a model that refuses chat - all leave the verdict alone. The bug being fixed is a silent false positive; trading it for a false negative that tells someone their working key is broken would be the worse deal. **The first version of the fix changed nothing, and that is the second defect.** _OPENAI_COMPATIBLE_DEFAULT_MODEL["nvidia_nim"] was meta/llama-3.3-70b-instruct, which NVIDIA has RETIRED - it answers 410 Gone. Every NIM user who did not override the model was getting that instead of an answer. The probe used the same default, got 410, correctly declined to invent a failure, and so left a dead key passing. Default repointed to nvidia/llama-3.1-nemotron-70b-instruct; the probe now skips 404/410 and tries further candidates, capped at _AUTH_PROBE_MAX_MODELS. NVIDIA checks model existence BEFORE the key, which is usable: a deliberately invalid key returns 410 for a retired model and 403 for a live one, so the two can be told apart with no valid key. Verified live that way: nvidia/llama-3.1-nemotron-70b-instruct, mistralai/mistral-nemotron, nvidia/nemotron-3-super-120b-a12b and nvidia/nemotron-3-ultra-550b-a55b are live; meta/llama-3.3-70b-instruct is not. Verified against the live API and not only against mocks: before, a real key and a fabricated one both returned ok=True; after, both return ok=False with auth_failed. The tests are fully mocked and hit no network, per section 11, and each was negative-controlled - reverting its fix fails it. Gate: run_ci_checks.bat green, 960 passed, 1 skipped, 6 deselected.
After the head-share fix the 3b_local class uses 93.5% of its budget (2,357 of
2,520), so the ceiling rather than the allocation is what now limits it - and
1,262 tokens of headroom sit unused before gemma2-2b's measured 4,099-token
prompt cap. That looks like free coverage. It is not worth taking.
EFFECTIVE_CEILINGS["3b_local"] is promoted to settings.context_ceiling_small so
it could be swept at all. Three builds per arm, delivery stage:
ceiling | budget | 3b coverage | sd | worst-case prompt
4000 | 2,520 | 0.743 | 0.069 | 2,809
4500 | 3,020 | 0.783 | 0.069 | 3,133
5000 | 3,520 | 0.822 | 0.119 | 3,443
+0.079 at best against sd 0.069-0.119: about 1 sd, not significant. The risk is
not symmetric with it. Exceeding a model's real window truncates HEAD FIRST and
silently, costing the system instructions before it costs evidence - measured
earlier when gemma2-2b, handed 8,331 tokens, kept the last 4,099 and returned
garbage. And 3b_local is assigned by parsing the model NAME, so headroom measured
on gemma2-2b is not headroom on another model landing in that class.
Default unchanged at 4000. The setting stays because the sweep is what
established that, and app/config.py carries the numbers so it is not re-run.
One build crashed with rc=3221225477 (access violation, ~1 s in) - section 13's
intermittent instability, the third this session. `--resume` retried it
automatically, which is the fix from the previous commit working on a real crash
rather than a constructed one.
The principled fix, not built and deliberately left for discussion: derive the
budget from the provider's REPORTED context length instead of a class table.
Ollama exposes it directly - that is how 131,072 for gemma4-local and 8,192 for
gemma2-2b were measured - and reading it would delete EFFECTIVE_CEILINGS and the
name heuristic together. It changes compute_context_budget for every provider,
so it is a section 1.5 conversation.
Gate: run_ci_checks.bat green, 960 passed, 1 skipped, 6 deselected.
… a stale null
The journal keys experiments on config, build and generation arm - not on code.
That is deliberate: keying on the commit would invalidate the whole journal on
every push, which would make the loop useless rather than rigorous. But it means
two rows with identical configs can have measured different software, silently.
It has already mattered. `max_per_file` swept flat over 1/2/3, and that null was
recorded as settled - but it was measured while the snippet allocation was the
binding constraint, so everything downstream of that ceiling was starved equally
and per-file dedup had nothing left to change. **A null is only null for the
configuration it was measured in.**
Re-swept after the allocation fix, and the answer holds - but for better reasons
than the first time:
per_file | pre-fix coverage | post-fix coverage
1 | 0.398 | 0.822 <- shipped, best in both eras
2 | 0.359 | 0.743
3 | 0.319 | 0.763
The spread is ~1 sd either way, so it is a null both times; what is worth having
is that the direction is consistent across two configurations rather than resting
on one stale measurement. Coverage roughly doubling across every arm (0.36 ->
0.78) is the head-share fix from the previous commit turning up in a sweep that
was not about it - an independent confirmation.
Journal rows now carry `code`: short git HEAD plus a `+dirty` marker, best effort,
degrading to "unknown" when git is unavailable so provenance can never take a
sweep down. `shutil.which` resolves git rather than suppressing ruff's S607.
Gate: run_ci_checks.bat green, 962 passed, 1 skipped, 6 deselected.
…g condition
Sweeping tests/eval/corpus_large is finished. Recording why, so the next session
does not spend a day rediscovering it.
Six of eight queries now deliver the whole answer to 3b_local. The two that do
not fail for unrelated reasons, and neither is a knob:
geometry_cache 3b cov 0.525, 1,169 tokens of a ~2,440 budget - starved of
MATERIAL, not budget. Three chunks from three files under
max_per_file=1, and the parent window only widens so far.
turbulence 3b cov 0.059, 2,437 tokens - budget FULL of the wrong text.
The answer sits at rank 4-10, which only the 15-slot class sees.
Everything else is 1.000. Fixing either failure moves the mean by ~0.12 against a
delivery noise floor of sd 0.07-0.12, which is the resolution limit of an
8-query fixture.
Four knobs swept post-fix, all null at ~1 sd:
context_ceiling_small 4000/4500/5000 +0.079, against silent head-first
truncation risk. Not raised.
parent_window_multiplier 3/5/7 +0.080 then plateau - the predicted
shape - but ~1 sd, and it costs
7b_local 14% more tokens for nothing.
reranker_rrf_fusion_weight 0.0/0.5/1.0 3b coverage moves 0.02 across the whole
range. Ranking is NOT the constraint.
context_max_chunks_small 3/5/8 re-swept post-fix, still null - the head
share starves the tail now, where the
allocation ceiling did before.
That is not four failures, it is one result. **head_share and max_chunks are
antagonistic**: at 0.7 the first three snippets take 97.3% of the budget, so extra
slots receive ~80 tokens each - enough to name a file, not to carry an answer.
With a 2,520 token budget fewer-and-fuller beats more-and-thinner, because an
answer span only counts if it arrives whole. max_chunks=3 with a high head share
is the right shape rather than a compromise.
One nuance worth not over-reading: at fusion weight 1.0 the cross-encoder is
ignored for ordering and nothing changes. That is a saturation artifact of this
corpus at chunk_size=2048, NOT a case for removing it - 8.7d has it clearly
positive at 512, and SciFact at +0.03 nDCG.
Gate: run_ci_checks.bat green, 962 passed, 1 skipped, 6 deselected.
…s 7-14x
Sweeping tests/eval/corpus_large was declared finished earlier today because
every knob returned null at about one standard deviation. That was a property of
the fixture - 8 queries, sd 0.069-0.137 - not of the knobs. This replaces the
limit rather than working around it.
scripts/fetch_squad_corpus.py builds a span-labelled corpus from SQuAD
(rajpurkar/squad, CC BY-SA 4.0). RESHAPED, not used natively: a SQuAD paragraph
is ~700 characters, shorter than one chunk at the shipped chunk_size=2048, so
every answer would sit alone in its own chunk and no chunking would be exercised
at all. Each article's paragraphs are concatenated into one document and every
answer_start recomputed into it.
corpus_large corpus_squad
documents 24 generated 48 real Wikipedia articles
mean length ~4k chars 33,428 chars
chunks 140 899
queries 8 100
Every span is verified rather than trusted: document[start:end] == answer_text is
asserted for all 6,361 candidates and 0 failed. An off-by-one offset is invisible
in a metric and would poison everything downstream.
The measurement that matters, 3 builds:
corpus delivery sd/build detectable at 3 sd of a 3-build mean
corpus_large 0.069 - 0.137 ~0.12 - 0.24
corpus_squad 0.0053 - 0.0096 ~0.017
Effects about 7x smaller are decidable now. context_ceiling_small (+0.079),
parent_window_multiplier (+0.080) and max_per_file_small (+0.079) were all ~1 sd
on the old fixture and are worth re-running here.
One finding hardens from suspected to solid: on real prose 3b_local delivers
0.838 against cloud and 7b_local at 0.910 - 7.2 points at sd ~0.01, roughly 7 sd.
Retrieval finds the answer 98% of the time (document nDCG 0.957), so that gap is
delivery, not retrieval.
Keep all three corpora. SQuAD answers are 1-5 words, so this buys statistical
power and NOT long-answer coverage; corpus_large's long spans remain the only
measure of whether a long answer survives delivery, and SciFact remains the
externally comparable document-ranking check that speaks to neither.
Also here:
* autoresearch takes --corpus/--queries, and the corpus is part of the resume
key. The first version documented "do not mix corpora in one journal", which
is a bug written down rather than fixed - a corpus_large row could satisfy a
corpus_squad resume and the leaderboard would average two fixtures.
* eval_chunking's cloud key lookup now uses production's precedence, settings
before keyring. They disagreed: the keyring held a stale key returning 403
while .env held a working one, so the eval would have scored a live model as
unreachable.
* scripts/materialize_corpus.py turns any real folder into a text corpus with
offsets valid for labelling, mirroring indexing's extraction dispatch. Written
for a personal corpus; its output path was gitignored before it could run.
* Retracts a claim made earlier today: that an invalid key distinguishes live
NIM models (403) from retired ones (410). Only the 410 half holds -
llama-3.1-nemotron-70b-instruct returned 403 to a bad key and 404 to a good
one. 403 means auth was checked first, not that the model exists.
Gate: run_ci_checks.bat green, 963 passed, 1 skipped, 6 deselected.
`context_max_per_file_small` goes 1 -> 2. It is the first of the nulls
`corpus_squad` was built to re-decide, and it lands where the noise-floor
argument said it would.
On `corpus_large` this knob measured +0.079 delivered coverage at sd
0.069-0.137 and was recorded as a null at about one standard deviation. On
`corpus_squad` -- 100 queries over 48 real Wikipedia articles, sd 0.0043-0.0078
-- the same knob measures +0.063 with the per-value ranges completely disjoint:
value 1's best build is 0.8467, value 2's worst is 0.8976. The knob did not
change. The fixture did.
Three builds per arm, reranker on:
max_per_file_small | 3b coverage | sd | 3b tokens | 7b_local (control)
-------------------+-------------+--------+-----------+-------------------
1 | 0.8417 | 0.0043 | 1,481 | 0.9126
2 | 0.9043 | 0.0058 | 1,697 | 0.9093
3 | 0.9058 | 0.0078 | 1,764 | 0.9092
It saturates at 2 -- 2 -> 3 buys +0.0016, inside build noise -- so 3 is not
taken. The 7b_local control cannot read this setting and stays flat within
0.0034, which confines the change to the class it is for.
What it buys: `3b_local` delivered coverage 0.842 -> 0.904 against cloud's
0.909. Section 8.7g opened by measuring that gap at roughly 7 sd and
attributing it to delivery rather than retrieval, since document nDCG is 0.957.
This closes most of it, which is the point of section 3 -- the small class
should not be the one that loses answers.
Stated rather than left to be discovered: this is a corpus-shape bet. Two
chunks per file helps when the answer needs two passages of ONE document and
costs file diversity when it needs one passage each from three, and every SQuAD
answer sits inside a single article. `corpus_large` is not shaped that way and
measured +0.079 in the same direction, just without the power to call it. Two
fixtures agreeing in direction is the argument; neither alone would be.
Also adds the test that was missing. A pinned default proves nothing if the
setting stops reaching `_deduplicate_by_file`, and nothing covered that path --
the existing slot tests build distinct files on purpose, which never exercises a
per-file cap. The new test feeds four passages of one file and counts survivors.
Negative-controlled: hardcoding `max_per_file = 1` fails it, restoring the
setting passes.
… -> 1024
Two changes, one measured and one instructed, kept together because the second
makes the first matter more.
MEASURED: `StreamChunker._find_boundary` carried seven separators, all paragraph
or sentence level, and fell through to a cut at exactly `pos` -- which lands
INSIDE A WORD and hands the embedder a split token. Adding the rungs LangChain's
ladder ends with (a line break, clause separators, and a space) removes them
entirely. On tests/eval/corpus_squad at chunk_size=1024:
7 rungs (was shipped) 1909 splits blind 6.13% MID-WORD 3.67% (70)
11 rungs (ships) 1917 splits blind 0.63% MID-WORD 0.00% (0)
The 100-character lookback also becomes a setting scaled off chunk_size,
defaulting to 0.25. That is a separate axis from the cascade: the cascade kills
mid-word cuts at any width, while the width decides boundary quality. At 100 the
rungs reached are paragraph 12.7% / sentence 46.4% / clause 29.3% / word 10.9%;
at 256 they are 31.4% / 62.5% / 5.4% / 0.2%. So 94% of chunks now end on a
sentence or paragraph boundary against 60% before.
A wrong diagnosis of mine is retracted in the process. I first reported that the
lookback "silently became 4.9% of the chunk when chunk_size went 512 -> 2048".
False: it is an absolute character count, so the blind-cut rate is a property of
the prose rather than of chunk_size -- 39.8% at 512, 39.4% at 1024, 40.5% at
2048. It never regressed. What chunk_size changes is the absolute count, and
1024 produces exactly 2x the broken sentences of 2048 because it splits twice as
often.
INSTRUCTED: chunk_size 2048 -> 1024, against the evidence in CLAUDE.md 8.7f,
which settled 2048 on generation quality monotonically on both local models. The
trade is precision -- at 2048 char_precision is 0.024, i.e. 97.6% of what the
model reads is not the answer, which 8.7e left flagged and unresolved. Whether
the trade is net positive is NOT measured here and needs the generation arm,
because 8.7f showed delivered coverage ranking chunk_size in the opposite order
from generation for 3b_local. Coupled settings move with it: chunk_overlap
204 -> 102 per the stated 10% rule, and CodeChunker off a literal max_tokens=512
onto chunk_size // 4 -- 8.7 A6's "the two chunkers agree" was a coincidence of
one value until now, and 1024 would have reopened it at 2x.
LangChain was read as a reference (langchain_text_splitters 1.1.2, already in the
uv cache) and deliberately not ported. `character.py::_split_text` line 118 tests
whether a separator occurs ANYWHERE in the document and the first match wins for
the whole split, so every boundary lands on that rung even when it badly
under-fills; reproduced on a clean install, a 609-character paragraph against a
1024 budget fills 59.5%. That is the same failure 8.7b measured and rejected for
`chunk_markdown`, precision 0.163 -> 0.100. Searching only [pos - lookback, pos]
avoids it for free by making the lookback a minimum-fill floor. No Markdown rungs
were taken either -- A3's measurement outranks a reference implementation.
The fixture guard failed on the way, and it was the chunker improving: with
sentence-aligned boundaries the labelled long answers stopped being shredded,
going from 3 chunks to 2 at the reference size, which collapsed the short/long
contrast a sweep reads. REFERENCE_CHUNK_SIZE is recalibrated 512 -> 384, where
short [1,1,2,2] and long [3,3,3,4] separate cleanly. The assertions are unchanged
and still bite at 512. That constant is independent of settings.chunk_size for
precisely this reason.
Five new tests, negative-controlled: deleting the word rung fails two of them,
restoring passes. One had to be rewritten BECAUSE the cascade landed -- it
asserted a blind cut that no longer happens.
Gate: 964 -> 969 passed, 1 skipped, 6 deselected, SCRIPT_EXIT=0. Baseline
measured earlier the same session rather than read from CLAUDE.md.
…t is not Comment-only. Verified: `git diff` changes zero non-comment lines and the parsed AST is byte-identical to the parent, so a sweep whose rows span this commit is still a valid comparison. `_extract_and_chunk` opened with "Code and markdown are chunked from the whole file ... chunk_markdown needs to see every heading", the buffering branch said "_create_chunks routes on the extension: CodeChunker for source, chunk_markdown for markdown", and `_create_chunks` itself described its markdown branch as the live fix for CLAUDE.md 8.7 A3. All three describe a version that was reverted. `_WHOLE_TEXT_EXTENSIONS = _CODE_EXTENSIONS`, and the buffering branch is the only production caller of `_create_chunks`. So markdown never buffers, never reaches that function, and its markdown and .txt branches -- and therefore `rust_core.chunk_markdown` and `rust_core.create_chunks` -- are reachable from tests only. Prose streams through StreamChunker. The behaviour is correct and deliberate: section-aware markdown chunking was built, measured worse twice, and reverted (8.7b, A3 -- chunk precision 0.163 -> 0.100 because merged runs join unrelated subsections). Only the prose was left describing the losing version, so the file contradicted its own 8.7b comment block twenty lines above. A reader trusting these comments would believe markdown is structure-chunked when it is not, which is exactly the wrong mental model to debug retrieval with. The dead branches are kept rather than deleted: they are exercised by tests/test_chunker.py and are what A3 would re-route if a corpus is ever found where section boundaries carry more signal than they do here. They are now labelled NOT REACHED IN PRODUCTION so that stays a choice rather than a discovery.
The generation sweep that CLAUDE.md 8.7h left open. 1024 was shipped yesterday
on instruction and against the evidence in 8.7f, which had settled 2048. The
measurement reverses 8.7f, and reverses my own stated prediction.
corpus_squad, 100 queries over 48 real Wikipedia articles, 3 builds per arm,
reranker on, both arms running the new separator cascade:
model 1024 builds 2048 builds delta
gemma2-2b .8803 .8917 .8629 .8307 .8123 .8153 +0.0589
gemma4-local .8933 .9368 .9097 .8376 .8316 .8275 +0.0810
The per-arm ranges are DISJOINT on both models -- 1024's worst build beats
2048's best. gemma2-2b is the clean arm: zero empty answers at either size, so
recall and recall_answered agree exactly.
gemma4-local's delta is inflated and survives deflation. 13 empty answers at
2048 against 0 at 1024; excluding them it is +0.0433, still over its 0.038
threshold. Those empties are not only a measurement artifact -- a thinking model
given more context spends its whole num_predict budget reasoning and returns no
content, so 13% of queries fail for a real user. Abstentions move the same way,
8 -> 15.
Everything else agrees. Document nDCG .9872 vs .9686, 3b delivered coverage
.9652 vs .9370, and 3b receives 1,162 tokens against 1,702 -- 32% less text and
better answers, which is 8.7e's precision argument paying off.
WHAT THIS DOES NOT SETTLE, and the commit says so because a future reader will
want to cite it: whether 1024 beats 2048 without the cascade. Both arms ran with
it, and 1024 splits twice as often, so better boundaries plausibly help it more
-- the boundary fix may have MOVED the optimum rather than 8.7f having been
wrong. That is confounded with the corpus change and separating them needs a
no-cascade arm nobody has run.
Two corrections recorded rather than quietly dropped. Seeing all three model
classes deliver an identical 1,162 tokens at 1024, I proposed that
context_max_per_file_small 1 -> 2 had collapsed them; wrong, they separate again
at 2048, so the collapse is chunk-size-dependent. It does mean 7b_local is not a
usable control arm at 1024. And the detection threshold I quoted from two builds
(0.014) was an underestimate -- the third build put gemma2-2b at sd 0.0145, so
the threshold is 0.025. A two-build spread underestimates sd.
Also bounds 8.7f's "abstention never fired" to corpus_large. It fires on every
arm here.
No code changes: settings are already at the measured optimum.
Ollama returns reasoning in `message.thinking`. `OllamaProvider` never read that
field -- `chat` returned `data["message"]["content"]` and `stream` yielded only
`content` -- so a model that spent its whole `num_predict` budget reasoning
returned an empty string and the UI rendered a blank answer with no error to
explain it.
Measured before it was understood. The chunk_size sweep recorded 13 of 100
queries empty on `gemma4-local` at chunk_size=2048 against 0 at 1024, which
inflated that arm's apparent answer-recall delta by nearly half. CLAUDE.md 8.7f
had already named the mechanism as an EVALUATION trap; this is the same
mechanism as a PRODUCTION defect, and 13% of queries failing is not a
measurement artifact.
Reproduced against the live API. A 6,000-character RAG prompt at
num_predict=256:
as shipped content=0 chars thinking=1113 done=length
identical + think:false content=1271 chars thinking=0 done=length
Fixed as a RECOVERY rather than a blanket `think: false`. Both `chat` and
`stream` retry once with thinking disabled when a reply carried reasoning and no
content. Two deliberate constraints on that:
* Gated on reasoning actually having been seen. An empty answer with no
reasoning behind it is the model's own choice, and retrying would spend a
second round trip to get the same nothing.
* Not global. Reasoning measured fine at the shipped chunk_size=1024 -- zero
empties, 0.9133 answer-recall -- so disabling it everywhere would change
behaviour that currently works on the strength of no measurement at all.
This path only fires where the answer is already lost.
`think` is safe to send to a model that does not support it, verified against
`gemma2-2b`, so the retry needs no capability probe.
Tests are fully mocked and hit no network (section 11); the live behaviour above
was verified separately. They pin both directions: the retry fires on
reasoning-only replies, and does NOT fire on a normal answer -- the happy path
must stay one round trip -- or on an empty reply with no reasoning.
Negative-controlled: severing the recovery fails exactly the three recovery
tests while the three "must not retry" tests keep passing, which is what shows
they test different things.
Gate: 969 -> 976 passed, 1 skipped, 6 deselected, SCRIPT_EXIT=0. The 969
baseline was measured earlier this session, not read from CLAUDE.md, and +7 is
exactly the new test file.
…silence Two changes that came out of a design review REJECTING a larger one. CLAUDE.md 8.7f proposed deriving the context budget from the provider's reported context_length, deleting EFFECTIVE_CEILINGS and the name heuristic together. Confirmed live against Ollama, that is unsafe: it reports context_length 8192 for gemma2-2b, which 8.7f measured honouring ~4,099 tokens. The declared window is 2x the usable one for the very class section 3 exists to serve, so trusting it would have reproduced the same silent truncation with a provider's authority behind it. Two further objections, either sufficient alone. `model_class` does two unrelated jobs -- it sets a token ceiling AND selects a delivery shape (max_chunks 3 vs 15, max_per_file, head_share 0.7 vs 0.34, blanking folder profiles and graph paths). A reported window can inform the first and says nothing about the second: 8.7f measured the small class wanting FEWER AND FULLER snippets, which is attention quality, not window size. Deleting the class re-shapes delivery for every local model and invalidates 8.7f through 8.7h. And a declared 262,144-token window is a property of the model, not of a 4GB-VRAM machine. So what ships cannot move a budget: list_models reports the real window. It returned a hardcoded 8192 for EVERY Ollama model -- wrong for most of them, and ProvidersPage.tsx:700 renders it to the user as "N ctx", so someone choosing a model for long documents was shown 8,192 for a 131,072-token one. Read from details.context_length in the /api/tags response list_models already fetches, so it costs no extra request; verified identical to /api/show for all six local models here. Display and diagnostics only -- nothing in the budget path reads it, by design, and the docstring says so to stop a future reader wiring it up. The silent fallback now announces itself. A model whose name carries no parameter count gets `7b_local` and ~8,520 tokens purely because nothing matched; if it is actually small, its prompt is truncated head-first, so the system prompt goes before any evidence and the answer degrades with nothing in the logs. Every model in this library is a custom *-local import with no digits, so that path is the normal one here. One warning per model name, not per query -- get_model_class runs on every one -- naming the model, the class it got, and the PMA_MODEL_CLASS_OVERRIDES line that corrects it. Warning rather than guessing smaller is deliberate: demoting unknown models to 3b_local would halve the context of every correctly-sized model that merely happens to be named without a size, trading a real regression for a hypothetical one. Still open and blocked on measurement: using the reported window as a CAP, min(ceiling, reported x safety), rather than a source. gemma2-2b needs 4099/8192 ~= 0.5 and one model is not a distribution. Measured live: gemma2-2b 8192, qwen-coder-local 32768, gemma4-local and glm-ocr 131072, gemma4-12B-local 262144, nomic-embed-text 2048. 17 tests, deterministic, no network. Negative-controlled: severing either change fails exactly its own two tests while the fallback, silence and classification-invariance tests keep passing -- which is what shows they test different things rather than all keying on one flag. Gate: 976 -> 993 passed, 1 skipped, 6 deselected, SCRIPT_EXIT=0. The 976 baseline was measured earlier this session, and +17 is exactly the new test file.
`Run-Tests.bat` had not run since the 921 baseline, ~20 commits back. It came
back red on the TypeScript stage with every test green: 29 files, 214 tests, all
passing, and `Errors 1 error`.
ReferenceError: window is not defined
resolveUpdatePriority react-dom-client.development.js:1308
dispatchSetState react-dom-client.development.js:9126
sonner/dist/index.mjs:948
Timeout._onTimeout sonner/dist/index.mjs:573
sonner's `deleteToast` schedules the removal 200ms out for the exit animation
and never clears it on unmount (node_modules/sonner/dist/index.mjs:567-577). If
jsdom is torn down inside that window the callback runs against a dead
environment. Vitest reports it under "Unhandled Errors" and exits non-zero, so
the stage fails while every assertion passed.
Load-dependent, which is why it survived this long: ExplorerPage.test.tsx passes
in isolation and only fails inside the full suite.
The obvious fix is wrong. `toast.dismiss()` routes through the same
`deleteToast` and would schedule another timer -- reading sonner's source rather
than assuming is what caught that.
So the fix drains the window instead, in setup.ts where every test file passes
through, rather than in the one file that happens to trigger it today --
`test-utils.tsx` mounts the <Toaster/> for anything using `renderWithProviders`,
so the next test to raise a toast is covered without knowing about this.
The gate is read BEFORE cleanup(), because cleanup is what removes the sonner
container, and it costs nothing where no Toaster was mounted:
ExplorerPage.test.tsx (Toaster) 248ms -> 1017ms
MessageBubble.test.tsx (no Toaster) 127ms
7 of 214 tests, so ~1.75s rather than the ~53s an unconditional wait would cost.
Same class as the react-query race already documented at the foot of
ExplorerPage.test.tsx, different source.
Not caused by recent work -- latent since the toast confirmation landed, and it
only surfaced because this is the first Run-Tests.bat in ~20 commits. That is an
argument for running it more often than "before merge".
Both gates now green together for the first time since the 921 baseline:
run_ci_checks.bat 993 passed, 1 skipped, 6 deselected SCRIPT_EXIT=0
Run-Tests.bat [PASS] Python / Rust Core / Rust Tauri /
TypeScript / Playwright E2E SCRIPT_EXIT=0
vitest 29 files, 214 tests, no Errors line
Section 13 already says it about the other machine output: "reports land in the repo root -- ruff-report.txt, pytest-report.xml, bandit-report.json, coverage.xml. Do not commit these." The autoresearch journals are the same kind of thing and were committed anyway, which was a judgment call made without flagging the conflict. They are 254 KB across 137 rows at ~1.8 KB each, of which 40% is a `provenance` block repeating the same settings dict, python version and platform string on every row. At the loop's designed cadence -- roughly 100 experiments overnight -- that is ~1.7 MB per thousand rows of machine output in an MIT repo. The argument for keeping them was that they hold the per-build numbers behind the measured claims in CLAUDE.md, so deleting them makes those claims unfalsifiable. That argument survives: the files stay ON DISK, untracked. `--resume` still reads them to skip work already done, and the evidence is still inspectable by whoever has the machine. What changes is that they stop entering the public repo and stop dirtying the tree on every sweep. PROGRAM.md and EDITABLE.md stay tracked -- they are design documents, not output, and the plan that introduced this work called for them by name. One thing worth stating because nothing enforces it: journal rows record the corpus a sweep ran against. Every row so far is a public corpus (corpus_large, corpus_squad, SciFact) and carries aggregate metrics only -- verified: no query text, no answer text, no personal paths, no keys. A future sweep against corpus_college would put the structure of a personal document library into these files, and only this .gitignore would stop it reaching GitHub.
…or it
`_chat_payload` sent `temperature` and `num_predict` and nothing else, so every
request ran at Ollama's default context window. Measured on this machine:
prompt ~3,500 tokens -> ingested 3515 whole
prompt ~4,000 tokens -> ingested 4015 whole
prompt ~4,200 tokens -> ingested 2051 cliff
prompt ~5,000 tokens -> ingested 2051
The default is 4096, and past it Ollama discards roughly HALF the prompt rather
than just the overflow. `compute_context_budget` hands `7b_local` ~8,520 tokens
and `build_context` fills them, so the model was reading about a quarter of the
evidence PMA assembled -- and the class with the LARGEST budget was losing the
most. Verified end to end through the provider at that budget:
before (no num_ctx) prompt_eval_count = 2051
after (num_ctx set) prompt_eval_count = 8022
`_required_num_ctx` sizes the window to the request (chars/4 + max_tokens + 256)
and sets it only when it exceeds the default, so short prompts allocate exactly
what they did before. Sized rather than maxed because num_ctx drives the KV
cache and section 6 targets ~4GB VRAM.
TWO RETRACTIONS, and the second is the one worth keeping.
CLAUDE.md 8.7f recorded `gemma2-2b` truncating at ~4,099 tokens "head first" and
read it as a property of that model. It is not: `gemma4-local` hits the
identical cliff with a 131,072-token declared window. The limit was always the
server default.
That section also recorded "a hypothesis of mine that was wrong: Ollama does not
truncate by default", retracted because gemma4-local declares 131,072. The
hypothesis was RIGHT and the retraction was the error -- the declared window is a
fact about the model and says nothing about the server's runtime default.
Retracting a correct hypothesis on irrelevant evidence closed the question for
two sessions.
The mechanism behind that is worth stating on its own: `num_ctx` at the TOP
LEVEL of the request body is silently ignored. At a 6,000-token prompt,
top-level num_ctx=8192 gives 2051 -- identical to omitting it -- while
options.num_ctx=8192 gives 6014. The earlier session saw "unmoved by
num_ctx=8192", concluded the model was at fault, and that wrong finding then
became a premise in a design review. When a knob appears to do nothing, check it
is being read before concluding anything about what it controls.
Not a measurement confound: every sweep in 8.7f-8.7h delivered 1,162-2,006
tokens, under the cliff, so those results stand. This changes production, where
the budget is larger.
Also in this pass: `family` comes from Ollama's reported `capabilities` instead
of a substring match on the model name. `looks_like_vision_model` matches
"gemma4", and measured live that was wrong in the dangerous direction on two of
six models -- gemma4-local and gemma4-12B-local report completion,tools,thinking
and no vision. Its own docstring names why a false positive is the costly one:
"silently running OCR through a text-only model produces confident hallucinated
page text that lands in the search index". app/ocr/api.py builds the OCR Tier 3
picker from it, so both were being offered for page images they cannot read. That
picker now reads the family the provider already decided rather than re-running
the heuristic on the id -- one derivation instead of two that could disagree. The
heuristic stays as the fallback for an older server and for LM Studio, which
reports nothing equivalent.
Gate: 993 -> 1008 passed, 1 skipped, 6 deselected, SCRIPT_EXIT=0. +8 vision
capability tests, +7 num_ctx tests. Negative-controlled: severing either fix
fails exactly its own two tests while the fallback and short-prompt tests keep
passing.
…pact claim
After the journal purge, a single .gitignore line is the only thing standing
between a personal document library and a public MIT repo. Deleting it fails
nothing and says nothing. Now it turns a test red.
Eighteen tests over eight private paths, two independent assertions each because
they fail differently:
git check-ignore the rule exists AND matches. A pattern can be present and
still not match -- a leading slash, a missing trailing
slash, or a later negation all look right and do nothing.
git ls-files nothing is in the index. This is the half .gitignore cannot
enforce, and it is exactly how the journals reached GitHub:
committed first, ignored afterwards.
Writing it caught a subtlety worth keeping. `git check-ignore` reports
tests/eval/corpus_college as NOT ignored on a clean checkout, because a `foo/`
pattern matches directories only and git cannot tell an absent path is a
directory. corpus_college/notes.txt IS ignored, so the protection is real -- but
a test asserting on the bare directory name would have failed for the wrong
reason forever. It probes a file inside each directory now, since files are what
get committed.
Also fixes a .gitignore comment that still read "research/journal.jsonl is
deliberately NOT ignored", left stale when they were untracked and directly
contradicting the rule 24 lines below it.
RETRACTION, and it is the same error twice in one session.
The previous commit claimed "3.9x more context delivered" for the num_ctx fix,
from prompt_eval_count 2051 -> 8022. That was measured on a SYNTHETIC
8,000-token prompt, not on anything production builds. Verified end to end
afterwards -- real index over corpus_squad, real hybrid retrieval, parent
windows, build_context, generate_answer, with httpx.AsyncClient.post wrapped to
tee the response rather than mock it:
context budget 8,520 tokens
context BUILT 1,833 tokens (7,558 chars)
num_ctx sent 6,462
prompt tokens READ 1,979 exceeds BUILT; the system prompt rides on top
Production does not reach the 4,096 cliff, and forcing a 12,000-token budget
changed nothing because retrieval does not return more material to build with.
The defect was real and the fix is correct, but it is INSURANCE against
configurations that would cross it -- a larger chunk_size, more slots,
accumulated chat history, longer documents -- not a recovery happening today.
Measuring a fix on a prompt the system never produces is the same mistake as
8.7e's instrument measuring the wrong stage. The check that caught it is the one
that exercised the whole path instead of the component.
Side finding from that run, unrelated and unexamined: 7b_local fills 21.5% of
its 8,520-token budget. That is the same under-fill 8.7f chased for the small
class, now visible on the large one.
Gate: 1008 -> 1026 passed, 1 skipped, 6 deselected, SCRIPT_EXIT=0.
Negative-controlled: removing two ignore rules fails exactly the three affected
paths, restoring them passes.
… token `text_preview` is stored as `[EXT: name] ` + body. That prefix reached the dense embedding, FTS5, the cross-encoder and the model - except when `attach_parent_windows` widened a result, because `_chunk_body` strips it. The fallback is per chunk, decided by `len(stitched) > len(text)`, a comparison the prefix itself biases. So ranking scored one string and the user read another, and every measurement in CLAUDE.md 8.7 was taken through that. Stripped below storage instead: `_chunk_body` takes a path and strips exactly, with the length identity demoted to a fallback for callers that have offsets but no path (the graph leg selects `f.path` and no offsets, which is why the path-based form is the one that generalises). Wired at `_build_candidate_results`, `attach_parent_windows` and `_execute_graph_plan`. Storage, FTS, offsets and every `answer_span` label are untouched - trigram FTS matching on a filename is a feature, and the citation panel already renders the path from `src.file_path`, so the in-body tag was a duplicate. `_MIN_CANDIDATE_CHARS` deliberately still measures the stored preview. Moving the 50-char floor onto the stripped body would silently change which chunks are dropped and confound the very measurements this cleans up. Verified against pristine code rather than argued: 3 builds at b33cb61 vs 3 patched, corpus_squad. Chunks indexed identical (1965 in all six), document nDCG/recall/MRR and chunk precision all inside 3 sd. Generation, both models, 3 builds each: gemma2-2b 0.8609 0.8729 0.8753 -> 0.8829 0.8869 0.9139 +0.0220 floor gemma4-local 0.9070 0.9103 0.9267 -> 0.9181 0.9290 0.9342 +0.0111 floor gemma2-2b's per-arm ranges are disjoint (exact permutation p = 0.05), so this is a real gain for the class section 3 exists to serve, not just non-inferiority. The 3-sigma-of-mean test is marginally under at +0.0249 vs 0.025, so: supported, not overwhelming.
…ed the noise form
`reembed.py` embedded the raw summary display string while `service.py` embedded
`summary_embedding_text(...)`. So every user who pressed "Rebuild embeddings" in
Diagnostics silently replaced every summary vector with the form CLAUDE.md 8.4a
measured at recall 0.819 against 0.972. This module's own docstring warns about
exactly that - "a second copy of a rebuild loop is exactly the kind of drift that
produces a subtly wrong vector store" - and it drifted anyway, because nothing
asserted on the TEXT either loop embeds, only on the calls they make.
Both loops now route through `chunk_embedding_text`, and the summary leg matches
ingest. `settings.embed_chunk_prefix` (default True, behaviour unchanged) selects
what the embedder sees, and `_compose_signature` appends `:noprefix` only when the
flag is off - so no existing install is shown a mismatch banner for a setting it
never touched, while a real convention change now reaches
`check_model_signature` instead of leaving the signature byte-identical and the
index rotting silently.
The negative control found a second instance of the same blind spot: reverting
the INGEST embed loop to raw `text_preview` passed the entire 1040-test suite.
Nothing anywhere pinned the text that path hands the embedder. Both are pinned
now, and both controls fail as they should.
The knob was measured, and the hypothesis behind it is REFUTED. A ~20 min
delivery change-detector (3 builds per arm) replaced a planned 5.2h generation
sweep, which is sound here because after the previous commit both arms deliver
prefix-free text - contexts are byte-identical unless ranking moved:
delivered coverage 0.9729 -> 0.9626 (-0.0102)
document nDCG 0.9877 -> 0.9807 (-0.0070)
retrieval-stage cov 0.9815 -> 0.9682 (-0.0133)
per-query changes rebuild noise 1.3/100 vs cross-arm 4.4/100
Four independent metrics degrading together. The prefix is not inert noise, it is
mildly helpful - and the mechanism was inside the precedent this change cited.
`summary_embedding_text` keeps the file stem ("a name like colour_pipeline_notes
is genuinely topical") and drops only the bracket tag. `[MD: notes.md] ` carries
both, and stripping it removes the topical half too. The untested arm is the one
the summary layer already performs: drop `[EXT: ]`, keep the stem. Not built.
Default stays True, now on evidence rather than caution.
…rately not moved
The comment used to read "held at ~10% of chunk_size, which is what the sweep
above used" - it was a passenger on the chunk_size sweep and had never been
measured on its own. It has been now: {0, 51, 102, 205} x 3 builds on
corpus_squad, delivery stage, reranker on.
Overlap changes NO retrieval metric across a 4x range - nDCG, recall and
retrieval-stage coverage are flat. What it changes is chunk COUNT: +11.4% at 102
over 0, +27% at 205, paid in embedding compute, SQLite/LanceDB rows and index
size on every ingest. It is a cost knob, not a quality knob.
Quality at 0 vs 102 is NOT DECIDABLE here: +0.0098 against corpus_squad's ~0.017
delivery threshold. That was predicted before the run, and it is why the subset
analysis exists.
The straddle stratification the design review asked for turned out to be
unrunnable, and measuring that is what produced the finding. At overlap=0, 0 of
100 SQuAD answers cross a chunk boundary - the median answer is 20 chars against
a 1024-char chunk whose split now snaps to a sentence end. Counting boundary
CROSSINGS finds nothing; measuring DISTANCE to the boundary finds the population.
On the 19 queries within 102 chars of an edge, more overlap measured
monotonically WORSE (0.9415 / 0.9298 / 0.9298 / 0.8947) while the 53 unreachable
queries stayed flat - the internal control. Mechanism, named before the run:
adjacent chunks share at most 205/1024 = 0.20 of their span against
`_deduplicate_redundant`'s 0.7 threshold, so overlapping neighbours never dedup
and spend two context slots delivering one passage of new text.
`tests/eval/metrics.boundary_margin` ships that stratifier so the 13/19/47 counts
are reproducible from code rather than from a write-up.
Not lowered, and the reason is a gap rather than a preference. Every number comes
from a fixture whose answers are 1-5 words, and overlap's actual job is protecting
LONG answers - the only fixture with those (corpus_large, 4 of 8 straddling) has
delivery sd 0.069-0.137 and cannot resolve it. "Cheaper" is established; "no
worse" is established only for short answers. Lowering a shipped default on a
measurement blind to the case the knob serves is the 8.4a failure mode. The 5-10%
saving stays available the moment a powered long-answer fixture exists.
The planned generation arm for this knob (~7h) was cancelled rather than
deferred: the effect was already known to sit below the detection threshold, so
more generation could not make the conclusion more true.
No default changed. Comment, stratifier and its tests only.
The existing rule covered research/journal*.jsonl. The sweep now also produces per-build result JSONs (d1_screen/, d2_delivery/, d4_leak/) and a running notes file, all generated reports rather than source. research/PROGRAM.md and EDITABLE.md stay tracked - a tracked file is unaffected by an ignore rule.
… rewritten
P1 StreamChunker was O(n^2) in fragment size. Its secondary fix is
json_extractor, which yielded a whole file as one fragment - the same shape,
and a violation of the boundedness invariant that peak-above-idle must be a
function of the tunables alone and of nothing about the corpus.
P2 both visualizer queries selected the whole files table with no LIMIT. The
binary stream materialises 32 bytes per node, but the meta sidecar builds a
dict per file plus one per ancestor folder, which is the expensive half and
scales with the corpus rather than with any tunable. visualizer_max_nodes caps
both, and the two endpoints must keep the same ORDER BY and the same limit:
the sidecar joins to the buffer by hash_tree_path(path), so if they disagree
about which rows they returned the join silently drops nodes. The ceiling is
unmeasured - chosen to keep the response bounded, not from a profile.
P3 attach_parent_windows issued a sequential N+1. The per-file ranges are now
OR-ed into a single round trip rather than collapsed to file_id IN (...), so
each file still fetches only the siblings its own window needs - the same row
set the per-file queries returned.
PERFORMANCE_BOTTLENECKS_AND_ROADMAP.md is rewritten against measured evidence:
it gains a retractions section so the old numbers are not re-cited, and a
"verified-good, do not optimize these" section. Three correctness/ops items
that were section 6 are not performance work and move to
04_AUDITS_AND_ISSUES.md; nothing was dropped.
Both gates green on this tree:
run_ci_checks.bat SCRIPT_EXIT=0 - ruff clean, mypy, 1051 passed / 1 skipped
/ 6 deselected, bandit, eslint, cargo clippy+deny+audit on both crates
Run-Tests.bat SCRIPT_EXIT=0 - rust_core 36, src-tauri 10, Miri 30 passed,
vitest 229 passed with no Errors line, Playwright 4 passed
…test The AsyncMock standing in for asyncio.wait_for never awaited the coroutine it was handed. A stub that never awaits leaks it, and the RuntimeWarning then surfaces under whichever later test happens to trigger the next gc pass - which is how it came to be reported against test_degraded_flag_survives_reordering_and_truncation, a test that had nothing to do with it. The replacement closes the awaitable and raises TimeoutError itself. Also pins the deadline. Stubbing wait_for means nothing else in the suite bounds it, so widening the reranker timeout to 500s would have passed here. It is the only bound that enforces, so the stub now asserts it was handed 5.0.
…nnot see it Research_Paper_1 section 4.4 claims optimal RRF k is determined by top-1 trustworthiness and rank-list correlation between the retrievers, and predicts a monotone relationship between those and optimal k. Section 6.5 is the check. Nothing in app/ implements any of it: config.py:588 is rrf_k = 60 applied unconditionally, and every quantitative claim in the paper is a [PH] placeholder, so this does not reproduce a result - it produces the first one. k enters only the fusion denominator, so given the per-leg ranked lists every value of k is re-scorable offline with no re-indexing and no LLM. The whole grid costs one index build and 8.3's three-builds discipline costs three, not three times the grid. `_compute_rrf_scores` is called rather than reimplemented; 9a24443 is what reimplementing a formula for measurement costs. First result, corpus_squad, 3 builds, 300 rows - a null and a void: k 5 10 20 30 60* 100 150 nDCG .9755 .9766 .9764 .9775 .9764 .9707 .9696 rec 1.000 1.000 1.000 1.000 .990 .990 .990 Whole-curve range 0.0079 against a 0.025 detection threshold, and recall@10 is 1.0000 - the corpus is saturated and cannot show a fusion effect at any k. k=30 beats the shipped 60 by 0.0011, which is 4% of threshold. The mechanism test is VOID rather than negative: optimal_k landed on the grid's own minimum for 293 of 300 rows, so the optimum was never bracketed and optimal-k is censored. A Spearman against a 98%-constant variable is not weak evidence, it is no evidence. The grid now brackets down to k=0 and the script prints explicit edge-censoring, near-constant and ceiling warnings so this cannot be read as a result next time. What did come out solid: mean FTS/semantic overlap@10 is 2.73 of 10, 34 rows share nothing at all, and Kendall tau is undefined on 89 of 300 (30%). The two retrievers largely return different documents. Appendix A's B10 maps tau to k, and tau is uncomputable for a third of queries here. Also corrects an arithmetic claim made earlier in this workstream: both legs fetch 2 * recall_k (retrieval.py:217 and :241), so the lists are 100 long, not 50 - measured fts_len = sem_len = 100, summary_len = 75 on every row. That puts rho(60) at 2.62x for FTS/semantic against 1.23x for the summary leg, a 2.1x discrimination gap rather than the ~1.5x first stated. Verified: ruff check . and ruff format --check . clean repo-wide; mypy . and mypy --platform linux . clean on 96 files; --self-check green and negative-controlled (breaking tie-averaging fails it, exit 1).
…omain balancing
Every small fixture here is saturated and cannot answer a fusion question.
Measured, all three: corpus_squad nDCG 0.976 / recall 1.0000 with the whole
k-curve spanning 0.0079 against a 0.025 threshold; tests/eval/corpus nDCG
0.9973 / recall 1.0000, range 0.0592 against its 0.06. Only SciFact's 5183
documents leave headroom. materialize_corpus.py already recorded the same
diagnosis for corpus_large in 2026-09: "the tuning headroom is gone".
So compose. --corpus/--queries are repeatable and several corpora are copied
into one root, which works because every fixture already namespaces
relevant_files by a domain directory and the five in use are disjoint - docs,
notes, research, squad, scifact. Labels transfer untouched. Composed into a
temp dir, not committed: it is ~9 MB and would put a second copy of SciFact in
the repo. A domain appearing in two corpora is refused rather than silently
overwritten (corpus and corpus_large both claim docs/ and research/).
Composing de-saturates: squad alone is recall 1.0000 at every k, and the same
100 queries against 5255 competing documents score nDCG 0.95.
Also measures the domain-balanced ranking alongside the raw one.
_allocate_by_domain is a no-op on a single-domain corpus, which is why SciFact
and SQuAD did not need it, but its docstring names this exact case - "on a
heterogeneous corpus a lexically dense domain takes every slot and the others
are invisible". With scifact at 5183 documents against code/ at 8, reporting
only the unbalanced ranking would be a number production never computes.
Result, 3 builds x 412 queries = 1236 rows, balanced nDCG@10:
k 0 1 5 10 30 60* 150
.7732 .7743 .7746 .7657 .7530 .7490 .7340
per build: best k=1/5/3, delta +0.0251 / +0.0311 / +0.0238
mean +0.0267, sd 0.0039, floor +0.0238, ranges disjoint
Marginal, and stated as such: the ranges do not overlap and all three builds
agree in direction, but the floor sits just under the 0.025 threshold. On
SciFact alone the same comparison is floor +0.0368, clearly over.
Two things the per-source split shows. Optimal k is register-dependent -
scifact is monotone with best k=0 (.7061 -> .6514), while squad peaks at k=5
and k=60 actually beats k=0 there. That is the shape Research_Paper_1 predicts,
though squad's own range is 0.018 and under threshold, so it is a hint, not a
finding. And multi-relevant queries do NOT want a larger k: the >=2-relevant
subset prefers k=0 (n=93 rows), which refutes for the second time on
independent data the hypothesis that democratic averaging helps when several
documents are jointly relevant.
The mechanism itself stays untestable: optimal-k is pinned at RRF's floor for
1066 of 1236 rows, so the Spearman is censored, not negative.
Verified: ruff check . and ruff format --check . clean repo-wide; mypy . and
mypy --platform linux . clean; --self-check green and negative-controlled.
"all four legs (FTS, embedding, semantic, summary)" counted the query-embed step as a retrieval leg. hybrid_retrieve launches exactly three - _fts_search (retrieval.py:190), _semantic_search_with_emb (:236) and _summary_search_with_emb (:318) - and _compute_rrf_scores fuses those three. Embedding is what feeds the latter two, not a list of its own. Verified this session while measuring rrf_k; CLAUDE.md section 5 already said three.
… a no-op The three legs do not hand RRF lists of the same length, and the denominator's discrimination depends on length. Both chunk legs fetch 2 * recall_k (retrieval.py:217 and :241), so at recall_k=50 each is 100 long - measured fts_len = sem_len = 100 on 900 rows. The summary leg instead ranks *files* via the caller's k and gives every chunk of a file its file's rank: 75 entries carrying at most 15 distinct ranks, also measured. First-to-last reward ratio (k + n) / (k + 1): k=60 FTS/semantic 2.62x summary 1.23x k=5 FTS/semantic 17.5x summary 3.33x config.py:570 already recorded the k=60 case for a 5-element list - "rrf_k = 60 barely differentiates" - and the fix applied then was to shrink the weight to 0.05 rather than correct the denominator. These settings make the other half of that fix测 available. They are UNMEASURED as per-leg values; CLAUDE.md 8.3a carries the global-k measurement, and nothing yet establishes that splitting them helps. rrf_k_fts / rrf_k_semantic / rrf_k_summary are `int | None`, default None, falling back to rrf_k. So the shipped ranking is bit-identical, PMA_RRF_K still moves all three at once, and the env overrides come free from BaseSettings' PMA_ prefix - verified, because model_config sets extra="ignore" and a typo'd knob would otherwise be silently swallowed: PMA_RRF_K_SUMMARY=7 python -c "...print(Settings().rrf_k_summary)" -> 7 No FUSION_VERSION bump, deliberately. An earlier plan called it mandatory and that was wrong for a default-None design: defaults change no ranked score, and settings are read once per process, so a per-leg k cannot change under a live in-process cache. Bumping would invalidate every user's retrieval cache for no behaviour change. The persistent LanceDB query_cache does not key on fusion at all (retrieval.py:990) and should be cleared after any fusion change, exactly as for rrf_k itself. Also documents the three-way `k` collision at the fusion signature. The denominator, this function's positional `k` (a truncation window receiving recall_k, used only at the final slice), and hybrid_retrieve's caller `k` (retrieval_top_k, sizing the summary fetch) are three different quantities. 8.3 records that importing one into another's scale has been done twice. Two tests. One pins the no-op: unset and explicitly-equal-to-rrf_k must both reproduce the baseline scores, which is what licenses shipping without the version bump. The other asserts each leg reads its own denominator, as a ratio computed from the settings rather than a literal - a hard-coded score would pass against an implementation that ignored the per-leg settings entirely, which is the exact defect it exists to catch. Negative control run both directions: collapsing the three reads back to one makes the denominator test fail (obtained 0.0164 = 1/61, required 0.5 = 1/(k_fts+1)); restoring makes it pass. Gate green: SCRIPT_EXIT=0, 1053 passed / 1 skipped / 6 deselected, up from the 1051 baseline measured this session by exactly the two tests added.
test_pick_folder_mocked and test_pick_folder_dialog_failure patched asyncio.get_running_loop, which is global: anyio's TaskGroup then created its tasks on the mock and waited on them forever, hanging the suite. Both now intercept the tkinter import through sys.modules, as the other pick_folder tests already do.
…into dist Build-Exe.bat: - --add-data "app;app" copied the whole tree, including the 1.2 GB Cargo target under app/scanner/rust_core. Only app/assets and schema.sql are read from disk at runtime; the worker and protocol keep their own lines. - demo_data was never bundled, so /api/demo/seed returned 400 "demo_data folder not found" in the exe. - After zipping the sidecar it now runs the Tauri build with the bundle config and copies pma-desktop.exe (with python/PMA-sidecar.zip beside it) and the MSI into dist. Skipped under CI, which builds the MSI as its own step. ESLint ignores src-tauri/target: a release build embeds the SPA there as compressed .js assets that fail to parse.
…providers Two causes of an empty or unusable model picker: - ValidationCache dropped a verdict at its 60 s TTL, so the first /api/providers call after an idle minute returned last_validation null for every provider, and the picker cached that as "0 models available". list_providers now serves the expired verdict (get(..., allow_stale=True)) while it re-validates in the background; validate() still sees only fresh verdicts. - The picker listed models from providers whose validation failed. NVIDIA NIM serves /v1/models without auth, so a bad key put 80 unusable models ahead of the local ones and past the 30-row cap. It now lists a provider only when its validation passed or it is serving a cached heap.
The ask bar is a borderless line at the top edge, and the empty answer pane said "Ask, and only the frames that hold the answer come up." without saying where. The input now takes focus on mount and the idle text reads "Type a question in the Q. bar above and press Enter." The placeholder is unchanged; the E2E and unit tests select on it.
The smallest text in the app (captions, kickers, badges, receipt lines) sat at 10 and 10.5px. Everything below 11px is now 11px; no other sizes change.
…t send - The ask bar moves below the conversation, where a chat composer is expected. Its focus ring is inset so the full-height input no longer clips it. - The Receipt column is hidden by default and toggled from the answer header (aria-pressed, remembered in localStorage). The narrow layout keeps the frames column width when the receipt toggles. - ModelPicker's custom-model entry was a <form> nested inside the ask form; its submit bubbled up and sent the typed question. It is a div with an Enter handler now. Refresh icon is a glyph, not an emoji. - Recent questions render as a list of buttons instead of a listbox whose option semantics the dropdown did not implement. - SearchPage and safelight.css also carry their share of the 11px text change.
Validate All called POST /providers/{id}/self_test, whose reply has no
`models`, so a passing provider hit `validationResult.models.length` and
took the Providers page down. self_test also ran a real chat completion
against every provider, cloud ones included, with no consent check.
Validate All now calls the same validate endpoint as Test & Validate,
only for configured or local providers, and skips cloud/aggregator
providers until cloud_privacy_consent is set. The self_test endpoint and
its client had no other caller and are removed.
…chmark profile_ingest_throughput.py times each ingest stage as the union of its busy intervals (the pipeline is concurrent), cold and no-change re-index; it showed embed_texts busy ~99% of the pipeline. --emulate-4core reproduces M4c's affinity-0xFF / intra-4 emulation. bench_embed.py compares EmbeddingService session configs inside one process, arms alternating ABBA so throttling drift cancels in paired ratios; --ingest N runs each arm as a real ingest of a fixed seeded subset. Findings recorded in MEASURE_M8.md / MEASURE_M9.md (both closed, no app change).
Ingest is embedding-bound (embed_texts busy ~99% of the pipeline). GPU mode sends document batches to a DirectML worker running the pinned fp16 model in its own uv venv (onnxruntime-directml can't share an interpreter with CPU onnxruntime). Off by default, never auto-selected, Windows only. - app/embeddings/gpu/: worker.py (stdlib at import, run in place with -I), client.py (blocking JSON-lines pipe, lazy spawn, 60 s idle retire; any error/crash/timeout redoes that batch on CPU and degrades for the process), registry.py (exact --no-deps pins, temp-dir install + promote, smoke test vs CPU INT8 at cos >= 0.98, device persisted in settings.json), api.py. - Queries and fallback stay on the shipped INT8 CPU session. Mixed fp16 docs / INT8 queries: SciFact dense nDCG@10 0.7038 vs 0.7046 all-INT8. An fp32 query session matched the docs exactly but cost ~90 MB idle (338 vs 248 MB, over the 250 MB ceiling), so it was dropped. - GPU mode appends ":gpu-fp16" to the index signature, so switching either way raises the existing re-embed banner. A switch applies on restart. - models.lock.json pins onnx/model_fp16.onnx; Settings gets an install / toggle section; Build-Exe.bat ships the worker file. Measured on an RTX 5060 laptop (not the 4 GB target class): 500-file ingest 10.0-10.3 s vs 46.9-47.5 s; idle 245.6-247.8 MB; serving and search latency unchanged; worker ~380 MB RAM and ~1 GB VRAM while ingesting.
…length Picking a folder always answered "I couldn't find any relevant documents". The picker sends the indexed root path (the keys of /api/files/tree), but retrieval compared it with folder_tag, which holds only the basename. _folder_scope splits the value into (tag, root): the tag drives the FTS WHERE and LanceDB prefilter, and _filter_retrieved_results also requires file_path under the root, which separates like-named folders. folder_tag max_length 100 -> 4096 so a long root is not a 422. Under a folder or file-type filter, context blocks that described the whole index no longer reach the model: the fast paths, global file_stats, the agentic branch's all-folder profiles text, and the latest/largest file lists (now scoped by root in SQL; dropped under a file_type filter). Stream near misses obey the same filter. answer_length (short | detailed) on QueryRequest, a length select in the Ask filter bar, appended as a LENGTH INSTRUCTION in _build_prompt. Both answer caches key on it. rag_system.txt and the inline fallback: answer from the files first with citations, say what they miss, then add general knowledge under "Beyond your files (general knowledge)", uncited. The "I don't have enough information" sentence is kept for the nothing-relevant case because eval_chunking.py detects abstention by it. Prompt effect is unmeasured; answers already in the semantic cache keep the old wording. Negative-controlled: removing the root split, the root guard, the insight scope, or the length in the cache key each fails its test.
Bumps locked versions only; pyproject.toml and package.json are unchanged. Clears 140 of 148 open Dependabot alerts (pypdf, pillow, nltk, react-router, starlette, cryptography, urllib3, anyio, vite and others). Still open: tinypool and vitest (test tooling, needs vitest 4), nltk GHSA-8mgp-746c-j5xp (no upstream fix, affected APIs unused), and glib (Linux-only via Tauri GTK, already ignored in audit.toml).
…ariants eval_chunking.py: --gen-models accepts provider:tag entries so a local and a cloud model answer in one invocation on the same index build; --gen-context-class feeds every model one class's context to isolate the model from the budget; --gen-prompt-suffix runs each model a second time with fixed text appended (results keyed tag|label), recorded in provenance. per_query now keeps answer text, reference and context_coverage for judging. Harness only; no production prompt or code changes.
…ntrol
--gen-pipeline LABEL=STEP[,STEP...] runs each query through chained prompt
templates ({context}, {query}, {shipped}, {prev}). @baseline reuses the
shipped arm's answer without a second call; a nothink: prefix sends that
step with think=false through OllamaProvider._chat_payload. per_query
records every pass's text and seconds; answer is the final pass.
Used for the pre-registered refine / quote-first test (both rejected).
Unreleased now counts 95 commits and lists the 14 after 87f7e66; both notes record the 2026-11-02 review findings that v0.0.73 addresses.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars.
|
There was a problem hiding this comment.
CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.
capture-screens.mjs took its output dir and check-utilities.mjs its stylesheet path from process.argv[2]. No caller passes either (ci.yml:89, run_ci_checks.bat:104, frontend/README.md:11), and an arbitrary output dir could write screenshots into tracked paths. Code scanning alerts 141, 142, 143.
test_l4_ingestion split paths on a backslash, which only works on Windows. test_routing_set rebuilt the frozen set from tests/eval/queries_squad.json, which is gitignored and absent on a fresh checkout; it now skips when the sources are not materialized.
Model names, the saved fallback chain and Ollama's reported window and done_reason reached log messages verbatim, so a newline in any of them could forge a log entry. app/utils/log_safe.py escapes CR and LF; the nine CodeQL log-injection sites in ollama.py and llm_client.py go through it, plus done_reason, which comes straight from Ollama's response.
….yaml The src-tauri tests built their scratch paths from std::env::temp_dir(), which reads TMP/TEMP at run time, so CodeQL traced that environment value into every path in the tests and through them into resolve_prod_sidecar_paths. A compile-time root under the crate's gitignored target dir removes the source. Also removes the CodeRabbit config at the owner's request.
Binitpyro
added a commit
that referenced
this pull request
Nov 2, 2026
README: the MSI build is scripts/Build-Exe.bat (a bare `npm run tauri build` ships no backend); first run downloads the embedder (~460 MB: every ONNX build in the pinned revision is fetched); the reranker is never downloaded; `uv run maturin develop`; how to run from source; Rust pinned at 1.97.1; opt-in GPU embedding. __main__.py docstring: drop the nonexistent --mode flag and the build line that ships no backend. 05/04: PR #12 merged as 2f26d85, first Linux CI green, the review commits, 81 CodeQL alerts open on main (pre-existing, untriaged), the download over-fetch. 06: the Ask row after db96310.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First merge of
UI/UXsince PR #11 (2026-08-24): 95 commits, 2026-08-29 through 2026-10-28. Full notes:PMA Obsidian/05_CHANGELOGS.md, "Unreleased".This is also these commits' first CI run:
ci.ymltriggers onmainonly.chunk_size1024, Ollamanum_ctxsized to the request.Local gates on 2d2526b:
run_ci_checks.batandRun-Tests.batgreen (pytest 1426 passed). Vitest ran 5.0.5 locally; this PR keeps the committed 3.2.4.Known limits: existing indexes need a forced re-index, and the MSI is not built or tested. Packaging, first-run and privacy-claim fixes follow in a separate PR for v0.0.73.