File size: 15,766 Bytes
a09270c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bdeb461
b0413c1
 
 
a09270c
b0413c1
a09270c
 
 
b0413c1
 
 
a09270c
 
b0413c1
 
 
 
a09270c
 
 
b0413c1
 
 
 
 
 
 
 
 
 
a09270c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46459e0
 
 
 
 
 
a09270c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b0413c1
a09270c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bdeb461
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a09270c
 
 
 
 
 
 
 
 
b0413c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
"""The ONE canonical ``analyze(text)`` (plan 02-05, PITFALLS § 10) and its token record.

Every later system - furigana (02-07), the lookup popover (02-08), Phase 3's level guard,
Phase 5's vocabulary tracking - consumes the record these tests pin and nothing else. Each
CONTEXT decision is a named test: D-10 (unlisted -> "N1+"), D-11 (particles / auxiliaries /
punctuation are not words), D-12 (proper nouns -> "name"), D-13 (word axis and kanji axis are
separate), plus the committed reading override (人気 -> ひとけ before の) and the known-ambiguous
readings PITFALLS names (今日, 行った x2, 人気 x2).

Facts recorded by the wave-2 summaries and pinned knowingly here (02-03-SUMMARY.md § Known
Edges): なる resolves to the verb 1375610, which is on the N3 list (n5.csv's なる row is the
archaic copula 2138260 - an upstream yomitan-jlpt-vocab quirk); いい resolves to its own JMdict
entry 2820690 (N5), not 良い 1605820.

All tests use the session-scoped ``analyzer`` fixture (tests/conftest.py): one dictionary open
and one compact-JMdict load per session.
"""

from __future__ import annotations

import concurrent.futures
import functools
import importlib.metadata
import json
import time
from pathlib import Path

from japanese_avatar.nlp.analyzer import TOKEN_KEYS

FIXTURES = Path(__file__).parent / "fixtures"
GOLDEN = FIXTURES / "sentences.json"

LONG_36_MORA = "今日はいい天気ですから、公園を散歩してから、買い物に行きました。"

REGENERATE = (
    "regenerate with .venv/Scripts/python.exe tests/fixtures/make_sentences.py "
    "and REVIEW the diff - a difference means SudachiDict, JMdict, the JLPT lists "
    "or the joining rule changed what learners read"
)


@functools.cache
def _golden() -> dict:
    """The fixed sample sentence set (SC-1): ``{"analyzer": pins, "sentences": [...]}``."""
    return json.loads(GOLDEN.read_text(encoding="utf-8"))


def _golden_texts() -> list[str]:
    return [s["text"] for s in _golden()["sentences"]]


def _by_surface(units: list[dict], surface: str) -> dict:
    matches = [u for u in units if u["surface"] == surface]
    assert len(matches) == 1, f"expected one unit {surface!r}, got {[u['surface'] for u in units]}"
    return matches[0]


def test_record_keys_exact(analyzer):
    """Every unit carries exactly TOKEN_KEYS, in order - the contract Phases 3 and 5 read."""
    units = analyzer("日本語を勉強しています。")
    assert units, "a sentence yields units"
    for u in units:
        assert tuple(u.keys()) == TOKEN_KEYS, u
    assert TOKEN_KEYS == (
        "surface",
        "reading",
        "lemma",
        "level_key",
        "pos",
        "tappable",
        "is_name",
        "jlpt",
        "kanji_levels",
        "ruby",
        "jmdict_id",
        "gloss",
        "start",
        "end",
    )


def test_reading_disambiguation(analyzer):
    """The known-ambiguous readings PITFALLS § 10 names: 今日 and the two 行った."""
    kyou = _by_surface(analyzer("今日はいい天気ですね。"), "今日")
    assert kyou["reading"] == "きょう"

    itta = _by_surface(analyzer("昨日、駅に行った。"), "行った")
    assert itta["reading"] == "いった"
    assert itta["lemma"] == "行く"

    okonatta = _by_surface(analyzer("会議を行った。"), "行った")
    assert okonatta["reading"] == "おこなった"
    assert okonatta["lemma"] == "行う"


def test_hitoke_override(analyzer):
    """人気 is ひとけ before の (the committed override) and にんき N3 elsewhere."""
    hitoke = _by_surface(analyzer("人気のない通りを歩いた。"), "人気")
    assert hitoke["reading"] == "ひとけ"
    assert hitoke["ruby"] == [["人気", "ひとけ"]]

    ninki = _by_surface(analyzer("この店は人気がある。"), "人気")
    assert ninki["reading"] == "にんき"
    assert ninki["jlpt"] == "N3"


def test_tappability(analyzer):
    """D-11: は / です / ね / 。 are plain text with no level and no gloss; words are tappable."""
    units = analyzer("今日はいい天気ですね。")
    assert [u["surface"] for u in units] == ["今日", "は", "いい", "天気", "です", "ね", "。"]
    for surface in ("は", "です", "ね", "。"):
        u = _by_surface(units, surface)
        assert u["tappable"] is False, surface
        assert u["jlpt"] is None, surface
        assert u["gloss"] == [], surface
        assert u["jmdict_id"] is None, surface
        assert u["kanji_levels"] == {}, surface
        assert u["ruby"] == [[surface, None]], surface
    for surface in ("今日", "いい", "天気"):
        u = _by_surface(units, surface)
        assert u["tappable"] is True, surface
        assert u["jlpt"] == "N5", surface


def test_level_derivation(analyzer):
    """D-12 name, level from the level_key entry, D-10 N1+ for a word on no list."""
    units = analyzer("田中さんは東京に住んでいます。")
    tanaka = _by_surface(units, "田中さん")
    assert tanaka["is_name"] is True and tanaka["jlpt"] == "name"
    assert tanaka["tappable"] is True, "D-12: names are tappable (the reading is the useful part)"
    tokyo = _by_surface(units, "東京")
    assert tokyo["is_name"] is True and tokyo["jlpt"] == "name"
    sunde = _by_surface(units, "住んでいます")
    assert sunde["lemma"] == "住む" and sunde["jlpt"] == "N5"

    # 話せる has its own, unlisted JMdict entry; its level comes from level_key 話す (N5).
    hanaseru = _by_surface(analyzer("話せるようになりたい"), "話せる")
    assert hanaseru["level_key"] == "話す"
    assert hanaseru["jlpt"] == "N5"
    assert hanaseru["jmdict_id"] == 1562360
    assert any("able to speak" in g for g in hanaseru["gloss"][0]), hanaseru["gloss"]

    # 鬱 is not one of the 2,211 listed kanji: kanji axis None (D-13 / D-02).
    uttoushii = _by_surface(analyzer("鬱陶しい天気だ。"), "鬱陶しい")
    assert uttoushii["kanji_levels"]["鬱"] is None
    assert uttoushii["tappable"] is True

    # D-10: a word on no list is "N1+" - never a guess. The fixture pins 鬱陶しい's observed
    # label (N1 or N1+, whichever the pinned lists say); the D-10 assertion itself uses a rare
    # word that has a JMdict entry and sits on no list, so "N1+" comes from the rule, not from
    # a lookup miss.
    assert uttoushii["jlpt"] in {"N1", "N1+"}
    beyond = _by_surface(analyzer("彼は瀟洒な服を着ている。"), "瀟洒")
    assert beyond["tappable"] is True
    assert beyond["jmdict_id"] is not None, "瀟洒 has a JMdict entry"
    assert beyond["jlpt"] == "N1+", "on no JLPT list -> N1+ (D-10)"


def test_kanji_axis_separate_from_word_axis(analyzer):
    """D-13: the word's level and each kanji's level are two axes in the same record."""
    nihongo = _by_surface(analyzer("日本語を勉強しています。"), "日本語")
    # 日本語 resolves to JMdict 1464530, which is on NONE of the five pinned lists (Waller's
    # lists carry 日本 at N3 and no 日本語 at all) - so the word axis is the honest D-10 "N1+"
    # while every kanji in it is N5. The two axes really are independent; the plan's assumed
    # "N5" was a guess the pinned data contradicts (02-05-SUMMARY.md).
    assert nihongo["jmdict_id"] == 1464530
    assert nihongo["jlpt"] == "N1+"
    assert nihongo["kanji_levels"] == {"日": "N5", "本": "N5", "語": "N5"}

    benkyou = _by_surface(analyzer("日本語を勉強しています。"), "勉強しています")
    assert benkyou["lemma"] == "勉強する"
    assert set(benkyou["kanji_levels"]) == {"勉", "強"}
    assert all(v in {"N5", "N4", "N3", "N2", "N1"} for v in benkyou["kanji_levels"].values())
    # The word is N5 while its kanji are harder - the axes really are independent.
    assert benkyou["jlpt"] == "N5"
    assert benkyou["kanji_levels"]["勉"] != "N5"


def test_gloss_shipped_with_token(analyzer):
    """D-07 / research § Q3: glosses ride inside the token so a tap needs no round trip."""
    units = analyzer("食べました")
    assert len(units) == 1
    u = units[0]
    assert u["lemma"] == "食べる"
    assert isinstance(u["jmdict_id"], int)
    assert u["gloss"], "the token carries its glosses"
    assert any("eat" in g for g in u["gloss"][0]), u["gloss"]
    assert len(u["gloss"]) <= 3 and all(len(sense) <= 3 for sense in u["gloss"])
    assert u["ruby"] == [["食", "た"], ["べました", None]]


def test_offsets_cover_text(analyzer):
    """Units tile every fixture sentence and each offset pair reads back its surface."""
    for text in _golden_texts():
        units = analyzer(text)
        assert "".join(u["surface"] for u in units) == text
        pos = 0
        for u in units:
            assert u["start"] == pos, (text, u)
            assert u["end"] == pos + len(u["surface"]), (text, u)
            assert text[u["start"] : u["end"]] == u["surface"], (text, u)
            pos = u["end"]
        assert pos == len(text)


def test_analyze_is_fast(analyzer):
    """Warm analysis is milliseconds: 100 runs of the 36-mora sentence, mean well under 50 ms."""
    analyzer(LONG_36_MORA)  # warm any lazily built table
    runs = 100
    started = time.perf_counter()
    for _ in range(runs):
        analyzer(LONG_36_MORA)
    mean_ms = (time.perf_counter() - started) * 1000 / runs
    print(f"\nanalyze({len(LONG_36_MORA)} chars): mean {mean_ms:.3f} ms over {runs} runs")
    assert mean_ms < 50, f"{mean_ms:.1f} ms per analysis (budget 50 ms; expected < 5 ms)"


def test_analyze_is_thread_safe(analyzer):
    """Plan 02-07: concurrent analyses all succeed and all agree. The Space serves them.

    ``sudachipy.Tokenizer`` is a PyO3 object that takes a mutable borrow for the duration of
    a ``tokenize`` call, so ONE shared instance answers the first caller and raises
    ``RuntimeError: Already borrowed`` at every thread that arrives while it is busy - a lost
    analysis, not a wrong one. Before ``nlp.tokenizer`` gave each thread its own tokenizer,
    8 threads on this sentence lost 5 of 8 calls; the deployed symptom was a directive
    carrying ``tokens: []`` and an avatar line rendered with no furigana at all.

    It became reachable in 02-07 because the host page now analyses the learner's line while
    the turn's own analyse stage runs, so a single typed turn issues two overlapping
    requests - and a busy Space multiplies that by its visitors.

    A real regression test: reverting tokenizer.get_tokenizer to one shared instance fails
    this, and the failure is the exception, not a flaky count.
    """
    workers = 8
    rounds = 4
    expected = analyzer(LONG_36_MORA)
    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
        futures = [pool.submit(analyzer, LONG_36_MORA) for _ in range(workers * rounds)]
        results = [f.result() for f in futures]
    assert len(results) == workers * rounds
    for i, units in enumerate(results):
        assert units == expected, f"thread {i} disagreed with the single-threaded analysis"


def test_empty_text(analyzer):
    """Nothing in, nothing out; whitespace is a non-tappable unit with no level."""
    assert analyzer("") == []
    units = analyzer("   ")
    assert "".join(u["surface"] for u in units) == "   "
    for u in units:
        assert u["surface"].strip() == ""
        assert u["tappable"] is False and u["jlpt"] is None and u["gloss"] == []
        assert tuple(u.keys()) == TOKEN_KEYS


def test_known_list_gaps_are_honest_n1_plus(analyzer):
    """Words the pinned lists simply do not carry badge "N1+" (D-10) - pinned by JMdict id.

    These look wrong to a Japanese teacher and are right by the data: none of the five
    yomitan-jlpt-vocab CSVs lists ない / 無い (1529520) or 日本語 (1464530), and 人気 read ひとけ is
    the separate entry 1367020 (unlisted), not にんき's 1367010 (N3). The lookup resolves each to
    the correct entry; the label follows the lists. If the product later wants a level alias for
    them, that is a new override table and a conscious change to this test, never a weaker lookup.
    """
    nai = _by_surface(analyzer("人気のない通りを歩いた。"), "ない")
    assert nai["tappable"] is True and nai["lemma"] == "ない" and nai["level_key"] == "無い"
    assert nai["jmdict_id"] == 1529520 and nai["jlpt"] == "N1+"
    nakatta = _by_surface(analyzer("きれいじゃなかった"), "なかった")
    assert nakatta["jmdict_id"] == 1529520 and nakatta["jlpt"] == "N1+"
    hitoke = _by_surface(analyzer("人気のない通りを歩いた。"), "人気")
    assert hitoke["jmdict_id"] == 1367020 and hitoke["jlpt"] == "N1+"
    assert any("human presence" in g for g in hitoke["gloss"][0]), hitoke["gloss"]
    ninki = _by_surface(analyzer("この店は人気がある。"), "人気")
    assert ninki["jmdict_id"] == 1367010 and ninki["jlpt"] == "N3"
    # The other side of the same coin: こんにちは IS listed, at N3 (and N1), so it badges N3.
    konnichiwa = analyzer("こんにちは")
    assert len(konnichiwa) == 1
    assert konnichiwa[0]["jmdict_id"] == 1289400 and konnichiwa[0]["jlpt"] == "N3"


# --- the fixed sample sentence set (success criterion 1) ---------------------------------


def test_fixture_sentences(analyzer):
    """The analyzer reproduces every golden unit of every fixture sentence exactly.

    This is JPN-01's quick-loop row (02-VALIDATION.md). A SudachiDict, JMdict or JLPT-list bump,
    or a change to the joining rule, must show up here as a reviewed diff - never as a silent
    change to what learners read.
    """
    golden = _golden()
    assert len(golden["sentences"]) == 28
    for sentence in golden["sentences"]:
        text, expected = sentence["text"], sentence["units"]
        got = analyzer(text)
        assert got == expected, f"{text!r}: analyze() differs from sentences.json - {REGENERATE}"


def test_fixture_set_covers_the_contract():
    """The set holds every case the phase promised to pin (ambiguous readings, joins, edges)."""
    texts = _golden_texts()
    assert len(texts) >= 25
    joined = "\n".join(texts)
    for needle in (
        "今日",
        "食べました",
        "田中さん",
        "勉強し",
        "読んで",
        "待ち合わせ",
        "鬱",
        "美味しくない",
        "きれいじゃなかった",
    ):
        assert needle in joined, needle
    assert sum("行った" in t for t in texts) >= 2, "both readings of 行った"
    assert sum("人気" in t for t in texts) >= 2, "both readings of 人気"
    # The known-ambiguous readings are pinned as READINGS, not just as texts.
    readings = {
        (s["text"], u["surface"]): u["reading"] for s in _golden()["sentences"] for u in s["units"]
    }
    assert readings[("今日はいい天気ですね。", "今日")] == "きょう"
    assert readings[("昨日、駅に行った。", "行った")] == "いった"
    assert readings[("会議を行った。", "行った")] == "おこなった"
    assert readings[("人気のない通りを歩いた。", "人気")] == "ひとけ"
    assert readings[("この店は人気がある。", "人気")] == "にんき"


def test_fixture_pins_match_installed():
    """The golden file was generated by the data this environment has, or it is stale."""
    import sudachipy

    from japanese_avatar.nlp import jmdict

    pins = _golden()["analyzer"]
    assert pins["sudachipy"] == sudachipy.__version__
    assert pins["sudachidict_core"] == importlib.metadata.version("sudachidict_core")
    assert pins["jmdict"] == jmdict.load().meta["version"]
    assert pins["jlpt_vocab"] == "2025.08.01.0"
    assert pins["kanji_data"] == "00fd7079c3890f430759536f91aa5e854ec0ca4f"