WolfDavid commited on
Commit
7eb3e94
Β·
1 Parent(s): 5411e5e

docs(phase-02): phase execution report - 10/11 plans, findings, measurements, deploy blocker

Browse files
.planning/phases/02-japanese-language-core/02-EXECUTION-REPORT.md ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Phase 02 β€” Japanese Language Core: Execution Report
2
+
3
+ status: blocked-on-deploy
4
+ phase: 02-japanese-language-core
5
+ plans_complete: 10 of 11
6
+ commits: 54 (`df2c231`..`5411e5e`)
7
+ written: 2026-09-12
8
+
9
+ ---
10
+
11
+ ## What the phase set out to do
12
+
13
+ Give the learner a page where Japanese is *readable*, not just audible: every line broken into
14
+ whole words, furigana over the kanji at a level the learner picks, a tap on any word explaining
15
+ it, and an English reveal per line. Requirements JPN-01 through JPN-04.
16
+
17
+ Phase 1 had shipped the voice-and-avatar loop. Phase 2 adds the language layer behind the same
18
+ `gr.HTML` bridge, with no new transport and no GPU on the turn path.
19
+
20
+ ---
21
+
22
+ ## Status
23
+
24
+ Ten of eleven plans are complete, committed and locally green. The eleventh (`02-10`) is an
25
+ owner-gated close-out that cannot run until the build is on the Space.
26
+
27
+ | Wave | Plan | What it built | Status |
28
+ |------|------|---------------|--------|
29
+ | 1 | 02-01 | LFS arming, stack pins, JLPT + compact JMdict data sets | βœ“ Complete |
30
+ | 2 | 02-02 | Morphemes β†’ whole-word units; ruby alignment; reading overrides | βœ“ Complete |
31
+ | 2 | 02-03 | Ranked JMdict headword lookup; JLPT level derivation (both axes) | βœ“ Complete |
32
+ | 2 | 02-04 | OPUS-MT jaβ†’en converted to CTranslate2 int8; translate() | βœ“ Complete |
33
+ | 3 | 02-05 | The canonical `analyze(text)`; 28-sentence golden set | βœ“ Complete |
34
+ | 4 | 02-06 | Three server functions; tokens on every directive; warm-up | βœ“ Complete |
35
+ | 5 | 02-07 | Furigana on the page: three modes, level picker, persistence | βœ“ Complete |
36
+ | 6 | 02-08 | Word-lookup popover, zero bridge calls per tap | βœ“ Complete |
37
+ | 7 | 02-09 | Inline EN reveal with per-line cache and state machine | βœ“ Complete |
38
+ | 8 | 02-11 | Nine licence rows verified; on-page credits; docs/LANGUAGE.md | βœ“ Complete |
39
+ | 9 | 02-10 | Deployed suite, latency record, phone rows, requirement marks | ⬜ **Blocked** |
40
+
41
+ **Blocker:** the build has not been pushed to the Space. Local `master` is 54 commits ahead of
42
+ `space/main`, which still runs `df2c231`. The four LFS objects the build needs **were uploaded
43
+ successfully** (`git lfs push space master`, 87 MB); only the ref update remains.
44
+
45
+ ---
46
+
47
+ ## What shipped
48
+
49
+ ### Server side β€” `src/japanese_avatar/nlp/`
50
+
51
+ | Module | Role |
52
+ |--------|------|
53
+ | `tokenizer.py` | Sudachi mode-C morphemes; **per-thread** instance under a build lock |
54
+ | `units.py` | Morphemes β†’ whole-word units (joining rules A1–A7) |
55
+ | `ruby.py` | Surface + reading β†’ per-kanji-run ruby spans, with an honest fallback |
56
+ | `overrides.py` | Committed reading-override table |
57
+ | `jmdict.py` | Compact JMdict loader; ranked headword lookup |
58
+ | `levels.py` | JLPT level derivation on the word and kanji axes |
59
+ | `analyzer.py` | The one canonical `analyze(text)` β†’ 14-key token records |
60
+ | `translate.py` | CTranslate2 int8 translator, singleton + warm-up |
61
+ | `warm.py` | Whole-language warm-up with per-component timings and RSS |
62
+
63
+ ### Browser side β€” `avatar/`
64
+
65
+ `transcript.js` owns rendering and all three reading features; `transcript.css` styles them;
66
+ `transcript-harness.html` exercises the module with no server at all. `host.js` owns *policy*
67
+ (which line, which cache, when to call) and holds the single `avatar.translate()` call site in
68
+ the codebase. `avatar.js`, `avatar-iframe.js` and `stage.html` were changed by **zero lines** β€”
69
+ the transport seam Phase 1 established held for the whole phase.
70
+
71
+ ### Data, all pinned and hash-checked
72
+
73
+ | Asset | Pin | Size |
74
+ |-------|-----|------|
75
+ | JLPT vocabulary (yomitan-jlpt-vocab) | tag `2025.08.01.0` | 8,293 rows, 7,748 unique ids |
76
+ | Kanji levels (kanji-data) | commit `00fd7079…` | 2,211 kanji |
77
+ | Compact JMdict projection | jmdict-eng `3.6.2` | 218,672 entries, 7.66 MB gz (LFS) |
78
+ | OPUS-MT jaβ†’en β†’ CTranslate2 int8 | Hub rev `0770961a…` | 77,339,435 B (LFS) |
79
+
80
+ ---
81
+
82
+ ## Four findings that a green test suite would not have shown
83
+
84
+ These are the substance of the phase. Each was found by a check that was *built to be able to
85
+ fail*, and each is fixed and pinned.
86
+
87
+ ### 1. The Japanese tokenizer is not safe to share between threads
88
+
89
+ `sudachipy.Tokenizer` takes a mutable borrow for the whole `tokenize()` call. A second thread
90
+ gets `RuntimeError: Already borrowed` β€” and the server's own error path swallowed it and returned
91
+ an empty token list so the turn would still *speak*. The visible symptom was an avatar line that
92
+ rendered without furigana, occasionally, with tests green on both sides. Five of eight concurrent
93
+ calls were being lost.
94
+
95
+ Plan 02-07 is what made it reachable: it analyses the learner's line while the turn's own analyse
96
+ stage runs. The `Dictionary` stays process-wide (the mmapped ~110 MB); `get_tokenizer()` is now
97
+ `threading.local` under a build lock. 288 of 288 concurrent tokenisations clean.
98
+
99
+ **The guard is mutation-tested.** Reverting to a shared instance makes
100
+ `tests/test_analyzer.py::test_analyze_is_thread_safe` fail with the real exception β€” the test
101
+ cannot pass against the broken implementation.
102
+
103
+ ### 2. A word card could cover the word it explained
104
+
105
+ On a phone column, a card clamped back into the viewport could land on top of its own anchor. The
106
+ next tap then hit the card: the dismiss handler returned early because the target was inside the
107
+ popover, and the open handler found no word under the pointer. The tap was swallowed in total
108
+ silence. Placement now picks the side with more room and caps the card's height to that room.
109
+
110
+ ### 3. The transcript panel had never actually scrolled
111
+
112
+ `#transcript-text` was an auto-height block, so *any* card anchored to the last line hung below
113
+ the visible area β€” the plan's phone must-have was unreachable on the real page. It is now a fixed
114
+ 40vh scrolling column (335 px on a Pixel 7). Side effect worth knowing: `host.js`'s long-standing
115
+ `el.scrollTop = el.scrollHeight` does something for the first time; on an auto-height element it
116
+ had always been a no-op.
117
+
118
+ ### 4. Two acceptance checks could not fail
119
+
120
+ In plan 02-09, the grep asserting the translate call existed was matching a **comment**, because
121
+ the real call was chained across two lines. Deleting the actual call would have left both the grep
122
+ and the seam test green. A second check asserted the absence of something that had existed since
123
+ the previous plan. Both are now exact enough to break when the code they guard is removed.
124
+
125
+ A third, in 02-11, was met by substitute evidence rather than literally: the dictionary credit
126
+ string is defined **once** and interpolated into both the footer and the About panel, so the
127
+ plan's "appears at least twice" grep returns 1. Retyping the string to satisfy the grep would
128
+ create exactly the drift the machine-readable credits block exists to prevent. The rehearsal's own
129
+ printout β€” all five credits present across footer, About panel and `GET /config` β€” is recorded in
130
+ the summary in its place.
131
+
132
+ ---
133
+
134
+ ## Corrections to the research, made against primary sources
135
+
136
+ The phase refused to inherit numbers it could not reproduce.
137
+
138
+ | Claim in research/plan | Measured truth |
139
+ |---|---|
140
+ | 7,747 unique JLPT ids | **7,748**, all of which resolve in JMdict |
141
+ | 505 ids on more than one level | **447** ids; the 505 counted *rows*, not ids |
142
+ | jmdict-eng top-level `version` is the release tag | It is `"3.6.2"`; the plan's assertion would have rejected the correct file |
143
+ | OPUS-MT rights holder "Language Technology Research Group" | The Hub org's own name is **"Helsinki-NLP Research Group"**, University of Helsinki |
144
+ | SudachiDict licence resolvable from the GitHub API | API returns `license: null` (its file is `LICENSE-2.0.txt`); Apache-2.0 rests on the repo's own `LICENSE`/`LEGAL`/`README` plus PyPI. `LEGAL` also discloses bundled **UniDic** (BSD-3-Clause) and **NEologd**, which the plan did not mention |
145
+
146
+ All nine Phase 2 licence rows were verified against primary sources on 2026-09-07. Every one of
147
+ the fourteen cited URLs returned 200. Nothing was unreachable; nothing was guessed.
148
+
149
+ ### Honest level results that look like bugs and are not
150
+
151
+ `ζ—₯本θͺž` and `γͺい` badge as `N1+ / beyond lists` because they sit on none of the five pinned
152
+ lists. `こんにけは` is N3. `γͺγ‚‹` badges N3 because the N5 list's `γͺγ‚‹` row points at the archaic
153
+ copula, not the verb. `いい` resolves to its own JMdict entry `2820690`, not `良い`. These are
154
+ upstream list truths, recorded in the golden set rather than special-cased away.
155
+
156
+ ---
157
+
158
+ ## Numbers
159
+
160
+ ### Local suites at the end of the phase
161
+
162
+ | Suite | Result |
163
+ |---|---|
164
+ | Quick loop (`tests`, no e2e, no mt) | **343 passed**, 17 deselected, 35.66 s (budget 40 s) |
165
+ | Whole `tests/e2e/` | **39 passed**, 25 skipped, 0 failed |
166
+ | Translation marker (`-m mt`) | 17 passed |
167
+ | `ruff check` / `ruff format --check` | clean, 58 files |
168
+
169
+ ### Measured behaviour
170
+
171
+ | Measurement | Value |
172
+ |---|---|
173
+ | Warm analysis, 36-mora sentence | 0.14–0.19 ms |
174
+ | Ranked lookup, 10,000 iterations | 22.7 ms (budget 50) |
175
+ | Compact JMdict load | 0.56–0.70 s gz; 2.0–2.4 s to indexed entries |
176
+ | Translator warm-up | 0.35 s |
177
+ | Translation, idle, per sentence | 22–286 ms |
178
+ | Language warm-up, fresh process | tokenizer 0.04 s, JMdict 2.4 s, translator 0.34 s; RSS +467 MB |
179
+ | Ruby `rt` height | 11 px; line height always-vs-never 32.39 / 29.39 |
180
+ | Word card, requests per tap | `[]` under both transports and deployed |
181
+ | EN reveal, deployed rehearsal | 2,062 / 2,438 ms tap→shown; cached re-show `[]` requests |
182
+
183
+ **Every "deployed" number above was measured against a local `DISABLE_GPU=1 app.py` on loopback.**
184
+ They are rehearsals awaiting the owner's push, and they are labelled that way in every summary.
185
+ Real Space numbers are plan 02-10's job.
186
+
187
+ ### A caveat on browser timings
188
+
189
+ Under headless SwiftShader this machine is badly starved. The learner line's analyse round trip
190
+ measures 14.6–22.7 s of wall clock against 0.02–0.19 ms of server work, because Gradio serves it
191
+ around a 12–19 s synthesis. Phase 1's lesson β€” record timings, do not assert tight budgets β€”
192
+ applied throughout, and three absolute ceilings that flake under load were logged rather than
193
+ chased.
194
+
195
+ ---
196
+
197
+ ## Deferred, with owners
198
+
199
+ Six items in `deferred-items.md`. The three that matter:
200
+
201
+ 1. **First-turn wait behind the language warm-up on the Space is unmeasured.** Locally the warm-up
202
+ finishes 1.2 s before the first frame so a tap cannot overlap it, but the Space's CPU is 3–6Γ—
203
+ slower. 02-10 should time a "Say hello" tapped immediately after `ready`.
204
+ 2. **The learner's furigana costs a second round trip.** The avatar echoes the same text in
205
+ Phase 2, so one analysis could serve both lines β€” but Phase 3 makes the two lines differ, so
206
+ the split shape was kept deliberately.
207
+ 3. **Three wall-clock ceilings flake under whole-suite load** and pass in isolation (Phase 1's
208
+ tap-to-running ceiling, the WASM ASR fallback, the JMdict lookup budget). All are machine-speed
209
+ constants on a shared laptop, not product properties.
210
+
211
+ ---
212
+
213
+ ## What 02-10 still needs
214
+
215
+ 1. **The push** β€” `git push space master:main`. The LFS objects are already uploaded.
216
+ 2. Deployed suite on the pushed revision, with `DISABLE_GPU` read back.
217
+ 3. Real translation latency into `docs/LATENCY.md Β§ Translation` with the revision SHA, plus the
218
+ container's cores, memory and post-warm-up RSS into `docs/HOSTING.md`.
219
+ 4. **Phone rows** β€” furigana legibility, popover thumb reach and translation quality judged on a
220
+ real device, recorded with who judged what. These need a human and a phone.
221
+ 5. JPN-01 through JPN-04 marked complete **only** for rows that actually passed on the pushed
222
+ revision. Nothing has been marked yet, deliberately, across all ten plans.
223
+
224
+ Note that Phase 1's `AVTR-01` also still awaits an owner phone retest, and the same push serves
225
+ both.