Phases 1 & 5: mapping parser + pluggable corpus (incl. Ollama) - #2
Open
christiaanjs wants to merge 20 commits into
Open
Phases 1 & 5: mapping parser + pluggable corpus (incl. Ollama)#2christiaanjs wants to merge 20 commits into
christiaanjs wants to merge 20 commits into
Conversation
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>
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>
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.
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 frommappings/, and practice content can come from a static bank, a text file, a codebase, or a local Ollama model — all behind the existingMapping/Corpusseam 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) readsmappings/*.json, extracts the alt-layer (space-held) mirror pairs, and builds a mapping. It takes anfs.FS(neveros) socorestays wasm-safe; the JSON is embedded viamappings/mappings.go(embed.FS).mirrorTabletype (core/mirror_table.go) implementingHint/Supported/Diagnose/Reference, so parsed and static behave identically by construction.staticMappingis refactored to build the same type;NewStaticMappingis exported as the fallback.core/mapping_parse_test.go: oracle tests asserting the parsed mapping equals the static one across all interface methods, plusfstest.MapFSedge cases.Phase 5 — Corpus subsystem
New
corpus/package (all I/O lives here, outside the pure core), each constructor returning aSourcethat implementscore.Corpus:FromText/FromReader/FromFile— plaintext.FromCodebase— tokenizes source identifiers (camel/identifier splitting) into practice words/sentences.FromOllama— queries a local Ollama server via the officialgithub.com/ollama/ollama/apiclient (non-streaming); errors on too-few results so the caller falls back to static.staticCorpus.NewStaticCorpusis exported as the fallback.Frontend wiring
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.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=ollamablocked 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— acore.Corpusthat 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.Visible, not silent
Content now arrives after startup, so the core surfaces it:
State.Corpus(CorpusStatus/CorpusPhase), populated via an optionalStatusReporterinterface 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):The
Corpuscontract is now explicit about the rule that makes this possible:Word/Sentencerun 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):
Thinking models
Reasoning models (qwen3, deepseek-r1) emit their chain-of-thought to
GenerateResponse.Thinking, not.Response— so the corpus sat atwarmingwith 0 words for 2+ minutes while the GPU churned. Thinking is now disabled, conditionally: the capability is resolved once from the server, because sendingthinkto a model that lacks it is rejected.Live verification
Not just unit-tested — exercised against a real
qwen3:8b:streamingin ~1s; 42 generated words + 2 generated sentences within 6.5s; TUI startup with-corpus=ollamareturns in 605ms instead of blocking.OLLAMA_LIVE_TEST=1 OLLAMA_TEST_MODEL=qwen3:8b go test ./corpus -run TestFromOllamaLive -vruns it; it is skipped by default and in CI. Full suite passes undergo test -race ./....Docs
PLAN.md,README.md, andCLAUDE.mdupdated to mark phases 1 & 5 done and document the corpus flags / Ollama usage.Verification
gofmt,go vet ./...,go test ./..., nativego build, andGOOS=js GOARCH=wasm go build -o web/app.wasm ./cmd/weball pass; CI green on the branch.🤖 Generated with Claude Code