Skip to content

Phases 1 & 5: mapping parser + pluggable corpus (incl. Ollama) - #2

Open
christiaanjs wants to merge 20 commits into
mainfrom
build/phase-1-5-corpus
Open

Phases 1 & 5: mapping parser + pluggable corpus (incl. Ollama)#2
christiaanjs wants to merge 20 commits into
mainfrom
build/phase-1-5-corpus

Conversation

@christiaanjs

@christiaanjs christiaanjs commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Implements Phase 1 (mapping parser) and Phase 5 (pluggable corpus, incl. a local Ollama source) from PLAN.md, then wires both into the two frontends. The keymap is now parsed from mappings/, and practice content can come from a static bank, a text file, a codebase, or a local Ollama model — all behind the existing Mapping/Corpus seam interfaces, so the drill and frontends are unchanged.

Phase 1 — Mapping parser

  • core.ParseMapping(fsys fs.FS, dir string) (Mapping, error) (core/mapping_parse.go) reads mappings/*.json, extracts the alt-layer (space-held) mirror pairs, and builds a mapping. It takes an fs.FS (never os) so core stays wasm-safe; the JSON is embedded via mappings/mappings.go (embed.FS).
  • Unified the static and parsed mappings under one shared mirrorTable type (core/mirror_table.go) implementing Hint/Supported/Diagnose/Reference, so parsed and static behave identically by construction. staticMapping is refactored to build the same type; NewStaticMapping is exported as the fallback.
  • Errors on unknown key codes and on conflicting duplicate mappings across files.
  • core/mapping_parse_test.go: oracle tests asserting the parsed mapping equals the static one across all interface methods, plus fstest.MapFS edge cases.

Phase 5 — Corpus subsystem

New corpus/ package (all I/O lives here, outside the pure core), each constructor returning a Source that implements core.Corpus:

  • FromText / FromReader / FromFile — plaintext.
  • FromCodebase — tokenizes source identifiers (camel/identifier splitting) into practice words/sentences.
  • FromOllama — queries a local Ollama server via the official github.com/ollama/ollama/api client (non-streaming); errors on too-few results so the caller falls back to static.
  • Selection logic (deterministic-by-seed) is ported from staticCorpus. NewStaticCorpus is exported as the fallback.

Frontend wiring

  • TUI (cmd/tui): new flags -corpus (static|file|code|ollama), -corpus-path, -ollama-model, -ollama-host. Parses the embedded mapping and builds the selected corpus, each falling back to static with a stderr warning printed before Bubble Tea takes the screen.
  • Web (cmd/web): parses the embedded mapping (static fallback); corpus stays static — a browser sandbox can reach neither the filesystem nor a local Ollama server.

Streaming corpus (replaces the batch Ollama path)

The first cut of -corpus=ollama blocked startup fetching 200 words + 40 sentences, then never generated again. It is now streaming and unbounded:

  • corpus.Producer — a vendor-neutral seam. Produce(ctx, kind, n, emit) emits each item the moment its line completes, not batched at the end. This is exactly the seam Phase 7's Anthropic provider plugs into, so that phase no longer needs a new abstraction.
  • corpus.Stream — a core.Corpus that never blocks. It serves the static fallback while cold and generated text once warm, so the app starts instantly and upgrades in place. Buffers are rings (2000 words / 500 sentences, oldest evicted): content is unbounded, memory is not. The producer goroutine sleeps unless woken by demand or a backoff timer, so an idle trainer generates nothing.
  • Soft failure — a dead server, or a model that repeats itself, backs off and retries while the drill keeps running on the fallback. Ollama being down never breaks the trainer.

Visible, not silent

Content now arrives after startup, so the core surfaces it: State.Corpus (CorpusStatus/CorpusPhase), populated via an optional StatusReporter interface so fixed banks are unaffected. The TUI renders it and ticks to re-render while it changes (Bubble Tea only redraws on messages, so background arrivals would otherwise be invisible until the next keystroke):

corpus: ollama ⠹ generating (qwen3:8b) — drilling on static text meanwhile
corpus: ollama ● streaming (qwen3:8b) — 240 words, 60 sentences so far
corpus: ollama failed — drilling on static text; retrying. (connection refused)

The Corpus contract is now explicit about the rule that makes this possible: Word/Sentence run on the frontend event loop and must never block.

Bugs found and fixed while reviewing the producer loop

Both broke the "idle ⇒ no generation" property, and both now have regression tests (the second was verified to fail before the fix):

  1. A pending demand signal could short-circuit the backoff window — so with Ollama down, every drill item re-hammered the dead server, ignoring the 2s→30s backoff.
  2. A round that succeeded but added nothing new reset the backoff. Since the ring dedups, a model repeating itself can never reach the low-water mark — so every drill item re-signalled and the producer regenerated forever in a tight loop at full GPU.

Thinking models

Reasoning models (qwen3, deepseek-r1) emit their chain-of-thought to GenerateResponse.Thinking, not .Response — so the corpus sat at warming with 0 words for 2+ minutes while the GPU churned. Thinking is now disabled, conditionally: the capability is resolved once from the server, because sending think to a model that lacks it is rejected.

Live verification

Not just unit-tested — exercised against a real qwen3:8b:

  • Before: timed out at 2 minutes, 0 words.
  • After: reached streaming in ~1s; 42 generated words + 2 generated sentences within 6.5s; TUI startup with -corpus=ollama returns in 605ms instead of blocking.

OLLAMA_LIVE_TEST=1 OLLAMA_TEST_MODEL=qwen3:8b go test ./corpus -run TestFromOllamaLive -v runs it; it is skipped by default and in CI. Full suite passes under go test -race ./....

Docs

PLAN.md, README.md, and CLAUDE.md updated to mark phases 1 & 5 done and document the corpus flags / Ollama usage.

Verification

gofmt, go vet ./..., go test ./..., native go build, and GOOS=js GOARCH=wasm go build -o web/app.wasm ./cmd/web all pass; CI green on the branch.

🤖 Generated with Claude Code

christiaanjs and others added 13 commits July 10, 2026 10:46
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pure core.App (Dispatch/Snapshot) driving the prototype's four modes,
with Mapping and Corpus interfaces as slot-in seams. Hardcoded
staticMapping (from MIRROR + mappings/*.json) and staticCorpus (from the
prototype bank) sit behind those interfaces so the future Karabiner
parser and corpus extractor drop in without touching drill logic.
Compiles native and GOOS=js/wasm; table-driven tests for both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Model wraps *core.App; Update maps tea.KeyMsg to core.Event and calls
Dispatch; View renders State with Lip Gloss across all four modes
(mirror, nav, scratch, reference). Mode switch on F1-F4 (bubbletea
v1.3 has no ctrl+digit key type); scratch buffer kept frontend-side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cmd/web is a syscall/js entrypoint exposing snapshot()/dispatch(eventJSON)
that marshal core.State to JSON per the architecture's JS-boundary pattern
(all logic stays in core). web/ is a thin dependency-free vanilla renderer
(no framework, no CDN, no build step) porting the prototype UI across all
four modes. Build artifacts (app.wasm, wasm_exec.js) are gitignored and
produced by 'make build-web'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
syscall/js only exists under GOOS=js, so 'go vet ./...' and 'go build
./...' on the native toolchain fail to compile cmd/web. The //go:build
js && wasm tag excludes it from native builds while the wasm target
still includes it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reflect that phases 0/2/3/4 are complete: core + TUI + web frontends
work behind the Mapping/Corpus seams, with the parser (Phase 1) deferred
as the recommended next step. Add build/run instructions and the two
wasm build gotchas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…llama dep

- mappings package embeds qwerty-mirror/*.json as an io/fs.FS so the
  future parser works on native and wasm without disk access.
- Export core.NewStaticMapping/NewStaticCorpus as fallbacks.
- Add github.com/ollama/ollama/api for the local-model corpus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds core.ParseMapping(fs.FS, dir) which reads the alt-layer mirror
manipulators from mappings/*.json and builds a Mapping. Refactors the
shared Hint/Supported/Diagnose/Reference logic into a mirrorTable used by
both the parsed mapping and NewStaticMapping, so they are identical by
construction (verified by oracle tests against every rune, Reference, and
Diagnose). LeftHandKeys is documented keyboard geometry (also for the
future simulator). Errors loud on unknown or conflicting key_codes.
Stays pure: io/fs + encoding/json only, compiles native and wasm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New corpus package implementing core.Corpus from multiple sources:
FromText/FromReader/FromFile, FromCodebase (splits identifiers on
camelCase/underscore boundaries), and FromOllama — a local-model source
using the official github.com/ollama/ollama/api client (non-streaming
Generate) that errors out so callers can fall back to the static corpus.
LLM-output parsing is in pure, tested helpers; lives outside core so it
can use os/net/http.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cmd/tui: add -corpus (static|file|code|ollama), -corpus-path,
-ollama-model, -ollama-host flags. Parse the embedded default mapping
with a static fallback, build the selected corpus with a static
fallback, and inject both into core.New. Warnings print to stderr
before Bubble Tea takes the screen. newModel now takes *core.App.

cmd/web: parse the embedded mapping with a static fallback; corpus
stays static (a browser sandbox cannot reach a local Ollama server).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Update PLAN.md, README.md, and CLAUDE.md to reflect the landed mapping
parser (Phase 1) and pluggable corpus subsystem (Phase 5), including
the TUI's -corpus/-corpus-path/-ollama-* flags and the static-fallback
behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@christiaanjs christiaanjs changed the title Build/phase 1 5 corpus Phases 1 & 5: mapping parser + pluggable corpus (incl. Ollama) Jul 10, 2026
christiaanjs and others added 7 commits July 11, 2026 15:26
The Ollama corpus used to block startup requesting 200 words + 40
sentences, then never generate again. Now it streams: the app starts
instantly, content arrives from the model as it is produced, and the
buffer refills on demand so it never runs out.

corpus: add a provider-agnostic Producer seam (Produce emits each item
as its line completes, not batched at the end) and a Stream that wraps
it. Stream implements core.Corpus and never blocks: it serves a static
fallback while cold and generated text once warm. Buffers are rings
(2000 words / 500 sentences, oldest evicted), so content is unbounded
while memory is not. The producer goroutine sleeps unless demand or a
backoff timer wakes it -- an idle trainer generates nothing.

ollama: rewrite as a streaming Producer. Disable thinking for reasoning
models (resolved once via the server's capabilities): qwen3 spends
minutes emitting a reasoning trace to Thinking rather than Response, so
the corpus would sit at "warming" with 0 words while the GPU churned.
Verified live against qwen3:8b -- now streaming within ~1s.

core: surface where practice text comes from, and whether more is still
arriving, as State.Corpus (CorpusStatus/CorpusPhase). Corpora opt in via
an optional StatusReporter interface, so fixed banks are unaffected. The
Corpus contract now states the rule that makes this safe: Word/Sentence
run on the frontend event loop and must never block.

tui: render the corpus status (spinner while warming, live counts while
streaming, reason on failure) and tick to re-render while it changes --
Bubble Tea only redraws on messages, so background arrivals would
otherwise be invisible. -corpus=ollama no longer has a startup timeout.

Two bugs found reviewing the producer loop, both of which broke the
"idle => no generation" property, each now covered by a regression test:
a pending demand signal could short-circuit the backoff window (so a
dead server was re-hammered once per drill item), and a round that
succeeded but added nothing new reset the backoff -- meaning a model
repeating itself could never reach the low-water mark, re-signalling
forever and regenerating in a tight loop at full GPU.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Update README/PLAN/CLAUDE for the switch from batch to streaming
generation: what the status line shows, the demand-driven/bounded-memory
behavior, the thinking-model caveat, and how to run the live test.

Record the rule that makes the whole design work -- Word/Sentence run on
the frontend event loop and must never block -- plus the two invariants
that keep an idle trainer from generating forever, since both are easy to
reintroduce.

Also note that Phase 7 (LLM generation) no longer needs a new seam: the
vendor-neutral corpus.Producer is exactly it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The browser build now supports the same streaming corpus as the TUI,
selected by query string (?corpus=ollama&model=…) — the page's
equivalent of the TUI's flags, injected the same way at construction.
It starts instantly on static text and renders the same status line.

The old "a browser sandbox cannot reach a local Ollama server" claim was
simply wrong. Three findings, all documented in CLAUDE.md:

1. CORS is a non-issue: Ollama's default policy allows localhost
   origins, so a page on :8000 may call :11434 (verified by preflight).

2. http.DefaultClient CANNOT reach the network under GOOS=js. net/http
   only routes through fetch() when the Transport has no dial hooks; if
   one is set it dials, landing in Go's in-process FAKE network where
   localhost always fails "connection refused" — and DefaultTransport
   sets DialContext. Hence ollama_client_wasm.go, which passes a
   zero-value Transport. This compiled perfectly and was totally broken.

3. Node disables fetch (jsFetchDisabled, go.dev/issue/57613), so it
   can't verify (2) by default. scripts/wasm-smoke.cjs works around it.

Add that smoke harness (make smoke-web / smoke-web-ollama): it boots the
real app.wasm and drives snapshot()/dispatch(), because `go build` only
proves the wasm compiles, which finding (2) shows means nothing.

Also fixes a starvation bug the harness surfaced: sentences sat at 0 for
over 2 minutes while words trickled in. A Produce call runs to
completion and a model asked for a batch of words streams for minutes
(mostly duplicates that dedup discards), so producing both kinds in one
round starved the second. Each kind now gets its own producer goroutine,
ring, backoff and wake, so neither can block the other; low-water marks
are smaller since they double as the per-request batch size. Regression
test included; live warmup with both kinds now lands in ~6-14s.

The page polls only the status node, never the stage — repainting the
stage would destroy the capture <input> mid-keystroke.

Known cost, accepted for local use: app.wasm grows 3.6MB -> 12MB (1.0 ->
3.2MB gzipped), almost all of it dead weight the official client drags
in (ollama/auth -> x/crypto/ssh, crypto/tls, log/slog, regexp).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…_penalty)

Add Options.Temperature (default 1.0) and Options.RepeatPenalty (default
1.2), exposed as -ollama-temperature / -ollama-repeat-penalty and as the
temperature / repeat-penalty query params. Zero uses these defaults; a
negative value sends nothing, deferring to the model's own parameters.

The defaults are measured, not guessed. What the corpus actually cares
about is DISTINCT items per round: the ring dedups, and a round that adds
nothing new triggers a backoff, so duplicates directly cost warmup time.

Asking qwen3:8b for 60 words under its own declared parameters
(temperature 0.6, repeat_penalty 1) returned 75 lines but only 30
distinct words -- 60% duplicates. Raising temperature alone barely moved
it (64% duplicates at 1.0): the model repeats itself not because sampling
is too sharp but because nothing penalises repetition, and many models
ship with repeat_penalty disabled. Adding repeat_penalty 1.2 took
duplicates to 2% (50 distinct of 60 asked for).

1.2 is near the ceiling: at 1.5 sentence yield halved, because the penalty
starts suppressing the very words ordinary sentences are built from ("the",
"a"). Both kinds are fine at 1.0/1.2.

Effect live: cold-start warmup now clears the word low-water mark in a
single round (61 distinct words, 4.5s) where it previously yielded ~34-40
distinct, needing repeated rounds that tripped the unproductive-round
backoff.

Note these need no capability check, unlike thinking: Ollama accepts
sampling options for every completion model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…l data

Default corpus is now "auto": use a local Ollama if one is actually there,
static bank otherwise. Both frontends (-corpus=auto, and the plain page
with no ?corpus=). Explicit -corpus=ollama / ?corpus=ollama skips the
probe on purpose, so a down server is REPORTED as a failure rather than
silently downgraded; auto degrades quietly, since having no Ollama is the
normal case and not an error worth shouting about.

corpus.Detect handles the two things a naive "just try it" check gets
wrong:

  - The server can be up while the package default model is not pulled
    (llama3.2 on a box that only has qwen3), and generating against a
    missing model just fails. Detect resolves the model against what the
    server actually lists, preferring known-good small instruct models and
    skipping embedding-only ones that could never generate text.
  - It must not stall startup. An absent server refuses instantly;
    DetectTimeout (2s) only bites on one that listens but never answers.

Also pin repeat_penalty properly. The 1.2 default was right but rested on
a single sample, and single runs vary wildly (the same setting gave 64 and
117 distinct words on consecutive runs). Averaged over 3 runs on qwen3:8b
at temperature 1.0, 60 words requested:

  repeat_penalty   distinct words   duplicates
  1.0 (off)                    57        33.5%
  1.1                          76        21.6%
  1.2                          82         2.6%   <- best
  1.3                          63         0.5%

1.2 maximises distinct output while nearly eliminating duplicates, and
sentence yield holds (26-30 usable of 30). Kept as the default; the
comments and README, which quoted the overstated single-run figures
(60% -> 2%), now quote the averaged ones.

Update the wasm smoke harness for the new default: it now spoofs argv0
unconditionally, since otherwise Go's fetch stays disabled under Node and
auto-detection could never reach Ollama — the default run would have been
testing something a browser never does. Adds make smoke-web-auto.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reported: "TypeError: window.snapshot is not a function". The auto-detect
default I just added broke page startup outright.

go.run() hands control back to the page the moment Go blocks on anything
async. buildCorpus() probed for Ollama (a fetch) from main(), so Go
suspended BEFORE js.Global().Set("snapshot", ...) ran. app.js then called
window.snapshot() and it did not exist yet. A dead page, not a slow one --
and `go build` was perfectly happy with it.

Fix, which follows the rule the corpus already lives by (never block on
I/O): register snapshot/dispatch first and resolve the corpus behind them.
cmd/web now returns a corpus.Deferred immediately -- it serves the static
bank and swaps the real source in when detection completes on a goroutine.
It reports "warming" while probing so the frontends keep polling and
notice the upgrade, and settles onto static if there is no Ollama. The
page is interactive throughout and upgrades in place, exactly as it
already does for streamed content.

The Node harness missed this because it waited a second before its first
snapshot() call -- hiding precisely the bug it exists to catch. It now
calls snapshot() immediately after go.run(), like the page does, and fails
if the globals are absent; it reproduces the failure on the old binary.
web/app.js additionally waits for the globals, so any future regression
here costs a slow boot rather than a blank page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reported: skipping a lot never grows the sentence count.

Reproduced live (new opt-in TestStreamTopsUpUnderDemandLive): with the
bank settled at its low-water mark of 20, drawing 25 sentences -- a full
pass through everything it holds -- left it at 20.

The refill trigger was a flat 100 draws regardless of bank size. The
sentence bank holds 20, so a user had to cycle it FIVE times, seeing every
sentence five times over, before a single new one was generated. Skipping
felt like it did nothing because it did nothing.

The threshold now scales with the bank: a kind earns a top-up once it has
been drawn from about as many times as it holds. Sentences refill after
~20 draws, words after ~60. This also self-throttles -- as the ring grows
toward its cap, top-ups naturally become rarer -- and keeps the trigger
honest instead of a magic constant.

To the second question -- yes, new sentences really are generated, and
generation is not the bottleneck. Measured: two different themes produce
20 distinct sentences each with ZERO overlap, and even re-running the same
theme yields 20 more. Live, one pass of skipping now takes the bank 20 ->
40, all 20 new. The old behaviour was purely the refill trigger, not the
model repeating itself.

(The first cut of the live test reported "20 -> 23" and looked like a
duplicate problem; it was returning on the first sign of growth while the
round was still streaming in. It now waits for the round to finish.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Base automatically changed from build/trainer-phases to main July 30, 2026 04:55
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