WolfDavid commited on
Commit
a4c4b26
·
1 Parent(s): f743d45

docs(02-06): complete server functions, directive tokens and language bridge plan

Browse files

- 02-06-SUMMARY.md: measured warm-up seconds and RSS (+467 MB bare, +558 MB
in-app vs +410 expected), system.dic / model.bin bytes, translate ms under
both transports, e2e totals, the strict-autoplay A/B against the pre-plan
commit, three auto-fixed deviations
- deferred-items.md: the Phase 1 tap-to-running sanity ceiling this machine no
longer meets; first-turn wait behind the language warm-up to measure on the Space
- STATE.md: plan 7 of 11, metrics row, four decisions, session
- ROADMAP.md: phase 02 plan progress

.planning/ROADMAP.md CHANGED
@@ -59,7 +59,7 @@ Decimal phases appear between their surrounding integers in numeric order.
59
  - [x] 02-03-PLAN.md — Ranked JMdict lookup + JLPT word/kanji level derivation (N1+, name) (TDD) [wave 2]
60
  - [x] 02-04-PLAN.md — OPUS-MT ja-en → CTranslate2 int8 under LFS, conversion script, translate() runtime (mt marker) [wave 2]
61
  - [x] 02-05-PLAN.md — The canonical analyze(text) + the 28-sentence golden fixture set (SC-1) [wave 3]
62
- - [ ] 02-06-PLAN.md — tokens on the directive; analyze / translate / language_info server functions; warm-up on load; turn-loop + facade surface; parity [wave 4]
63
  - [ ] 02-07-PLAN.md — Furigana: transcript.js renderer, three-mode control + level picker, persistence, three-layer numbers (JPN-02) [wave 5]
64
  - [ ] 02-08-PLAN.md — Word-lookup popover with published numbers, desktop + Pixel 7 rows (JPN-03) [wave 6]
65
  - [ ] 02-09-PLAN.md — Inline EN reveal with per-line cache, translation latency harness (JPN-04) [wave 7]
 
59
  - [x] 02-03-PLAN.md — Ranked JMdict lookup + JLPT word/kanji level derivation (N1+, name) (TDD) [wave 2]
60
  - [x] 02-04-PLAN.md — OPUS-MT ja-en → CTranslate2 int8 under LFS, conversion script, translate() runtime (mt marker) [wave 2]
61
  - [x] 02-05-PLAN.md — The canonical analyze(text) + the 28-sentence golden fixture set (SC-1) [wave 3]
62
+ - [x] 02-06-PLAN.md — tokens on the directive; analyze / translate / language_info server functions; warm-up on load; turn-loop + facade surface; parity [wave 4]
63
  - [ ] 02-07-PLAN.md — Furigana: transcript.js renderer, three-mode control + level picker, persistence, three-layer numbers (JPN-02) [wave 5]
64
  - [ ] 02-08-PLAN.md — Word-lookup popover with published numbers, desktop + Pixel 7 rows (JPN-03) [wave 6]
65
  - [ ] 02-09-PLAN.md — Inline EN reveal with per-line cache, translation latency harness (JPN-04) [wave 7]
.planning/STATE.md CHANGED
@@ -3,13 +3,13 @@ gsd_state_version: 1.0
3
  milestone: v1.0
4
  milestone_name: milestone
5
  status: unknown
6
- stopped_at: "Completed 02-05-PLAN.md (wave 3): analyze(text) canonical token record (14 keys, 0.14-0.19 ms warm), levels/ruby is_kanji reconciled, 28-sentence golden fixture + refusal-guard generator, 14 analyzer tests; quick loop 293 passed / 30.7 s. Next 02-06."
7
- last_updated: "2026-09-07T00:08:47.920Z"
8
  progress:
9
  total_phases: 6
10
  completed_phases: 1
11
  total_plans: 22
12
- completed_plans: 16
13
  ---
14
 
15
  # Project State
@@ -24,7 +24,7 @@ See: .planning/PROJECT.md (updated 2026-08-08)
24
  ## Current Position
25
 
26
  Phase: 02 (japanese-language-core) — EXECUTING
27
- Plan: 6 of 11
28
 
29
  ## Performance Metrics
30
 
@@ -66,6 +66,7 @@ Plan: 6 of 11
66
  | Phase 02 P03 | 11 | 2 tasks | 5 files |
67
  | Phase 02 P04 | 17 | 2 tasks | 13 files |
68
  | Phase 02 P05 | 12 | 2 tasks | 7 files |
 
69
 
70
  ## Accumulated Context
71
 
@@ -145,6 +146,10 @@ Recent decisions affecting current work:
145
  - [Phase 02]: Three plan-assumed word levels corrected to what the pinned lists say and pinned by JMdict id, not patched: 日本語 1464530 is N1+ (on no list; kanji all N5 - the two-axis example), ない/なかった resolve to 無い 1529520 which no list carries (N1+), こんにちは 1289400 is N3; 人気 read ひとけ is the separate entry 1367020 (N1+). A level-alias table is the honest fix if the product wants otherwise (02-10 review), never a weaker lookup
146
  - [Phase 02]: levels.is_kanji IS ruby.is_kanji (02-03's parallel-wave copy removed): one predicate decides both ruby and the kanji-axis gate, so supplementary-plane kanji (𠮷) reach the axis as unlisted instead of being skipped
147
  - [Phase 02]: The fixed sample sentence set (SC-1) follows the golden_timeline pattern: 28 sentences / 115 records generated by the real pipeline, 18 EXPECTED facts checked before writing (exit 1, nothing written otherwise), frozen LF byte-for-byte with data pins read from the environment; JPN-01 still bound to 02-10's rows
 
 
 
 
148
 
149
  ### Pending Todos
150
 
@@ -166,8 +171,8 @@ None yet.
166
 
167
  ## Session Continuity
168
 
169
- Last session: 2026-09-07T00:08:47.915Z
170
- Stopped at: Completed 02-05-PLAN.md (wave 3): analyze(text) canonical token record (14 keys, 0.14-0.19 ms warm), levels/ruby is_kanji reconciled, 28-sentence golden fixture + refusal-guard generator, 14 analyzer tests; quick loop 293 passed / 30.7 s. Next 02-06.
171
  Resume file: None
172
 
173
  **Roadmap-level open decision:** the Phase 1 latency trigger (p50 7.1 s, ~90 % server synthesis) fired; recorded in `docs/LATENCY.md` and in 02-CONTEXT.md § Deferred. Phase 2 adds a CPU translation model to the same container — measure on the Space. Consider `/gsd:insert-phase` for streaming/synthesis relocation before Phase 3 lengthens turns.
 
3
  milestone: v1.0
4
  milestone_name: milestone
5
  status: unknown
6
+ stopped_at: "Completed 02-06-PLAN.md (wave 4): analyze/translate/language_info server functions + tokens on the directive, lock-serialised language warm-up on Blocks.load (+467 MB local vs +410 expected), analyze/translate/languageInfo on the shared turn loop, parity under both transports; quick loop 308 passed / 33.4 s; whole e2e 26 passed. Next 02-07."
7
+ last_updated: "2026-09-07T00:52:28.909Z"
8
  progress:
9
  total_phases: 6
10
  completed_phases: 1
11
  total_plans: 22
12
+ completed_plans: 17
13
  ---
14
 
15
  # Project State
 
24
  ## Current Position
25
 
26
  Phase: 02 (japanese-language-core) — EXECUTING
27
+ Plan: 7 of 11
28
 
29
  ## Performance Metrics
30
 
 
66
  | Phase 02 P03 | 11 | 2 tasks | 5 files |
67
  | Phase 02 P04 | 17 | 2 tasks | 13 files |
68
  | Phase 02 P05 | 12 | 2 tasks | 7 files |
69
+ | Phase 02 P06 | 38 | 3 tasks | 10 files |
70
 
71
  ## Accumulated Context
72
 
 
146
  - [Phase 02]: Three plan-assumed word levels corrected to what the pinned lists say and pinned by JMdict id, not patched: 日本語 1464530 is N1+ (on no list; kanji all N5 - the two-axis example), ない/なかった resolve to 無い 1529520 which no list carries (N1+), こんにちは 1289400 is N3; 人気 read ひとけ is the separate entry 1367020 (N1+). A level-alias table is the honest fix if the product wants otherwise (02-10 review), never a weaker lookup
147
  - [Phase 02]: levels.is_kanji IS ruby.is_kanji (02-03's parallel-wave copy removed): one predicate decides both ruby and the kanji-axis gate, so supplementary-plane kanji (𠮷) reach the axis as unlisted instead of being skipped
148
  - [Phase 02]: The fixed sample sentence set (SC-1) follows the golden_timeline pattern: 28 sentences / 115 records generated by the real pipeline, 18 EXPECTED facts checked before writing (exit 1, nothing written otherwise), frozen LF byte-for-byte with data pins read from the environment; JPN-01 still bound to 02-10's rows
149
+ - [Phase 02]: warm_language() is a lock around an lru_cache'd body: lru_cache alone lets two concurrent first callers load the compact JMdict twice (CPython calls the function outside any lock), proven by a two-thread test; every server function and turn's analyze stage call it first so a request during Blocks.load waits for the singletons instead of racing a second +300 MB load
150
+ - [Phase 02]: Language warm-up measured: local bare process tokenizer 0.04 s / JMdict 2.4 s / translator 0.34 s, RSS +467 MB vs research +410 (02-03's +312 MB JMdict accounts for it); inside app.py +558 MB because warm_synthesizer shares the Blocks.load window; rss_mb() reads /proc on the Space, the Windows working set via ctypes locally (the GetCurrentProcess pseudo-handle must be declared HANDLE)
151
+ - [Phase 02]: Bridge helpers are requireBridge() + checkResult(), not one callBridge(name, payload): every turn-loop call site keeps its literal one-payload bridge.x({...}) for the seam grep and dispatchTurn keeps 'await bridge.' on the line the thinking-before-first-await guard reads; transports changed by 0 lines
152
+ - [Phase 02]: A full-suite timing failure (strict-autoplay tap-to-running 11.5 s > Phase 1's 10 s sanity ceiling) was A/B'd against the pre-plan commit in a throwaway worktree on the same machine (6.2 s baseline vs 6.1 s HEAD inline) and a stamped probe showed the load warm-up finishing before the first frame: not a regression; the machine-speed ceiling is logged to deferred-items, the load-bearing relative assertion held every run
153
 
154
  ### Pending Todos
155
 
 
171
 
172
  ## Session Continuity
173
 
174
+ Last session: 2026-09-07T00:52:28.896Z
175
+ Stopped at: Completed 02-06-PLAN.md (wave 4): analyze/translate/language_info server functions + tokens on the directive, lock-serialised language warm-up on Blocks.load (+467 MB local vs +410 expected), analyze/translate/languageInfo on the shared turn loop, parity under both transports; quick loop 308 passed / 33.4 s; whole e2e 26 passed. Next 02-07.
176
  Resume file: None
177
 
178
  **Roadmap-level open decision:** the Phase 1 latency trigger (p50 7.1 s, ~90 % server synthesis) fired; recorded in `docs/LATENCY.md` and in 02-CONTEXT.md § Deferred. Phase 2 adds a CPU translation model to the same container — measure on the Space. Consider `/gsd:insert-phase` for streaming/synthesis relocation before Phase 3 lengthens turns.
.planning/phases/02-japanese-language-core/02-06-SUMMARY.md ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ phase: 02-japanese-language-core
3
+ plan: 06
4
+ subsystem: ui
5
+ tags: [gradio, gr.HTML, server_functions, bridge, turn-loop, facade, warm-up, rss, playwright, parity, sc-4, tdd]
6
+
7
+ # Dependency graph
8
+ requires:
9
+ - phase: 02-japanese-language-core
10
+ plan: 05
11
+ provides: "analyzer.analyze(text) -> 14-key records, analyzer.warmup(); the 28-sentence golden fixture the parity assertions are drawn from"
12
+ - phase: 02-japanese-language-core
13
+ plan: 04
14
+ provides: "translate.translate() / warmup() / model_bytes() / MAX_TEXT_CHARS; the LFS CT2 int8 model; the mt marker"
15
+ - phase: 02-japanese-language-core
16
+ plan: 03
17
+ provides: "jmdict.load() (218,672 entries, meta.version) and levels; jmdict.COMPACT_PATH"
18
+ - phase: 01-voice-avatar-loop-skeleton
19
+ plan: 08
20
+ provides: "one-payload server functions that never raise, warm_synthesizer on Blocks.load, AVATAR_SURFACE as data, turn-loop.js as the only turn implementation, the parity suite"
21
+ - phase: 01-voice-avatar-loop-skeleton
22
+ plan: 11
23
+ provides: "the strict-autoplay layer of the parity suite and its tap-to-running sanity ceiling (re-measured here)"
24
+ provides:
25
+ - "src/japanese_avatar/nlp/warm.py: warm_language() - lock-serialised, lru_cache-idempotent warm-up of tokenizer + compact JMdict + CT2 translator with per-component seconds, RSS before/after/delta beside EXPECTED_WARM (+410 MB), CPU_CORES/MEMORY, errors[]; rss_mb() via /proc, psutil or the Windows working set"
26
+ - "src/japanese_avatar/ui/blocks.py: analyze(payload), translate(payload), language_info(payload) server functions (one positional, {error} on every failure); turn() attaches tokens inside an analyze stage; warm_language_on_load bound to Blocks.load; server_functions=[turn, greeting, analyze, translate, language_info]"
27
+ - "src/japanese_avatar/voice/models.py: AvatarDirective.tokens: list[dict] = field(default_factory=list)"
28
+ - "avatar/turn-loop.js: analyze(text), translate(text, lineId), languageInfo() implemented once for both transports; requireBridge()/checkResult() shared with dispatchTurn; tokens on the turn event; 9 language debug keys initialised at construction"
29
+ - "avatar/facade.js: AVATAR_SURFACE += analyze, translate, languageInfo (15 names)"
30
+ - "tests/test_server_functions.py (14 tests; 3 mt): payload shapes, {error} paths, language_info shape with and without the real warm, lock serialisation, failing component, tokens on the directive, every server function waits for warm_language()"
31
+ - "tests/test_no_gpu_on_turn_path.py: transformers banned; reachability walked from turn/analyze/translate/language_info with same-named definitions merged"
32
+ - "tests/test_transport_seam.py (+12 items): language surface declared once, lives only in turn-loop.js, debug keys, tokens inside emit('turn'), one payload per bridge call"
33
+ - "tests/e2e/test_facade_parity.py::test_language_bridge_under_both_transports[inline|iframe]"
34
+ affects: [02-07, 02-08, 02-09, 02-10, 02-11, phase-3-tutor-turns]
35
+
36
+ # Tech tracking
37
+ tech-stack:
38
+ added: []
39
+ patterns:
40
+ - "Every language server function calls warm_language() before touching a singleton: after the first call it is a lock acquire plus a cached dict; during Blocks.load it makes the request wait for the singletons instead of racing lru_cache misses into a second +300 MB JMdict load"
41
+ - "The warm-up measurement is the lru_cache'd return value of the locked body - one read-only dict, never a module-level mutable - so language_info can report it without a half-filled cache"
42
+ - "Bridge helpers split in two (requireBridge + checkResult) rather than one callBridge(name, payload): every call site keeps its literal one-payload `bridge.x({...})` for the seam grep and dispatchTurn keeps `await bridge.` on the line the thinking-before-await guard reads"
43
+ - "Quick-loop tests that only need the analyzer redirect blocks.warm_language to a dictionary-only stub (fixture dictionary_only_warm); the real warm-up is exercised under the mt marker and by the parity suite, and one counting test proves each server function calls it"
44
+ - "A timing-bound failure in the full suite is A/B'd against the pre-plan commit in a throwaway worktree on the same machine before it is called a flake or a regression"
45
+
46
+ key-files:
47
+ created:
48
+ - src/japanese_avatar/nlp/warm.py
49
+ - tests/test_server_functions.py
50
+ modified:
51
+ - src/japanese_avatar/ui/blocks.py
52
+ - src/japanese_avatar/voice/models.py
53
+ - avatar/turn-loop.js
54
+ - avatar/facade.js
55
+ - tests/test_directive.py
56
+ - tests/test_no_gpu_on_turn_path.py
57
+ - tests/test_transport_seam.py
58
+ - tests/e2e/test_facade_parity.py
59
+
60
+ key-decisions:
61
+ - "warm_language() = with _WARM_LOCK: return _warm_once() where _warm_once is lru_cache(maxsize=1): proven by test_warm_language_first_call_is_serialised (two concurrent first callers, each component ran exactly once, same dict object) - lru_cache alone runs the body twice because CPython calls the function outside any lock"
62
+ - "rss_mb() gained a Windows working-set route (psapi.GetProcessMemoryInfo through ctypes) so this machine measures instead of printing None; the pseudo-handle must be declared HANDLE or it is truncated to a 32-bit int and the call fails with 'The handle is invalid' (measured, fixed)"
63
+ - "The version pins language_info reports are READ, not typed: sudachipy.__version__, importlib.metadata for the dictionary, jmdict.load().meta['version'], and data/jlpt/README.md + data/mt/README.md for the two pins that live in scripts/ (the package must not import scripts/)"
64
+ - "analyze/turn share one _analyze_stage(text, timings) so `stage(\"analyze\")` appears once; analyze() turns its exception into {error}, turn() turns it into tokens=[] with a logged traceback so the avatar still speaks"
65
+ - "The quick loop stays translator-free: analyze/turn tests run with blocks.warm_language stubbed to a dictionary-only warm (the session analyzer fixture already holds the JMdict), and test_server_functions_wait_for_warm_language proves the real call sites with a counter; the real warm runs under mt (3 tests) and in the browser suite"
66
+ - "The one full-suite failure (test_gesture_unlocks_audio_under_both_transports[inline], tap-to-running 11,457 ms > 10,000 ms sanity ceiling) was A/B'd: the pre-plan commit 16f28bd in a worktree reads 6,160 ms inline / 2,976 ms iframe on the same machine minutes later, HEAD reads 6,084 / 4,228 ms - the same starved profile either way; not a regression, the Phase 1 ceiling (written at 0.7-3.5 s) is logged to deferred-items for re-measurement"
67
+
68
+ patterns-established:
69
+ - "Warm-up components are separate module functions (_warm_tokenizer/_warm_jmdict/_warm_translator) so a test can substitute a slow or failing one and prove the lock or the never-raise rule without touching the real singletons"
70
+ - "A parity test parks a slow promise on window and waits for the event it emits with the turn budget, instead of awaiting page.evaluate with no timeout"
71
+
72
+ requirements-completed: [] # JPN-01 / JPN-04 are bound to 02-10's rows (02-VALIDATION.md); not marked here
73
+
74
+ # Metrics
75
+ duration: 38min
76
+ completed: 2026-09-07
77
+ ---
78
+
79
+ # Phase 02 Plan 06: Server Functions, Directive Tokens and the Language Bridge Summary
80
+
81
+ **The language core reaches the browser the way the voice does: three payload-taking, never-raising server functions (`analyze`, `translate`, `language_info`) behind the `gr.HTML` bridge, three surface methods implemented once in `turn-loop.js` so inline and iframe get them together, the analyzer's tokens on every directive and on the `turn` event, and the tokenizer + compact JMdict + CT2 translator warmed on `Blocks.load` under a lock with their cost measured (local: 0.04 s + 2.4 s + 0.34 s, +467 MB bare / +558 MB in the app beside the researched +410) - proven end to end under both transports before any DOM is written.**
82
+
83
+ ## Performance
84
+
85
+ - **Duration:** 38 min (started 2026-09-07T00:10:44Z, last task commit `f743d45` at ~00:41Z, verification and A/B to 00:49Z)
86
+ - **Tasks:** 3 (Task 1 TDD: RED + GREEN; Tasks 2-3 feature/test)
87
+ - **Files:** 10 (2 created, 8 modified), +1,110 / -30 lines
88
+ - **Quick loop** (`pytest tests --ignore=tests/e2e -m "not mt"`): **308 passed, 17 deselected, 33.4 s** pytest (was 293 / 14 / 28.8 s after 02-05; budget 40 s). The +15 items and +4.6 s are the 11 quick-loop server-function tests (two turn syntheses, one 50 ms-per-component lock test) plus the 4 new SC-4 walks.
89
+ - **`mt` subset** (`tests/test_server_functions.py -m mt`): 3 passed in 6.6 s; `translate('駅はどこですか。') -> 'Where is the station?'` in **36.9-45.8 ms** server-side, idle.
90
+ - **Parity file** (`tests/e2e/test_facade_parity.py`): **12 passed in 174.9 s** (10 existing + 2 new).
91
+ - **Whole e2e, one invocation** (`pytest tests/e2e/`): **26 passed, 18 skipped (all `deployed`, need `--space-url`), 0 failed, 239.9 s**. No `test_asr_standalone` re-run was needed.
92
+ - **Whole suite, one invocation** (`pytest tests -m "not deployed"`): **366 passed, 1 failed, 18 deselected, 282.9 s** - the failure is the Phase 1 strict-autoplay sanity ceiling under load, A/B'd against the pre-plan commit and found identical (see Deviations / Issues).
93
+
94
+ ### Measured (the plan's `<output>` list)
95
+
96
+ | Fact | Measured | Expected (research / plan) |
97
+ |---|---|---|
98
+ | `system.dic` path | `.venv/Lib/site-packages/sudachidict_core/resources/system.dic` (`importlib.resources.files("sudachidict_core") / "resources" / "system.dic"`) | same layout |
99
+ | `system.dic` bytes | **217,466,039** | ~217 MB |
100
+ | `model.bin` bytes | **77,339,435** | 77,339,435 |
101
+ | compact JMdict bytes / entries | 7,662,785 / **218,672** | 218,672 |
102
+ | warm-up, bare process (`python -c`, fresh) | tokenizer **0.04 s**, jmdict **2.37-2.41 s**, translator **0.34 s**, total **2.75-2.80 s** | 0.03 / 2.0-2.4 / 0.35 (02-05, 02-04) |
103
+ | warm-up, inside `app.py` on `Blocks.load` (headless suite running) | tokenizer 0.06-2.08 s, jmdict **2.60-4.50 s**, translator 0.39-0.48 s, total **4.99-5.17 s** | - |
104
+ | RSS delta, bare process (Windows working set) | **+466.5 / +469 MB** (126 -> 593 MB; 141 -> 609 MB) | **+~410 MB** = 62 mmap + 241 JMdict + 103 CT2 |
105
+ | RSS delta, inside `app.py` | **+558 MB** (137-138 -> 695-696 MB) | the extra ~90 MB is `warm_synthesizer` running concurrently on the same `Blocks.load` and landing inside the window |
106
+ | `languageInfo().rss_mb` after a turn (app process) | 699-700 MB | - |
107
+ | `CPU_CORES` / `MEMORY` | `None` / `None` locally (the Space sets them; 02-10 records them) | - |
108
+ | `translate` under **inline**, in the browser suite | **1,038 ms** wall / 838 ms server | 22-286 ms idle (02-04); headless SwiftShader + two app processes starve the CPU (Phase 1 lesson) |
109
+ | `translate` under **iframe**, in the browser suite | **977 ms** wall / 872 ms server | same |
110
+ | `analyze` in the browser suite | 37.9 ms first call / 9.1 ms (inline); 0.8 ms (iframe) server-side; 96-190 ms wall | 0.14-0.19 ms warm, idle (02-05) - the first call after warm-up pages the mmap in |
111
+ | `analyze_ms` on the `turn` directive | 0.29 ms (inline) / 2.25 ms (iframe); 122 ms on the very first analysis after a fresh warm-up | - |
112
+ | greeting synthesis, headless | 7,973-18,987 ms (`synthesis_ms`) | Phase 1: 10-19 s headless vs 1.6 s headed |
113
+ | pins reported | sudachipy 0.6.11, sudachidict_core 20260723, jmdict 3.6.2+20260831182826, jlpt_vocab 2025.08.01.0, opus_mt_revision 0770961a39ba6bd66305b149c3f4110bcafca2e6 | all read from the environment / data READMEs |
114
+
115
+ The research's +410 MB is exceeded by ~57 MB in a bare process. 02-03 already measured the compact JMdict at +312 MB here (research said 241), which on its own accounts for the difference; the Windows working set also counts touched pages of the 217 MB `system.dic` mmap. The Space's number (`/proc/self/status` VmRSS) is 02-10's to record via `languageInfo()`.
116
+
117
+ ## Accomplishments
118
+
119
+ - **Server side (Task 1).** `analyze` / `translate` / `language_info` accept a dict, a list or a plain string (`_unpack`'s tolerance through a shared `_text_only(payload, what)`), refuse empty / whitespace / `None` / `[]` / >200 chars with `{"error": ...}` (wording `Nothing to analyse.` / `Nothing to translate.` / `Keep it to 200 characters (N given).`), and wrap every exception into `{"error": "analysis failed: ..."}` / `"translation failed: ..."` / `"language_info failed: ..."` with a logged traceback. `translate` echoes `line_id` (dict key or second list element). `turn()` gains `tokens` through `_tokens_for_turn` -> `_analyze_stage` (one `stage("analyze")`); a broken analyzer yields `tokens: []` and the avatar still speaks (`test_turn_speaks_when_analysis_fails`). `build_blocks` registers all five and binds `warm_language_on_load` beside `warm_synthesizer`.
120
+ - **`nlp/warm.py`.** `EXPECTED_WARM` recorded beside every measurement, `_WARM_LOCK` around the `lru_cache`'d body, three substitutable component functions, `_timed()` so a failing component logs, records `None` seconds and an `errors` entry, and never raises (`test_warm_language_survives_a_failing_component`). One INFO line: `language warm-up tokenizer 0.04s jmdict 2.37s translator 0.34s; CPU_CORES=None MEMORY=None rss=593 MB (delta 467 MB, expected +~410)`.
121
+ - **SC-4 carried forward.** `transformers` joins `torch` / `spaces` in the banned imports; the reachability walk starts from each of the four server functions and, as a strengthening, merges every same-named definition (previously `setdefault` kept only the first `analyze` / `translate` it met - the nlp module's, never the server function's). `zerogpu_probe` is unreachable from all four; `warm_language` is reachable from all four (the "every server function waits" property, asserted statically as well as by counter).
122
+ - **Browser side (Task 2).** `analyze(text)` rejects `analyze: text is required` before any bridge call, stamps `analyzeCount` / `lastAnalyzeMs`, returns `{tokens, timings}`; `translate(text, lineId)` returns `{text, lineId, timings, ms}` and stamps `translateCount` / `lastTranslateMs` / `lastTranslation` / `lastTranslateError`; `languageInfo()` caches into `state.languageInfo`. `dispatchTurn` now uses the same `requireBridge` / `checkResult` pair, sets `lastTokens` / `lastTokenCount` from the directive and emits `tokens` on the `turn` event; its `setThinking(true)`-before-`await bridge.` order and `stagePort.unlockAudio()`-first statement are unchanged (both guards green). `avatar.js`, `avatar-iframe.js`, `stage.html`: **0 lines changed** since `16f28bd`.
123
+ - **Parity (Task 3).** Under both transports against live objects: 食べました -> one tappable unit, lemma 食べる, reading たべました, N5, gloss present; 今日はいい天気ですね。 -> surfaces tile the text, 今日 reads きょう, は is not tappable; 駅はどこですか。 -> "Where is the station?" filed under `L1`, `translateCount 1`, `lastTranslateMs > 0`; `languageInfo()` -> 218,672 entries, 77,339,435-byte model, 217,466,039-byte dictionary, `warm.total_s` a number, `warm.errors == []`, the five pins; the `turn` event's `tokens` has the one 食べる unit and `lastTokenCount == 1`; `analyze('')` rejects with `analyze: text is required` and `translate('あ' x 201)` rejects with `Keep it to 200 characters (201 given).` - the server's `{error}` surfacing as a rejection carrying its words, never `undefined`. `test_turn_surface_is_live_under_both_transports` still passes with 15 names (the no-arg probes reject for ordinary reasons; `languageInfo()` simply succeeds).
124
+
125
+ ## Task Commits
126
+
127
+ | Task | Commit | Type |
128
+ |---|---|---|
129
+ | 1 RED - failing server-function, directive and SC-4 tests | `ac630fa` | test |
130
+ | 1 GREEN - warm.py, blocks.py server functions + warm-up, tokens field | `a60327b` | feat |
131
+ | 2 - turn-loop.js / facade.js surface + seam guards | `969caf4` | feat |
132
+ | 3 - language bridge parity under both transports | `f743d45` | test |
133
+
134
+ All commits `--no-verify`, explicit paths only; nothing pushed; no `git add -A`.
135
+
136
+ ## Files Created/Modified
137
+
138
+ - `src/japanese_avatar/nlp/warm.py` (new, 203 lines) - `EXPECTED_WARM`, `_WARM_LOCK`, `rss_mb()` + `_windows_working_set_mb()`, `_warm_tokenizer/_warm_jmdict/_warm_translator`, `_timed`, `_warm_once` (lru_cache), `warm_language`
139
+ - `src/japanese_avatar/ui/blocks.py` - imports; `_text_only`, `_line_id`, `_analyze_stage`, `_tokens_for_turn`, `analyze`, `translate`, `_system_dic_bytes`, `_pin_from_readme`, `_pins` (lru_cache), `language_info`, `warm_language_on_load`; `turn()` analyze stage + `tokens=`; `server_functions=[turn, greeting, analyze, translate, language_info]`; second `blocks.load`
140
+ - `src/japanese_avatar/voice/models.py` - `field` import; `tokens: list[dict] = field(default_factory=list)` as the last field, docstring line
141
+ - `avatar/turn-loop.js` - header paragraph; 9 state keys; `requireBridge` / `checkResult`; `dispatchTurn` refactor + tokens; `analyze` / `translate` / `languageInfo`
142
+ - `avatar/facade.js` - three doc lines, three surface names
143
+ - `tests/test_server_functions.py` (new, 14 tests) - as listed in `provides`
144
+ - `tests/test_directive.py` - `DIRECTIVE_KEYS` += `tokens`
145
+ - `tests/test_no_gpu_on_turn_path.py` - docstring, `BANNED_IMPORT_ROOTS`, `SERVER_ENTRY_POINTS`, `_reachable_from(entry)` with merged definitions, `test_gpu_probe_unreachable_from_every_server_function[4]`
146
+ - `tests/test_transport_seam.py` - `LANGUAGE_SURFACE`, `LANGUAGE_DEBUG_KEYS`, 5 new tests (12 items), parametrisation extended
147
+ - `tests/e2e/test_facade_parity.py` - `EAT` / `WEATHER` / `STATION`, `REJECTION`, `LANGUAGE_DEBUG`, `_unit`, `test_language_bridge_under_both_transports[2]`
148
+
149
+ ## Decisions Made
150
+
151
+ See `key-decisions` in the frontmatter. The two that shape later plans: every server function (and `turn`'s analyze stage) blocks on `warm_language()`, so a request during `Blocks.load` waits for the singletons - correct by construction, and it means a visitor who taps "Say hello" before the load warm-up finishes waits for it (locally the warm-up finished at t = 9.6 s, before the first rendered frame at 10.8 s, so there was no overlap; on the 3-6x slower Space that ordering is 02-10's to measure). And the quick loop stays translator-free by stubbing `blocks.warm_language` in the analyzer-only tests, with the real call sites proven by a counter and the real warm-up proven under `mt` and in the browser.
152
+
153
+ ## Deviations from Plan
154
+
155
+ ### Auto-fixed Issues
156
+
157
+ **1. [Rule 1 - Bug] The Windows RSS fallback silently returned `None`**
158
+ - **Found during:** Task 1 GREEN - the acceptance one-liner printed `ok None` although a ctypes working-set route had been added.
159
+ - **Issue:** `GetCurrentProcess()` returns the pseudo-handle `(HANDLE)-1`; without a declared `restype` ctypes truncates it to a 32-bit `int`, and `GetProcessMemoryInfo` fails with `[WinError 6] The handle is invalid`, which the "measurement only" `except` turned into `None`.
160
+ - **Fix:** declared `GetCurrentProcess.restype = HANDLE` and `GetProcessMemoryInfo.argtypes/restype`; the bare-process delta is now a number (+467 MB) instead of "unreadable".
161
+ - **Files modified:** `src/japanese_avatar/nlp/warm.py`
162
+ - **Committed in:** `a60327b`
163
+
164
+ **2. [Rule 2 - Correctness of the guard] SC-4's call graph kept only the FIRST definition per name**
165
+ - **Found during:** Task 1, reading `_reachable_from_turn` before parametrising it.
166
+ - **Issue:** `functions.setdefault(node.name, node)` over `sorted(SRC.rglob("*.py"))` binds `analyze` to `nlp/analyzer.py`'s and `translate` to `nlp/translate.py`'s definition, so a walk "from `analyze`" would never have seen the server function's calls (`warm_language`, `_analyze_stage`, ...). The plan says "extend, do not weaken"; this would have been a silent weakening.
167
+ - **Fix:** `functions: dict[str, list[FunctionDef]]`; the walk unions the calls of every definition of a name. Still an over-approximation (the safe direction).
168
+ - **Files modified:** `tests/test_no_gpu_on_turn_path.py`
169
+ - **Committed in:** `ac630fa`
170
+
171
+ **3. [Rule 3 - Blocking] The plan's single `callBridge(name, payload)` helper conflicts with two existing seam guards**
172
+ - **Found during:** Task 2 design.
173
+ - **Issue:** `test_thinking_engages_before_the_first_await` asserts `"await bridge." in body[await_at]` for dispatchTurn's first await, and the plan's own new `test_bridge_calls_take_one_payload` greps the literal `bridge.analyze({` / `bridge.translate({` / `bridge.language_info(`. A generic `bridge[name](payload)` helper satisfies neither.
174
+ - **Fix:** two helpers - `requireBridge(name, without)` (get + throw `no host bridge: ...`) and `checkResult(result, what)` (`undefined` / `{error}`) - used by dispatchTurn and the three new methods; every call site keeps its literal one-payload call. Same behaviour, same messages (`the host returned nothing for this turn - see its log` preserved verbatim).
175
+ - **Files modified:** `avatar/turn-loop.js`
176
+ - **Committed in:** `969caf4`
177
+
178
+ ### Scope notes (not deviations)
179
+
180
+ - `tests/test_server_functions.py` has 14 tests where the plan enumerated 10: added `test_language_info_never_raises`, `test_warm_language_survives_a_failing_component`, `test_rss_mb_is_a_number_or_none`, `test_turn_speaks_when_analysis_fails` and the plan's acceptance-listed `test_server_functions_wait_for_warm_language`. Each pins a rule the plan states in prose (never raise; startup must not take the page down; the turn must still speak).
181
+ - `warm_language()`'s dict carries an `errors: list[str]` key beyond the plan's list (empty on success) so a failed component is visible in `languageInfo()` rather than only in a log the browser cannot see. `test_language_bridge_under_both_transports` asserts it is `[]`.
182
+ - The quick-loop `analyze` tests stub `blocks.warm_language` (see Decisions) - the plan's `test_analyze_accepts_the_bridge_payload_shapes` would otherwise have loaded the translator into the quick loop, which the `mt` marker exists to prevent.
183
+
184
+ ---
185
+
186
+ **Total deviations:** 3 auto-fixed (1 Rule 1, 1 Rule 2, 1 Rule 3). No architectural change; no scope creep; nothing pushed; `state` commands not run before this SUMMARY; requirements not marked.
187
+
188
+ ## Issues Encountered
189
+
190
+ - **One failure in the whole-suite run, investigated to a conclusion rather than re-run to green.** `pytest tests -m "not deployed"` (366 passed, 1 failed): `test_gesture_unlocks_audio_under_both_transports[inline]`, `assert 11457.0 <= 10000` on `running_after_click_ms` - the strict-autoplay layer's *absolute sanity ceiling* (its comment: 0.7 s idle, "up to ~3.5 s with two Gradio apps synthesising under SwiftShader"). The same test passed in the e2e-only run (26 passed) and in two isolated re-runs (8,288 ms; 6,084 ms). Because this plan adds a post-load CPU burst to the app process (JMdict parse + translator load) and a lock wait to `turn()`, it could plausibly have been the cause, so two measurements were taken instead of assuming: (a) a stamped probe of a fresh app + headless page showed the language warm-up finishing at t = 9.62 s, *before* the first frame (10.78 s) and the tap, tap-to-running 1.53 s, and the 20.0 s response entirely `synthesis_ms` = 18,987 ms (Phase 1's headless 10-19 s range) with `analyze_ms` 122 ms; (b) an A/B in a detached worktree at the pre-plan commit `16f28bd`, same venv, same machine, minutes apart: **baseline 6,160 ms inline / 2,976 ms iframe (response 34.9 s / 14.6 s); HEAD 6,084 ms / 4,228 ms (16.6 s / 21.6 s)**. Identical profile with and without this plan's code: the machine is 2-3x slower today than when the ceiling was written (RAM 31.4 GiB, 10.1 GiB free at 67 % load, 16 logical CPUs). Recorded in `deferred-items.md` for re-measurement; the ceiling was not touched (Phase 1's test, out of scope). The relative, load-bearing assertion (`running` observed before the server answered) held in every run.
191
+ - A Git Bash heredoc that carried backticks (the `release tag \`...\`` regex) failed to parse; the `blocks.py` and JS patches were applied from scratchpad Python scripts instead.
192
+ - `git` printed its usual CRLF-will-be-replaced warning for the appended `test_facade_parity.py`; the committed blob is LF (`core.autocrlf=input`), as in 02-04/02-05.
193
+ - `ruff format` reflowed the seam test's `LANGUAGE_DEBUG_KEYS` list and one long return in the parity test; `pytest -q` over `addopts="-q"` hides counts, so counts here were taken with `-o addopts="--strict-markers"`.
194
+ - `psutil` is not installed in the venv (deliberately: not a dependency); the Windows route made the local measurement possible without adding one. On the Space `/proc/self/status` is the route.
195
+
196
+ ## Known Stubs
197
+
198
+ None. `tokens` on the directive is the analyzer's real output (or `[]` only after a logged analysis failure, which the parity suite shows does not happen); `languageInfo()` reports measured sizes, a measured warm-up and pins read from the environment; `cpu_cores` / `memory` are `None` locally because the container variables are the Space's, not placeholders - 02-10 records them from the deployed `languageInfo()`.
199
+
200
+ ## User Setup Required
201
+
202
+ None. Nothing pushed to the `space` remote; no external service touched.
203
+
204
+ ## Next Phase Readiness
205
+
206
+ - **02-07 (furigana) / 02-08 (popover):** the `turn` event now carries `tokens` (same records as `analyze()`), `state.lastTokens` / `lastTokenCount` are on `getDebug()`, and `window.Avatar.analyze(text)` tokenises the learner's own lines (`{tokens, timings}`; rejects `analyze: text is required` on empty). Glosses ride inside each token - no lookup round trip. `host.js` does not yet call any of the three methods; the seam test's host-call assertion (`test_host_glue_is_neither_a_transport_nor_the_turn_loop`) deliberately does not require it until those plans wire it.
207
+ - **02-09 (translation reveal):** `window.Avatar.translate(text, lineId)` -> `{text, lineId, timings, ms}`; rejects with the server's `{error}` words (`Keep it to 200 characters (N given).`) and sets `lastTranslateError`; `translateCount` / `lastTranslateMs` / `lastTranslation` are on `getDebug()`. The per-line cache is the host page's (D-17); the loop holds none. Local wall time in the browser suite was ~1.0 s under SwiftShader load vs 37-46 ms idle - design the loading state for seconds, as research says.
208
+ - **02-10 (deployed):** `languageInfo()` right after `ready` blocks on the load warm-up and returns `warm` (seconds per component, `rss_before_mb` / `rss_mb` / `rss_delta_mb` from `/proc`, `expected_rss_delta_mb: 410`, `errors`), `cpu_cores` / `memory` from the container, `rss_mb` now, sizes and pins - record them in `docs/HOSTING.md § Phase 2 container record` and `docs/LATENCY.md § Translation`. **Measure the first "Say hello" tapped right after `ready`:** `turn()` waits on `warm_language()` before its analyze stage, so on the 3-6x slower Space a fast first tap may wait for the JMdict load (locally the warm-up finished 1.2 s before the first frame; the Space's ordering is unknown). If it bites, the levers are ordering `warm_language_on_load` before `warm_synthesizer`, or a non-blocking first analysis (tokens `[]` on the very first line) - a product call, not this plan's.
209
+ - **02-11 (docs):** the warm-up log line format and the `languageInfo()` shape are in `nlp/warm.py` / `blocks.language_info` docstrings; the RSS expectation table is `EXPECTED_WARM`.
210
+ - **Quick-loop budget:** 33.4 s of 40 s; this plan added ~4.6 s. Anything new that needs a turn should reuse `test_server_functions.py`'s stubbed-synthesis pattern rather than synthesise again.
211
+
212
+ ---
213
+ *Phase: 02-japanese-language-core*
214
+ *Completed: 2026-09-07*
215
+
216
+ ## Self-Check: PASSED
217
+
218
+ All 2 created files (`nlp/warm.py`, `tests/test_server_functions.py`) and 8 modified files present on disk; commits `ac630fa`, `a60327b`, `969caf4`, `f743d45` present in `git log`; `warm.py` is 203 lines; `tests/test_server_functions.py` holds 14 `def test_` (3 `mt`); `AVATAR_SURFACE` has 15 names; `avatar.js` / `avatar-iframe.js` / `stage.html` differ from `16f28bd` by 0 lines; quick loop 308 passed / 17 deselected / 33.4 s; parity file 12 passed; whole e2e 26 passed / 18 skipped / 0 failed; repo-wide `ruff check . && ruff format --check .` clean (55 files). The SUMMARY's test count was corrected from an initial over-claim of 17 to the measured 14 during this check. Nothing pushed; `requirements mark-complete` not run (JPN-01 / JPN-04 are 02-10's rows).
.planning/phases/02-japanese-language-core/deferred-items.md ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deferred items - Phase 02
2
+
3
+ Out-of-scope discoveries logged during execution, not fixed in the plan that found them.
4
+
5
+ ## Logged by 02-06 (2026-09-07)
6
+
7
+ - **`tests/e2e/conftest.py::UNLOCK_BEFORE_RESPONSE_MS = 10_000` no longer clears this machine under
8
+ full-suite load.** The strict-autoplay layer's *absolute* tap-to-`running` sanity ceiling (Phase 1,
9
+ plan 01-11) was written when the number measured 0.7 s idle and "up to ~3.5 s with two Gradio apps
10
+ synthesising under SwiftShader". On 2026-09-07 the pre-plan commit `16f28bd` (a detached worktree,
11
+ same venv, same browser) reads **6,160 ms inline / 2,976 ms iframe** in isolation, HEAD reads
12
+ 6,084 / 4,228 ms, and the whole-suite run hit 11,457 ms on inline once. The relative, load-bearing
13
+ assertion (`running` before the server's `turn` answer) held every time; the ceiling is a
14
+ machine-speed constant, not a product property. Options: re-measure and raise the ceiling with the
15
+ new measurement recorded beside it, or make the ceiling a multiple of the observed
16
+ `response_after_click_ms`. Owner of the test is Phase 1's strict-autoplay proof, so it was not
17
+ touched from 02-06.
18
+ - **First-turn wait behind the language warm-up on the Space.** `turn()` (and `analyze` /
19
+ `translate` / `language_info`) block on `warm_language()`; locally the load warm-up finishes 1.2 s
20
+ before the first rendered frame, so a tap cannot overlap it, but the Space's CPU is 3-6x slower
21
+ and its warm-up ordering against the browser's VRM boot is unmeasured. 02-10's deployed run should
22
+ time a "Say hello" tapped immediately after `ready` and record `languageInfo().warm`. Levers if it
23
+ bites: bind `warm_language_on_load` before `warm_synthesizer`, or let the very first analysis be
24
+ non-blocking (tokens `[]` on the first line).