WolfDavid commited on
Commit
bdeb461
·
1 Parent(s): af23443

test(02-07): rehearsal script; furigana at three layers, and a per-thread Sudachi tokenizer

Browse files

- scripts/rehearse_deployed.py: free port, DISABLE_GPU=1 app.py, poll /config,
run the given pytest selection with --space-url, stop the app, exit with
pytest's code (3 with the app's last log lines when it never came up)
- test_facade_parity.py::test_ruby_under_both_transports[inline|iframe]: a real
typed turn puts ruby on the learner's line and the avatar's under both
transports, gated against the Python oracle over the page's own tokens
- test_avatar_loop.py: deployed rows test_furigana_rendered,
test_language_assets_loaded, test_furigana_modes (both preferences across a
reload), plus transcript_dump/wait_for_ruby so a missing rt reports why
- nlp/tokenizer.py: the Tokenizer is now per THREAD over one shared Dictionary.
A shared sudachipy.Tokenizer raises RuntimeError: Already borrowed under
concurrency (5 of 8 threads measured), which this plan made reachable - the
host analyses the learner's line while the turn's analyse stage runs, and the
lost analysis shipped a directive with tokens: [] and an unannotated line
- tests/test_analyzer.py::test_analyze_is_thread_safe: 32 concurrent analyses
all succeed and agree; mutation-tested against the shared-tokenizer revision

scripts/rehearse_deployed.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rehearse the deployed suite against a LOCAL app.py, on a free port, with one command.
2
+
3
+ .venv/Scripts/python.exe scripts/rehearse_deployed.py tests/e2e/test_avatar_loop.py -q
4
+ .venv/Scripts/python.exe scripts/rehearse_deployed.py tests/e2e/test_avatar_loop.py \
5
+ -q -k "furigana or language_assets"
6
+
7
+ The deployed rows (``pytestmark = pytest.mark.deployed``) skip unless pytest is given
8
+ ``--space-url``. Until the owner pushes, the only honest way to run them is against a real
9
+ app.py serving the working tree - and "start the app by hand on 7860, remember to export
10
+ DISABLE_GPU, remember to kill it" is exactly the ritual that produces a stale process, a
11
+ port clash with the parity suite's own apps, or a rehearsal that silently ran against
12
+ yesterday's build. So it is a script, and every later plan in this phase reuses it.
13
+
14
+ What it guarantees:
15
+
16
+ * a FREE port, taken by binding ``('127.0.0.1', 0)`` and reading the number back, so two
17
+ rehearsals (or a rehearsal and the parity suite) never collide. Nothing is hard-coded.
18
+ * ``DISABLE_GPU=1`` - the Phase 1 kill switch. Locally there is no ZeroGPU allocator, and
19
+ a rehearsal must never reach ``@spaces.GPU``.
20
+ * the app is up before pytest starts: ``/config`` is polled until it answers 200, which on
21
+ a cold working tree means the Sudachi dictionary, the compact JMdict and the CTranslate2
22
+ translator have all been read (``Blocks.load`` warms them) - a wait measured in tens of
23
+ seconds, not a sleep.
24
+ * the app is stopped afterwards, whatever pytest did, and its tree is killed if it ignores
25
+ the polite request. A rehearsal leaves no listener behind.
26
+
27
+ Exit code: pytest's own, so this is drop-in for CI - except 3, which means the app never
28
+ came up; its last log lines are printed so the reason is on screen rather than in a pipe.
29
+
30
+ The URL handed to pytest is a loopback one, which keeps ``docs/LATENCY.md``'s rule in
31
+ force: the latency harness writes to tmp_path for a non-Space URL, so a rehearsal can
32
+ never overwrite the Space's recorded numbers.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import collections
38
+ import os
39
+ import socket
40
+ import subprocess
41
+ import sys
42
+ import threading
43
+ import time
44
+ import urllib.error
45
+ import urllib.request
46
+ from pathlib import Path
47
+
48
+ REPO_ROOT = Path(__file__).resolve().parent.parent
49
+
50
+ # The app has to import Gradio, build the Blocks, and warm the tokenizer, the compact
51
+ # JMdict and the translator before /config answers. Measured ~30-60 s on this fleet from a
52
+ # warm page cache; 180 s is the headroom a cold one needs.
53
+ BOOT_TIMEOUT_S = 180
54
+ POLL_S = 0.5
55
+ STOP_GRACE_S = 15
56
+ LOG_TAIL_LINES = 40
57
+ APP_NEVER_CAME_UP = 3
58
+
59
+
60
+ def free_port() -> int:
61
+ """A port the OS says is free right now. Bind to 0, read it back, release it."""
62
+ with socket.socket() as probe:
63
+ probe.bind(("127.0.0.1", 0))
64
+ return int(probe.getsockname()[1])
65
+
66
+
67
+ def _drain(stream, sink: collections.deque) -> None:
68
+ """Read the child's merged output forever, keeping only the tail.
69
+
70
+ A pipe nobody reads fills and blocks the writer: Gradio logs every request, so an app
71
+ left unread would deadlock partway through a long suite. The deque bounds the memory.
72
+ """
73
+ for line in iter(stream.readline, ""):
74
+ sink.append(line.rstrip("\n"))
75
+ stream.close()
76
+
77
+
78
+ def start_app(port: int) -> tuple[subprocess.Popen, collections.deque]:
79
+ env = {
80
+ **os.environ,
81
+ "DISABLE_GPU": "1",
82
+ "GRADIO_SERVER_NAME": "127.0.0.1",
83
+ "GRADIO_SERVER_PORT": str(port),
84
+ "GRADIO_ANALYTICS_ENABLED": "False",
85
+ "PYTHONIOENCODING": "utf-8",
86
+ }
87
+ proc = subprocess.Popen( # noqa: S603 - sys.executable and this repo's own app.py
88
+ [sys.executable, "app.py"],
89
+ cwd=str(REPO_ROOT),
90
+ env=env,
91
+ stdout=subprocess.PIPE,
92
+ stderr=subprocess.STDOUT,
93
+ text=True,
94
+ encoding="utf-8",
95
+ errors="replace",
96
+ bufsize=1,
97
+ )
98
+ log: collections.deque = collections.deque(maxlen=400)
99
+ threading.Thread(target=_drain, args=(proc.stdout, log), daemon=True).start()
100
+ return proc, log
101
+
102
+
103
+ def wait_for_config(port: int, proc: subprocess.Popen) -> float:
104
+ """Block until GET /config answers 200. Returns the seconds it took.
105
+
106
+ ``/config`` rather than ``/``: Gradio 6 serves an SSR shell for the root before the app
107
+ is fully assembled, while /config is the app's own description.
108
+ """
109
+ url = f"http://127.0.0.1:{port}/config"
110
+ deadline = time.monotonic() + BOOT_TIMEOUT_S
111
+ t0 = time.monotonic()
112
+ while time.monotonic() < deadline:
113
+ if proc.poll() is not None:
114
+ raise RuntimeError(f"app.py exited with {proc.returncode} before serving {url}")
115
+ try:
116
+ with urllib.request.urlopen(url, timeout=5) as response: # noqa: S310 - loopback
117
+ if response.status == 200:
118
+ return time.monotonic() - t0
119
+ except (urllib.error.URLError, TimeoutError, ConnectionError, OSError):
120
+ time.sleep(POLL_S)
121
+ raise RuntimeError(f"app.py did not answer {url} within {BOOT_TIMEOUT_S}s")
122
+
123
+
124
+ def stop_app(proc: subprocess.Popen) -> None:
125
+ """Terminate, then kill the whole tree if it lingers. Gradio's server thread has
126
+ ignored a terminate here before, and a stray listener would poison the next run."""
127
+ if proc.poll() is not None:
128
+ return
129
+ proc.terminate()
130
+ try:
131
+ proc.wait(timeout=STOP_GRACE_S)
132
+ return
133
+ except subprocess.TimeoutExpired:
134
+ pass
135
+ if os.name == "nt":
136
+ subprocess.run( # noqa: S603, S607 - fixed argv, no shell
137
+ ["taskkill", "/F", "/T", "/PID", str(proc.pid)],
138
+ capture_output=True,
139
+ check=False,
140
+ )
141
+ else:
142
+ proc.kill()
143
+ try:
144
+ proc.wait(timeout=STOP_GRACE_S)
145
+ except subprocess.TimeoutExpired:
146
+ print("[rehearse] WARNING: app.py did not die; check for a stray process", flush=True)
147
+
148
+
149
+ def main(argv: list[str]) -> int:
150
+ if not argv:
151
+ print(__doc__)
152
+ return 2
153
+ port = free_port()
154
+ space_url = f"http://127.0.0.1:{port}"
155
+ print(f"[rehearse] starting DISABLE_GPU=1 app.py on {space_url}", flush=True)
156
+ proc, log = start_app(port)
157
+ try:
158
+ try:
159
+ boot_s = wait_for_config(port, proc)
160
+ except RuntimeError as err:
161
+ print(f"[rehearse] {err}", flush=True)
162
+ print(f"[rehearse] last {LOG_TAIL_LINES} log lines from app.py:", flush=True)
163
+ for line in list(log)[-LOG_TAIL_LINES:]:
164
+ print(f" {line}", flush=True)
165
+ return APP_NEVER_CAME_UP
166
+ print(f"[rehearse] app answered /config after {boot_s:.1f}s", flush=True)
167
+ command = [
168
+ sys.executable,
169
+ "-m",
170
+ "pytest",
171
+ *argv,
172
+ "--space-url",
173
+ space_url,
174
+ "-p",
175
+ "no:cacheprovider",
176
+ ]
177
+ print(f"[rehearse] {' '.join(command)}", flush=True)
178
+ result = subprocess.run( # noqa: S603 - argv built here, no shell
179
+ command,
180
+ cwd=str(REPO_ROOT),
181
+ env={**os.environ, "PYTHONIOENCODING": "utf-8"},
182
+ check=False,
183
+ )
184
+ return result.returncode
185
+ finally:
186
+ stop_app(proc)
187
+ print("[rehearse] app stopped", flush=True)
188
+
189
+
190
+ if __name__ == "__main__":
191
+ raise SystemExit(main(sys.argv[1:]))
src/japanese_avatar/nlp/tokenizer.py CHANGED
@@ -2,9 +2,21 @@
2
 
3
  Same shape as ``voice.tts``: nothing is built at import time, ``sudachipy`` is imported inside
4
  the functions, and the dictionary (``sudachidict_core``, memory-mapped, ~0.1-0.2 s to open) is
5
- created once by :func:`get_tokenizer` and shared read-only across sessions. :func:`warmup` lets
6
  app startup pay that cost before the first visitor does.
7
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  :func:`morphemes` is the only place a Sudachi object is touched. It returns plain dicts so that
9
  ``units.build_units`` and everything downstream is testable without the engine and no Sudachi
10
  type ever crosses a function boundary.
@@ -13,6 +25,7 @@ type ever crosses a function boundary.
13
  from __future__ import annotations
14
 
15
  import functools
 
16
  import time
17
  from typing import Any
18
 
@@ -20,17 +33,37 @@ from typing import Any
20
  #: which ``units.build_units`` joins back together.
21
  _SPLIT_MODE = "C"
22
 
 
 
 
23
 
24
  @functools.lru_cache(maxsize=1)
25
- def get_tokenizer() -> Any:
26
- """The process-wide Sudachi tokenizer over the core dictionary. Read-only; safe to share."""
27
  from sudachipy import Dictionary
28
 
29
- return Dictionary(dict="core").create()
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
 
32
  def warmup() -> float:
33
- """Build the tokenizer now and return the seconds it took (0.0 once it is already built)."""
 
 
 
 
 
34
  t0 = time.perf_counter()
35
  get_tokenizer()
36
  return time.perf_counter() - t0
 
2
 
3
  Same shape as ``voice.tts``: nothing is built at import time, ``sudachipy`` is imported inside
4
  the functions, and the dictionary (``sudachidict_core``, memory-mapped, ~0.1-0.2 s to open) is
5
+ created once by :func:`get_dictionary` and shared read-only across sessions. :func:`warmup` lets
6
  app startup pay that cost before the first visitor does.
7
 
8
+ **The Tokenizer is per THREAD, the Dictionary is per process.** A ``sudachipy.Tokenizer`` is a
9
+ PyO3 object holding a mutable borrow of its internals for the duration of a ``tokenize`` call:
10
+ a second thread entering the same instance gets ``RuntimeError: Already borrowed``, not a wrong
11
+ answer. Measured here (8 threads, one shared tokenizer): 5 of 8 calls raised. Plan 02-07 made
12
+ that reachable in production - the host page now analyses the learner's line while the turn's
13
+ own analyse stage runs, so two requests hit the analyzer concurrently on every typed turn, and
14
+ a lost analysis showed up as a directive carrying ``tokens: []`` and a line with no furigana.
15
+
16
+ The Dictionary is the expensive part (the memory-mapped ~110 MB ``system.dic``) and stays
17
+ shared; ``create()`` is cheap, is called at most once per thread, and is serialised by
18
+ ``_BUILD_LOCK`` because it borrows the Dictionary too. Threads that never tokenise pay nothing.
19
+
20
  :func:`morphemes` is the only place a Sudachi object is touched. It returns plain dicts so that
21
  ``units.build_units`` and everything downstream is testable without the engine and no Sudachi
22
  type ever crosses a function boundary.
 
25
  from __future__ import annotations
26
 
27
  import functools
28
+ import threading
29
  import time
30
  from typing import Any
31
 
 
33
  #: which ``units.build_units`` joins back together.
34
  _SPLIT_MODE = "C"
35
 
36
+ _LOCAL = threading.local()
37
+ _BUILD_LOCK = threading.Lock()
38
+
39
 
40
  @functools.lru_cache(maxsize=1)
41
+ def get_dictionary() -> Any:
42
+ """The process-wide memory-mapped core dictionary. Opened once; shared read-only."""
43
  from sudachipy import Dictionary
44
 
45
+ return Dictionary(dict="core")
46
+
47
+
48
+ def get_tokenizer() -> Any:
49
+ """This thread's tokenizer over the shared dictionary. Never share the return value."""
50
+ tokenizer = getattr(_LOCAL, "tokenizer", None)
51
+ if tokenizer is None:
52
+ # create() borrows the Dictionary, so two threads building at once would raise the
53
+ # very error this function exists to prevent. Once per thread; contention is nil.
54
+ with _BUILD_LOCK:
55
+ tokenizer = get_dictionary().create()
56
+ _LOCAL.tokenizer = tokenizer
57
+ return tokenizer
58
 
59
 
60
  def warmup() -> float:
61
+ """Open the dictionary now and return the seconds it took (0.0 once it is already open).
62
+
63
+ Pays the memory-mapping cost, which is what the visitor would otherwise wait for; the
64
+ per-thread ``create()`` that follows is microseconds and cannot be pre-paid for a
65
+ request thread that does not exist yet.
66
+ """
67
  t0 = time.perf_counter()
68
  get_tokenizer()
69
  return time.perf_counter() - t0
tests/e2e/test_avatar_loop.py CHANGED
@@ -26,6 +26,7 @@ from urllib.parse import urlparse
26
 
27
  import pytest
28
  import requests
 
29
 
30
  from japanese_avatar.nlp.levels import LEVEL_RANK, kanji_levels
31
  from japanese_avatar.nlp.ruby import is_kanji
@@ -1202,3 +1203,276 @@ def expected_rt(tokens: list[dict], mode: str, level: str) -> int:
1202
  shown += 1
1203
  break
1204
  return shown
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  import pytest
28
  import requests
29
+ from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
30
 
31
  from japanese_avatar.nlp.levels import LEVEL_RANK, kanji_levels
32
  from japanese_avatar.nlp.ruby import is_kanji
 
1203
  shown += 1
1204
  break
1205
  return shown
1206
+
1207
+
1208
+ STUDY_TEXT = "日本語を勉強しています。"
1209
+ STUDY_READING_FIRST = "にほんご"
1210
+
1211
+ # host.js binds after boot - the shared template imports host.js and transcript.js once the
1212
+ # facade exists - so `ready` is true for a window during which getDebug() carries no
1213
+ # furigana slice at all. Measured on the local rehearsal: at `ready` it reads null. Every
1214
+ # furigana read below waits for this first, so none of these rows can race the bind.
1215
+ HOST_BOUND = "() => !!(window.Avatar && window.Avatar.__debug && window.Avatar.__debug.furigana)"
1216
+ AVATAR_READY = (
1217
+ "() => !!window.Avatar && window.Avatar.__debug && window.Avatar.__debug.ready === true"
1218
+ )
1219
+
1220
+ FURIGANA = """
1221
+ async () => {
1222
+ const d = await window.Avatar.getDebug();
1223
+ return d.furigana ? JSON.parse(JSON.stringify(d.furigana)) : null;
1224
+ }
1225
+ """
1226
+ LAST_TOKENS = "async () => (await window.Avatar.getDebug()).lastTokens"
1227
+ RT_COUNTS = """
1228
+ () => ({
1229
+ you: document.querySelectorAll('#transcript-text .turn-you rt').length,
1230
+ avatar: document.querySelectorAll('#transcript-text .turn-avatar rt').length,
1231
+ total: document.querySelectorAll('#transcript-text rt').length,
1232
+ })
1233
+ """
1234
+ LEARNER_RUBY = "() => document.querySelectorAll('#transcript-text .turn-you rt').length > 0"
1235
+ AVATAR_RUBY = "() => document.querySelectorAll('#transcript-text .turn-avatar rt').length > 0"
1236
+
1237
+ # The learner's line is upgraded to ruby by a second round trip (analyze), issued the
1238
+ # instant the plain echo lands and then racing the turn's own synthesis through one Gradio
1239
+ # queue. The server-side analysis is sub-millisecond (lastAnalyzeMs is printed beside the
1240
+ # wall time, so the difference is visibly queueing); on the public Space the queue is also
1241
+ # where a cold container's warm-up sits. Generous, and RECORDED rather than asserted.
1242
+ LEARNER_RUBY_TIMEOUT_MS = 120_000
1243
+
1244
+
1245
+ def _furigana(page) -> dict:
1246
+ return page.evaluate(FURIGANA)
1247
+
1248
+
1249
+ def _set_reading(page, select_id: str, value: str) -> dict:
1250
+ page.select_option(f"#{select_id}", value)
1251
+ page.wait_for_timeout(150)
1252
+ return _furigana(page)
1253
+
1254
+
1255
+ def transcript_dump(page) -> str:
1256
+ """Everything needed to tell WHY a line has no ruby, gathered on failure only.
1257
+
1258
+ A bare ``wait_for_function`` timeout says "no rt appeared" and nothing else, and the
1259
+ three causes look identical from Python: the line was never added (the turn carried no
1260
+ subtitle), it was added with an empty token list (``turn`` returns ``[]`` rather than
1261
+ losing the utterance when the analyse stage fails), or the rt is there and the selector
1262
+ is wrong. The markup plus the turn event's own token surfaces separate them in one shot.
1263
+
1264
+ Shared with the both-transports layer, which imports it, so a failure at either layer
1265
+ prints the same evidence.
1266
+ """
1267
+ markup = page.evaluate(
1268
+ "() => { const el = document.querySelector('#transcript-text');"
1269
+ " return el ? el.innerHTML : '(no #transcript-text)'; }"
1270
+ )
1271
+ turns = page.evaluate(
1272
+ "() => (window.__events || []).filter((e) => e.event === 'turn').map((e) => ({"
1273
+ " subtitle: e.data && e.data.subtitle,"
1274
+ " tokens: e.data && e.data.tokens ? e.data.tokens.map((t) => t.surface) : null,"
1275
+ " speed: e.data && e.data.speed, timings: e.data && e.data.timings }))"
1276
+ )
1277
+ errors = page.evaluate(
1278
+ "() => (window.__events || []).filter((e) => e.event === 'error').map((e) => e.data)"
1279
+ )
1280
+ return "\n".join(
1281
+ [f"transcript={markup!r}", f"turn events={turns!r}", f"error events={errors!r}"]
1282
+ )
1283
+
1284
+
1285
+ def wait_for_ruby(page, predicate: str, timeout_ms: int, what: str) -> None:
1286
+ """wait_for_function, but a timeout carries the page's own explanation."""
1287
+ try:
1288
+ page.wait_for_function(predicate, timeout=timeout_ms)
1289
+ except PlaywrightTimeoutError:
1290
+ dump = transcript_dump(page)
1291
+ raise AssertionError(f"{what} within {timeout_ms} ms.\n{dump}") from None
1292
+
1293
+
1294
+ def _study_turn(page, events) -> dict:
1295
+ """One typed turn of the study sentence through the host's own path, timed.
1296
+
1297
+ Not ``_text_turn``: the learner's ruby lands on its own schedule, before speech-end,
1298
+ and the point of this row is that BOTH lines get it - so the two waits are separate
1299
+ and both numbers are reported.
1300
+ """
1301
+ before = len(events.named(page, "speech-end"))
1302
+ submitted = time.monotonic()
1303
+ _submit_text(page, STUDY_TEXT)
1304
+ wait_for_ruby(page, LEARNER_RUBY, LEARNER_RUBY_TIMEOUT_MS, "no ruby on the learner's line")
1305
+ learner_ms = round((time.monotonic() - submitted) * 1000)
1306
+ ends = events.wait_for(
1307
+ page, "speech-end", timeout_ms=SPEECH_END_TIMEOUT_MS, at_least=before + 1
1308
+ )
1309
+ wait_for_ruby(page, AVATAR_RUBY, SPEECH_END_TIMEOUT_MS, "no ruby on the avatar's line")
1310
+ return {
1311
+ "learner_ruby_ms": learner_ms,
1312
+ "turn_ms": round((time.monotonic() - submitted) * 1000),
1313
+ "duration": ends[-1]["audioDuration"],
1314
+ "analyze_ms": page.evaluate("async () => (await window.Avatar.getDebug()).lastAnalyzeMs"),
1315
+ }
1316
+
1317
+
1318
+ def _reload_ready(page) -> None:
1319
+ """Reload in place (not a fresh goto) and wait until the host has bound again."""
1320
+ page.reload(timeout=STAGE_ATTACHED_TIMEOUT_MS + 30_000)
1321
+ page.wait_for_selector("#vrm-stage", state="attached", timeout=STAGE_ATTACHED_TIMEOUT_MS)
1322
+ page.wait_for_function(AVATAR_READY, timeout=READY_TIMEOUT_MS)
1323
+ page.wait_for_function(HOST_BOUND, timeout=READY_TIMEOUT_MS)
1324
+
1325
+
1326
+ @pytest.mark.deployed
1327
+ def test_furigana_rendered(page, space_url, warm_space, speech_events, wait_for_avatar_ready):
1328
+ """JPN-02 / D-01 / D-03: on a first visit, with no control touched, every kanji on the
1329
+ avatar's line AND the learner's own line carries its reading as real ``<ruby><rt>``.
1330
+
1331
+ Three layers of number, because a count alone cannot distinguish rendered ruby from
1332
+ ruby a stylesheet has hidden (research Pitfall 4, the T-pose lesson): the rt COUNT from
1333
+ both the DOM and getDebug(), the rendered rt HEIGHT, and the line HEIGHT with the
1334
+ annotation versus without it. If this fails while
1335
+ tests/e2e/test_transcript_standalone.py passes, the fault is provably the host page -
1336
+ the stylesheet did not load, or the boot template did not hand the module over.
1337
+ """
1338
+ speech_events.install(page)
1339
+ timings = wait_for_avatar_ready(page, space_url)
1340
+ page.wait_for_function(HOST_BOUND, timeout=READY_TIMEOUT_MS)
1341
+
1342
+ # D-01: untouched defaults, server-rendered on the first paint.
1343
+ assert page.input_value("#furigana-mode") == "always"
1344
+ assert page.input_value("#level-select") == "N5"
1345
+ seeded = _furigana(page)
1346
+ print(
1347
+ f"\n[deployed] ready {timings['ready_seconds']:.1f}s, first frame "
1348
+ f"{timings['first_frame_seconds']:.1f}s; furigana at bind: {seeded}"
1349
+ )
1350
+ assert seeded["mode"] == "always" and seeded["level"] == "N5"
1351
+ assert seeded["storage"] == "ok", "the Space's page could not use localStorage"
1352
+
1353
+ turn = _study_turn(page, speech_events)
1354
+ counts = page.evaluate(RT_COUNTS)
1355
+ always = _furigana(page)
1356
+ tokens = page.evaluate(LAST_TOKENS)
1357
+ print(
1358
+ f"[deployed] {STUDY_TEXT}: rt {counts}; learner ruby {turn['learner_ruby_ms']} ms after "
1359
+ f"Enter (server analyze {turn['analyze_ms']} ms), turn {turn['turn_ms']} ms, audio "
1360
+ f"{turn['duration']}s; furigana {always}"
1361
+ )
1362
+ assert counts["you"] == 2, "D-03: the learner's own line must carry the readings too"
1363
+ assert counts["avatar"] == 2
1364
+ assert always["lastLineRt"] == 2
1365
+ assert always["lastLineKanjiRuns"] == 2
1366
+ assert always["lastLineRtHeightPx"] > 0, "rt exists but rendered with no height"
1367
+ assert always["rtTotal"] == counts["total"] == 4
1368
+ assert expected_rt(tokens, "always", "N5") == 2, tokens
1369
+ first_rt = page.locator("#transcript-text .turn-avatar rt").first.text_content()
1370
+ assert first_rt == STUDY_READING_FIRST, first_rt
1371
+
1372
+ never = _set_reading(page, "furigana-mode", "never")
1373
+ print(
1374
+ f"[deployed] rendered height: always rt {always['lastLineRtHeightPx']} px on a line of "
1375
+ f"{always['lastLineHeightPx']} px; never {never['lastLineHeightPx']} px"
1376
+ )
1377
+ assert never["rtTotal"] == 0
1378
+ assert always["lastLineHeightPx"] > never["lastLineHeightPx"], (
1379
+ "the annotated line is not taller than the bare one; transcript.css did not reach "
1380
+ f"the Space's page (always {always['lastLineHeightPx']} px vs never "
1381
+ f"{never['lastLineHeightPx']} px)"
1382
+ )
1383
+ _set_reading(page, "furigana-mode", "always")
1384
+
1385
+
1386
+ @pytest.mark.deployed
1387
+ def test_language_assets_loaded(page, space_url, warm_space, wait_for_avatar_ready):
1388
+ """JPN-01 on the Space: the language assets are REAL FILES in the container, not LFS
1389
+ pointers (research Pitfall 7), and the load warm-up ran to completion.
1390
+
1391
+ ``languageInfo()`` blocks on the same lock ``Blocks.load`` warms behind (plan 02-06), so
1392
+ this row cannot race the warm-up on a slow cold Space; it waits for it. A 133-byte LFS
1393
+ pointer where model.bin should be fails HERE, with a byte count, rather than as a
1394
+ mystery at some visitor's first tap on an English gloss.
1395
+ """
1396
+ wait_for_avatar_ready(page, space_url)
1397
+ info = page.evaluate("() => window.Avatar.languageInfo()")
1398
+ assert "error" not in info, info
1399
+ warm = info["warm"]
1400
+ print(
1401
+ f"\n[deployed] languageInfo: jmdict_entries={info['jmdict_entries']} "
1402
+ f"jmdict_bytes={info['jmdict_bytes']} "
1403
+ f"tokenizer_dict_bytes={info['tokenizer_dict_bytes']} "
1404
+ f"mt_model_bytes={info['mt_model_bytes']} rss_mb={info['rss_mb']} "
1405
+ f"cpu_cores={info['cpu_cores']} memory={info['memory']}"
1406
+ )
1407
+ print(
1408
+ f"[deployed] warm-up: total {warm['total_s']}s, rss_delta {warm['rss_delta_mb']} MB vs "
1409
+ f"expected {warm['expected_rss_delta_mb']} MB, errors {warm['errors']}; "
1410
+ f"pins {info['pins']}"
1411
+ )
1412
+ assert info["jmdict_entries"] == 218672
1413
+ assert info["mt_model_bytes"] > 70_000_000, "model.bin is an LFS pointer, not the model"
1414
+ assert info["tokenizer_dict_bytes"] > 100_000_000, "SudachiDict-core is not installed whole"
1415
+ assert isinstance(warm["total_s"], int | float)
1416
+ assert warm["errors"] == []
1417
+ assert info["pins"]["sudachidict_core"] == "20260723"
1418
+
1419
+
1420
+ @pytest.mark.deployed
1421
+ def test_furigana_modes(page, space_url, warm_space, speech_events, wait_for_avatar_ready):
1422
+ """JPN-02 / D-02 / D-13 / D-14: the three-way control gates on the KANJI axis at the
1423
+ learner's level, and BOTH preferences survive a reload.
1424
+
1425
+ The gated counts come from :func:`expected_rt` over the page's OWN tokens and the pinned
1426
+ kanji list, evaluated in Python: 勉 and 強's levels are the data's to say, and hard-coding
1427
+ them would let a wrong record and a wrong gate agree. The reload is the D-14 proof, and
1428
+ it reads the SELECTS as well as the debug slice - a mode that survives in memory but not
1429
+ on the control is a visitor confusion, not a persisted preference.
1430
+ """
1431
+ speech_events.install(page)
1432
+ wait_for_avatar_ready(page, space_url)
1433
+ page.wait_for_function(HOST_BOUND, timeout=READY_TIMEOUT_MS)
1434
+ _study_turn(page, speech_events)
1435
+ tokens = page.evaluate(LAST_TOKENS)
1436
+
1437
+ never = _set_reading(page, "furigana-mode", "never")
1438
+ assert never["rtTotal"] == 0 and never["lastLineRt"] == 0
1439
+ assert page.locator("#transcript-text rt").count() == 0
1440
+
1441
+ above_n5 = _set_reading(page, "furigana-mode", "above")
1442
+ want_n5 = expected_rt(tokens, "above", "N5")
1443
+ assert above_n5["mode"] == "above" and above_n5["level"] == "N5"
1444
+ assert above_n5["lastLineRt"] == want_n5, (above_n5, want_n5)
1445
+
1446
+ above_n2 = _set_reading(page, "level-select", "N2")
1447
+ want_n2 = expected_rt(tokens, "above", "N2")
1448
+ assert above_n2["level"] == "N2"
1449
+ assert above_n2["lastLineRt"] == want_n2, (above_n2, want_n2)
1450
+ assert want_n2 <= want_n5, "a higher level cannot annotate MORE"
1451
+
1452
+ _set_reading(page, "level-select", "N5")
1453
+ back = _set_reading(page, "furigana-mode", "always")
1454
+ print(
1455
+ f"\n[deployed] modes: always rt={back['lastLineRt']}; never rt=0; "
1456
+ f"above@N5 rt={above_n5['lastLineRt']} (oracle {want_n5}); "
1457
+ f"above@N2 rt={above_n2['lastLineRt']} (oracle {want_n2})"
1458
+ )
1459
+ assert back["lastLineRt"] == 2
1460
+
1461
+ # D-14: both preferences, across a reload, on the controls and in the renderer.
1462
+ _set_reading(page, "furigana-mode", "never")
1463
+ _set_reading(page, "level-select", "N3")
1464
+ _reload_ready(page)
1465
+ restored = _furigana(page)
1466
+ selects = {
1467
+ "mode": page.input_value("#furigana-mode"),
1468
+ "level": page.input_value("#level-select"),
1469
+ }
1470
+ print(f"[deployed] after reload: selects {selects}, furigana {restored}")
1471
+ assert selects == {"mode": "never", "level": "N3"}
1472
+ assert restored["mode"] == "never" and restored["level"] == "N3"
1473
+ assert restored["storage"] == "ok"
1474
+
1475
+ # Leave the Space as a first visitor should find it.
1476
+ _set_reading(page, "furigana-mode", "always")
1477
+ _set_reading(page, "level-select", "N5")
1478
+ assert _furigana(page)["mode"] == "always"
tests/e2e/test_facade_parity.py CHANGED
@@ -16,9 +16,12 @@ Marked slow but NOT deployed: they run entirely locally.
16
 
17
  from __future__ import annotations
18
 
 
 
19
  import pytest
20
 
21
  from tests.e2e.conftest import AVATAR_FIRST_FRAME_EXPR, AVATAR_READY_EXPR
 
22
  from tests.e2e.test_stage_standalone import (
23
  ARM_DOWN_MAX,
24
  FIRST_FRAME_TIMEOUT_MS,
@@ -649,3 +652,140 @@ def test_language_bridge_under_both_transports(transport, page, gradio_apps, spe
649
  after = page.evaluate(LANGUAGE_DEBUG)
650
  assert after["translateCount"] == 1, "a refused translate is not a translation"
651
  assert after["lastTranslateError"] is not None and "characters" in after["lastTranslateError"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  from __future__ import annotations
18
 
19
+ import time
20
+
21
  import pytest
22
 
23
  from tests.e2e.conftest import AVATAR_FIRST_FRAME_EXPR, AVATAR_READY_EXPR
24
+ from tests.e2e.test_avatar_loop import expected_rt, wait_for_ruby
25
  from tests.e2e.test_stage_standalone import (
26
  ARM_DOWN_MAX,
27
  FIRST_FRAME_TIMEOUT_MS,
 
652
  after = page.evaluate(LANGUAGE_DEBUG)
653
  assert after["translateCount"] == 1, "a refused translate is not a translation"
654
  assert after["lastTranslateError"] is not None and "characters" in after["lastTranslateError"]
655
+
656
+
657
+ # --------------------------------------------------------------- the rendered ruby (02-07)
658
+ #
659
+ # The standalone layer (tests/e2e/test_transcript_standalone.py) already proved the renderer
660
+ # in a browser with no server. This row proves the HOST: that the boot template hands
661
+ # transcript.js to bindHost under BOTH transports, that the stylesheet the app links is the
662
+ # one being applied, and that a real typed turn - the learner's own path through
663
+ # #text-input, not dispatchTurn - puts ruby on the learner's line as well as the avatar's.
664
+
665
+ STUDY_TEXT = "日本語を勉強しています。"
666
+ STUDY_READING_FIRST = "にほんご"
667
+
668
+ # host.js binds after boot (the template imports host.js and transcript.js once the facade
669
+ # exists), so `ready` is TRUE for a short window during which getDebug() carries no
670
+ # furigana slice. Every furigana read waits for this first - measured, not assumed: at
671
+ # `ready` the slice reads null on this fleet.
672
+ HOST_BOUND = "() => !!(window.Avatar && window.Avatar.__debug && window.Avatar.__debug.furigana)"
673
+
674
+ FURIGANA = """
675
+ async () => {
676
+ const d = await window.Avatar.getDebug();
677
+ return d.furigana ? JSON.parse(JSON.stringify(d.furigana)) : null;
678
+ }
679
+ """
680
+ LAST_TOKENS = "async () => (await window.Avatar.getDebug()).lastTokens"
681
+ RT_COUNTS = """
682
+ () => ({
683
+ you: document.querySelectorAll('#transcript-text .turn-you rt').length,
684
+ avatar: document.querySelectorAll('#transcript-text .turn-avatar rt').length,
685
+ total: document.querySelectorAll('#transcript-text rt').length,
686
+ })
687
+ """
688
+ LEARNER_RUBY = "() => document.querySelectorAll('#transcript-text .turn-you rt').length > 0"
689
+ AVATAR_RUBY = "() => document.querySelectorAll('#transcript-text .turn-avatar rt').length > 0"
690
+
691
+ # The learner's line is upgraded to ruby by a SECOND round trip (analyze), issued the
692
+ # moment the echo lands and racing the turn's own synthesis through one Gradio queue on a
693
+ # machine already running two apps under SwiftShader. The plan budgeted 5 s; the measured
694
+ # first-turn wall time here is 15-20 s (the server-side analysis itself is sub-millisecond
695
+ # - lastAnalyzeMs is printed beside it, so the difference is visibly queueing, not work).
696
+ # The wait is therefore generous and the number is RECORDED rather than asserted; a
697
+ # threshold would be measuring the fleet, not the feature.
698
+ LEARNER_RUBY_TIMEOUT_MS = 90_000
699
+
700
+
701
+ def _select(page, select_id: str, value: str) -> dict:
702
+ page.select_option(f"#{select_id}", value)
703
+ page.wait_for_timeout(150)
704
+ return page.evaluate(FURIGANA)
705
+
706
+
707
+ @pytest.mark.parametrize("transport", TRANSPORTS)
708
+ def test_ruby_under_both_transports(transport, page, gradio_apps, speech_events):
709
+ """JPN-02 / D-01 / D-02 / D-03 / D-13: the same ruby, from the same numbers, inline and
710
+ in an iframe.
711
+
712
+ Every claim is a number read from ``getDebug().furigana`` AND cross-checked against the
713
+ DOM, because a count alone cannot tell a rendered annotation from a hidden one: the rt
714
+ height and the always-versus-never line height are what make "invisible ruby" fail
715
+ (research Pitfall 4). The gated counts come from :func:`expected_rt` over the page's own
716
+ ``lastTokens`` and the pinned kanji list - 勉 and 強's levels are the data's to say.
717
+ """
718
+ url = gradio_apps(transport)
719
+ speech_events.install(page)
720
+ page.goto(url)
721
+ page.wait_for_function(AVATAR_READY, timeout=BOOT_TIMEOUT_MS)
722
+ page.wait_for_function(FIRST_FRAME, timeout=FIRST_FRAME_TIMEOUT_MS)
723
+ page.wait_for_function(HOST_BOUND, timeout=BOOT_TIMEOUT_MS)
724
+ assert page.evaluate("() => window.Avatar.__debug.transport") == transport
725
+
726
+ # D-01: the first visit is annotated, with nothing touched.
727
+ assert page.input_value("#furigana-mode") == "always"
728
+ assert page.input_value("#level-select") == "N5"
729
+ seeded = page.evaluate(FURIGANA)
730
+ print(f"[{transport}] furigana at bind: {seeded}")
731
+ assert seeded["mode"] == "always" and seeded["level"] == "N5"
732
+ assert seeded["storage"] == "ok", "localStorage was refused on a plain loopback page"
733
+
734
+ # A real typed turn through the host's own path, so the learner line is exercised.
735
+ ends_before = len(speech_events.named(page, "speech-end"))
736
+ submitted = time.monotonic()
737
+ page.fill("#text-input input", STUDY_TEXT)
738
+ page.press("#text-input input", "Enter")
739
+ wait_for_ruby(page, LEARNER_RUBY, LEARNER_RUBY_TIMEOUT_MS, "no ruby on the learner's line")
740
+ learner_ms = round((time.monotonic() - submitted) * 1000)
741
+ speech_events.wait_for(page, "speech-end", timeout_ms=TURN_TIMEOUT_MS, at_least=ends_before + 1)
742
+ wait_for_ruby(page, AVATAR_RUBY, TURN_TIMEOUT_MS, "the avatar's line never got ruby")
743
+
744
+ counts = page.evaluate(RT_COUNTS)
745
+ always = page.evaluate(FURIGANA)
746
+ tokens = page.evaluate(LAST_TOKENS)
747
+ analyze_ms = page.evaluate("async () => (await window.Avatar.getDebug()).lastAnalyzeMs")
748
+ print(
749
+ f"[{transport}] {STUDY_TEXT}: rt {counts}; learner ruby {learner_ms} ms after Enter "
750
+ f"(server analyze {analyze_ms} ms); furigana {always}"
751
+ )
752
+ assert counts["you"] == 2, "D-03: the learner's own line carries the same readings"
753
+ assert counts["avatar"] == 2
754
+ assert always["lastLineRt"] == 2
755
+ assert always["lastLineRtHeightPx"] > 0, "rt exists but rendered with no height"
756
+ assert always["mode"] == "always" and always["level"] == "N5" and always["storage"] == "ok"
757
+ assert always["rtTotal"] == counts["total"] == 4
758
+ assert (
759
+ page.locator("#transcript-text .turn-avatar rt").first.text_content() == STUDY_READING_FIRST
760
+ )
761
+ assert expected_rt(tokens, "always", "N5") == 2, tokens
762
+
763
+ never = _select(page, "furigana-mode", "never")
764
+ assert never["rtTotal"] == 0 and never["lastLineRt"] == 0
765
+ assert page.locator("#transcript-text rt").count() == 0
766
+
767
+ above_n5 = _select(page, "furigana-mode", "above")
768
+ want_n5 = expected_rt(tokens, "above", "N5")
769
+ assert above_n5["lastLineRt"] == want_n5, (above_n5, want_n5)
770
+
771
+ above_n2 = _select(page, "level-select", "N2")
772
+ want_n2 = expected_rt(tokens, "above", "N2")
773
+ assert above_n2["level"] == "N2"
774
+ assert above_n2["lastLineRt"] == want_n2, (above_n2, want_n2)
775
+ assert want_n2 <= want_n5
776
+
777
+ _select(page, "level-select", "N5")
778
+ back = _select(page, "furigana-mode", "always")
779
+ print(
780
+ f"[{transport}] modes: always rt={always['lastLineRt']} h={always['lastLineHeightPx']} "
781
+ f"rtH={always['lastLineRtHeightPx']}; never rt=0 h={never['lastLineHeightPx']}; "
782
+ f"above@N5 rt={above_n5['lastLineRt']} (oracle {want_n5}); above@N2 "
783
+ f"rt={above_n2['lastLineRt']} (oracle {want_n2}); back to always "
784
+ f"h={back['lastLineHeightPx']}"
785
+ )
786
+ assert back["lastLineRt"] == 2 and back["rtTotal"] == 4
787
+ assert back["lastLineHeightPx"] > never["lastLineHeightPx"], (
788
+ "the ruby line is not taller than the bare line: transcript.css did not reach the "
789
+ f"Gradio page (always {back['lastLineHeightPx']} px vs never "
790
+ f"{never['lastLineHeightPx']} px)"
791
+ )
tests/test_analyzer.py CHANGED
@@ -18,6 +18,7 @@ and one compact-JMdict load per session.
18
 
19
  from __future__ import annotations
20
 
 
21
  import functools
22
  import importlib.metadata
23
  import json
@@ -214,6 +215,34 @@ def test_analyze_is_fast(analyzer):
214
  assert mean_ms < 50, f"{mean_ms:.1f} ms per analysis (budget 50 ms; expected < 5 ms)"
215
 
216
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  def test_empty_text(analyzer):
218
  """Nothing in, nothing out; whitespace is a non-tappable unit with no level."""
219
  assert analyzer("") == []
 
18
 
19
  from __future__ import annotations
20
 
21
+ import concurrent.futures
22
  import functools
23
  import importlib.metadata
24
  import json
 
215
  assert mean_ms < 50, f"{mean_ms:.1f} ms per analysis (budget 50 ms; expected < 5 ms)"
216
 
217
 
218
+ def test_analyze_is_thread_safe(analyzer):
219
+ """Plan 02-07: concurrent analyses all succeed and all agree. The Space serves them.
220
+
221
+ ``sudachipy.Tokenizer`` is a PyO3 object that takes a mutable borrow for the duration of
222
+ a ``tokenize`` call, so ONE shared instance answers the first caller and raises
223
+ ``RuntimeError: Already borrowed`` at every thread that arrives while it is busy - a lost
224
+ analysis, not a wrong one. Before ``nlp.tokenizer`` gave each thread its own tokenizer,
225
+ 8 threads on this sentence lost 5 of 8 calls; the deployed symptom was a directive
226
+ carrying ``tokens: []`` and an avatar line rendered with no furigana at all.
227
+
228
+ It became reachable in 02-07 because the host page now analyses the learner's line while
229
+ the turn's own analyse stage runs, so a single typed turn issues two overlapping
230
+ requests - and a busy Space multiplies that by its visitors.
231
+
232
+ A real regression test: reverting tokenizer.get_tokenizer to one shared instance fails
233
+ this, and the failure is the exception, not a flaky count.
234
+ """
235
+ workers = 8
236
+ rounds = 4
237
+ expected = analyzer(LONG_36_MORA)
238
+ with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
239
+ futures = [pool.submit(analyzer, LONG_36_MORA) for _ in range(workers * rounds)]
240
+ results = [f.result() for f in futures]
241
+ assert len(results) == workers * rounds
242
+ for i, units in enumerate(results):
243
+ assert units == expected, f"thread {i} disagreed with the single-threaded analysis"
244
+
245
+
246
  def test_empty_text(analyzer):
247
  """Nothing in, nothing out; whitespace is a non-tappable unit with no level."""
248
  assert analyzer("") == []