File size: 23,431 Bytes
cd8c465
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9086401
 
 
 
 
 
 
 
cd8c465
 
d91ce3e
 
 
 
cd8c465
 
d91ce3e
 
 
 
 
 
 
 
 
 
 
 
 
 
cd8c465
 
0f14cdc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c38bb1a
 
 
 
0f14cdc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cf8974e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d91ce3e
 
 
 
 
 
 
 
689f204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d91ce3e
 
7bac37e
689f204
 
 
 
7bac37e
689f204
7bac37e
689f204
7bac37e
 
b8422e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aad1ed3
 
 
 
 
 
 
 
 
 
 
 
 
ba477a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cd8c465
 
 
 
 
 
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
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
#!/usr/bin/env python3
"""Checks for the deploy path. No GPU, no index, no network.



  python tests/test_deploy.py



Everything here guards a failure that is silent in production: a shard filter

that matches nothing, a language list that never loads, an empty CORS flag that

blocks the browser, or a duplicated language map that drifted.

"""
import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

from deploy.bake import MMS_LANG as BAKE_LANG          # noqa: E402
from deploy.boot import build_cmd                      # noqa: E402
from scripts.build_and_publish import val_shards       # noqa: E402
from src.guardrails import (GuardrailConfig, Guardrails,  # noqa: E402
                            calibrate_topic_threshold)
from src.router import Passage                          # noqa: E402
from src.voice import MMS_LANG as VOICE_LANG           # noqa: E402

REPO = Path(__file__).resolve().parents[1]


def test_val_shards_picks_the_right_languages():
    # The REAL ai4bharat/MSMARCO-XI layout, read off the hub. An earlier version
    # of this test invented "hin_Deva_validation-00000-of-00002.parquet" and
    # passed while the actual build matched nothing -- a fixture is only worth
    # what its resemblance to production is.
    files = [
        ".gitattributes", "README.md", "ms_marco_translations.py",
        "validation/hinval.parquet", "validation/benval.parquet",
        "validation/kanval.parquet", "validation/marval.parquet",
        "validation/tamval.parquet", "validation/urdval.parquet",
        "train/hintrain.parquet", "train/bentrain.parquet",
    ]
    got = val_shards(files, {"hi", "bn", "kn", "mr"})
    assert sorted(got) == ["validation/benval.parquet", "validation/hinval.parquet",
                           "validation/kanval.parquet", "validation/marval.parquet"], got

    # "validation/" contains the substring "val", so filtering on the full path
    # would drag every train shard in with it. The filter must use .name.
    assert not any("train" in f for f in val_shards(files, {"hi"})), "train leaked in"
    assert not any(f.endswith((".md", ".py")) for f in got), got
    assert val_shards(files, {"zz"}) == []


def test_download_lands_where_the_schema_step_looks():
    # 02_inspect_schema.py:find_parquets scans root/hf_cache only. Downloading to
    # root/data made it find zero parquet, exit non-zero, and stop the build
    # before any index existed -- with the failure three steps upstream of the
    # traceback the user actually saw.
    schema = (REPO / "scripts" / "02_inspect_schema.py").read_text(encoding="utf-8")
    build = (REPO / "scripts" / "build_and_publish.py").read_text(encoding="utf-8")
    assert 'root / "hf_cache"' in schema, "the scan root moved; update the download dest"
    assert 'root / "hf_cache" / "MSMARCO-XI"' in build, "download dest is not under hf_cache"


def test_bake_language_map_has_not_drifted():
    # deploy/bake.py duplicates the map because it runs before src/ is copied
    # into the image. If they disagree, the image bakes the wrong voice and the
    # first spoken request downloads 145 MB while a judge watches.
    assert BAKE_LANG == VOICE_LANG, {
        k: (BAKE_LANG.get(k), VOICE_LANG.get(k))
        for k in set(BAKE_LANG) | set(VOICE_LANG)
        if BAKE_LANG.get(k) != VOICE_LANG.get(k)
    }


def test_build_cmd_omits_flags_for_unset_variables():
    base = build_cmd({}, python="py")
    assert "--cors" not in base, base
    assert "--langs" not in base, base
    assert base[-2:] == ["--port", "7860"], base

    full = build_cmd({"PORT": "8010", "VOICERAG_CORS": "https://x.vercel.app",
                      "VOICERAG_PREWARM": "hi,bn", "VOICERAG_LANGS": " hi "},
                     python="py")
    assert "8010" in full, full
    assert full[full.index("--cors") + 1] == "https://x.vercel.app", full
    assert full[full.index("--langs") + 1] == "hi", full   # stripped

    # An empty-string variable is the Space UI's idea of "unset".
    assert "--cors" not in build_cmd({"VOICERAG_CORS": "   "}, python="py")


def test_tau_topic_lets_98_percent_of_real_queries_through():
    scores = [i / 1000 for i in range(1000)]          # 0.000 .. 0.999
    tau = calibrate_topic_threshold(scores, max_false_refusal=0.02)
    refused = sum(1 for s in scores if s < tau)
    assert refused / len(scores) <= 0.02, (tau, refused)
    assert calibrate_topic_threshold([]) == 0.35      # documented placeholder


def test_serve_derives_languages_from_the_manifest():
    # index_build.py writes indices/model/strategies and never a "languages"
    # key. serve.py used to read one, so every run without --langs raised
    # KeyError. Assert the manifest shape and that serve.py no longer asks.
    src = (REPO / "src" / "serve.py").read_text(encoding="utf-8")
    assert '["languages"]' not in src, "serve.py is reading a key that is never written"
    assert 'manifest.get("indices", {})' in src

    manifest = {"model": "BAAI/bge-m3", "strategies": ["FW", "FCC"],
                "default_strategy": "FW", "indices": {
                    "hi__FW": {"lang": "hi", "strategy": "FW"},
                    "hi__FCC": {"lang": "hi", "strategy": "FCC"},
                    "bn__FW": {"lang": "bn", "strategy": "FW"}}}
    langs = sorted({v["lang"] for v in manifest.get("indices", {}).values()})
    assert langs == ["bn", "hi"], langs
    assert json.loads(json.dumps(manifest))["default_strategy"] == "FW"


# --------------------------------------------------------------- gate 3


def _passages(scores):
    return [Passage(f"c{i}", f"text {i}", s, "kn") for i, s in enumerate(scores)]


def _write_cal(tmp, **kw):
    d = {"topic_signal": "spread", "tau_topic": 0.04, "gate_depth": 20, "n": 400}
    d.update(kw)
    tmp.write_text(json.dumps(d))
    return tmp


def test_tau_topic_cannot_return_the_other_signals_scale():
    # Bug 11: `spread` shipped paired with 0.35 -- a top-1-scale value about ten
    # times any spread observed -- and the gate abstained on all 300 benchmark
    # queries while reporting a fast pipeline that never ran the reader.
    top1 = GuardrailConfig()
    assert top1.topic_signal == "top1" and top1.tau_topic == top1.tau_top1

    spread = GuardrailConfig(topic_signal="spread")
    assert spread.tau_topic == spread.tau_spread
    assert spread.tau_topic < 0.25, "a spread tau on a top-1 scale is bug 11"


def test_load_refuses_a_threshold_written_on_the_wrong_scale():
    import tempfile
    d = Path(tempfile.mkdtemp())

    ok = _write_cal(d / "ok.json", topic_signal="spread", tau_topic=0.0393)
    cfg = GuardrailConfig.load(ok)
    assert cfg.topic_signal == "spread" and cfg.tau_topic == 0.0393
    assert cfg.topic_calibrated and cfg.gate_depth == 20

    for bad, why in [
        (_write_cal(d / "b1.json", topic_signal="spread", tau_topic=0.35),
         "spread with a top-1 tau"),
        (_write_cal(d / "b2.json", topic_signal="top1", tau_topic=0.04),
         "top1 with a spread tau"),
        (_write_cal(d / "b3.json", topic_signal="nonsense", tau_topic=0.3),
         "unknown signal"),
    ]:
        try:
            GuardrailConfig.load(bad)
        except ValueError:
            continue
        raise AssertionError(f"load() accepted {why}")

    # A missing file is a placeholder, not a crash.
    assert GuardrailConfig.load(d / "nope.json").topic_calibrated is False


def test_spread_reads_shape_not_height():
    cfg = GuardrailConfig(topic_signal="spread", tau_spread=0.05, gate_depth=20)
    g = Guardrails(cfg)

    # A peaked neighbourhood: high rank-1, long flat tail. Answerable.
    peaked = _passages([0.72] + [0.40] * 19)
    v, detail = g.topic_value(peaked)
    assert abs(v - 0.32) < 1e-6, v
    assert g.check_topical(peaked).action.name == "ALLOW"

    # A hub: rank-1 just as high, but everything else is nearly as close. This
    # is the case top-1 cannot see -- both have top_score 0.72.
    hub = _passages([0.72] + [0.70] * 19)
    v2, _ = g.topic_value(hub)
    assert abs(v2 - 0.02) < 1e-6, v2
    assert g.check_topical(hub).action.name == "ABSTAIN"

    top1 = Guardrails(GuardrailConfig(topic_signal="top1", tau_top1=0.35))
    assert top1.check_topical(hub).action.name == "ALLOW",         "top-1 allows the hub; that is exactly why spread was measured"


def test_short_depth_is_reported_not_hidden():
    g = Guardrails(GuardrailConfig(topic_signal="spread", gate_depth=20))
    _, detail = g.topic_value(_passages([0.7, 0.6, 0.5]))
    assert detail["depth"] == 3
    assert "short_depth" in detail, "a shape from 3 points must say so"


def test_gate_3_retrieves_the_depth_it_was_calibrated_on():
    # "A shape read from 5 points is not the shape the threshold was calibrated
    # on." The harness must retrieve gate_depth and slice k for the reader.
    h = (REPO / "src" / "harness.py").read_text(encoding="utf-8")
    assert "max(req.k, self.guards.cfg.gate_depth)" in h
    assert 'ctx["gate_passages"] = deep' in h,         "the gate must see the raw dense set, in its original order"
    # The reader's k may be reordered by the lexical rerank, but it must still
    # be derived from `deep` and it must not write back into gate_passages --
    # tau was calibrated on the shape of an untouched dense neighbourhood.
    assert 'ctx["passages"] = _lexical_rerank(ctx["query"], deep, req.k)' in h
    r = h[h.index("def _lexical_rerank"):h.index("class Harness")]
    body = r.split('"""')[2]          # skip the docstring; it names both on purpose
    assert ".score =" not in body and "gate_passages" not in body,         "the rerank must not mutate scores or touch the gate's set"


def test_describe_quotes_the_measured_verbatim_rate():
    """Bug 13 twice over. 11.1% is the English *absent* rate, and the 8.2% the

    handoff offered as its correction reconciles with nothing in the committed

    JSON. Assert /guardrails against the evidence file, not against either."""
    d = Guardrails(GuardrailConfig()).describe()
    cost = d["hallucination"]["cost"]

    ext = json.loads((REPO / "results" / "extractability_translated.json")
                     .read_text(encoding="utf-8"))
    by = ext["by_level"]
    verbatim = 100 * by["exact"] / sum(by.values())
    assert abs(verbatim - 9.2) < 0.1, verbatim
    assert f"{verbatim:.1f}%" in cost, (verbatim, cost)

    eng = json.loads((REPO / "results" / "extractability_english.json")
                     .read_text(encoding="utf-8"))
    eby = eng["by_level"]
    assert f"{100 * eby['exact'] / sum(eby.values()):.1f}%" in cost
    assert "11.1% appear verbatim" not in cost
    assert "8.2%" not in cost, "the unreconciled handoff figure is back"


# ----------------------------------------------------------- space card


def test_space_card_matches_the_entrypoint_that_exists():
    # Docker SDK is PRO-only, so this Space runs the Gradio base image and
    # executes app_file. A card naming a file that is not there fails at build
    # time with a message about Gradio, not about the missing file.
    card = (REPO / "README.md").read_text(encoding="utf-8")
    assert card.startswith("---"), "HF reads the card from the first bytes"
    front = card.split("---", 2)[1]
    assert "sdk: gradio" in front, front
    assert "app_file: app.py" in front, front
    assert "app_port" not in front, "app_port is Docker-only and is ignored here"
    assert (REPO / "app.py").exists()

    # The Gradio SDK installs the ROOT requirements.txt, not env/.
    reqs = (REPO / "requirements.txt").read_text(encoding="utf-8")
    pinned = [ln.strip() for ln in reqs.splitlines()
              if ln.strip() and not ln.lstrip().startswith("#")]
    # ZeroGPU rejects anything outside this set, and matches the exact
    # version string -- so a +cpu local version is rejected too.
    assert any(ln == f"torch=={v}" for ln in pinned
               for v in ("2.8.0", "2.9.1", "2.10.0", "2.11.0")), pinned
    assert not any(ln.startswith("gradio") for ln in pinned),         "gradio comes from the base image; pinning it fights sdk_version"


def test_both_entrypoints_agree_on_serve_flags():
    # app.py slices build_cmd()[2:] to drop the interpreter and the script path,
    # then feeds the rest to serve.py's own parser. If those two ever disagree
    # the Space starts with the wrong configuration rather than failing.
    import src.serve as serve

    env = {"PORT": "7860", "VOICERAG_CORS": "https://x.vercel.app",
           "VOICERAG_PREWARM": "hi,bn,kn,mr", "VOICERAG_LANGS": "hi,bn"}
    cmd = build_cmd(env)
    assert cmd[1] == "src/serve.py", cmd

    args = serve.parser().parse_args(cmd[2:])
    assert args.port == 7860
    assert args.host == "0.0.0.0"
    assert args.cors == "https://x.vercel.app"
    assert args.prewarm_tts == "hi,bn,kn,mr"
    assert args.langs == "hi,bn"

    # And the defaults path, which is what a bare Space actually runs.
    bare = serve.parser().parse_args(build_cmd({})[2:])
    assert bare.port == 7860 and bare.cors == "" and bare.prewarm_tts == ""

    app_src = (REPO / "app.py").read_text(encoding="utf-8")
    assert "build_cmd({**os.environ, \"PORT\": str(PORT)})[2:]" in app_src


def test_serve_can_build_the_app_without_binding_a_port():
    # The whole Gradio adaptation rests on this split: bootstrap() does
    # everything main() does except bind, so a host that owns the port reuses it.
    import src.serve as serve

    assert callable(serve.bootstrap) and callable(serve.parser)
    assert callable(serve.main) and callable(serve.build_app)
    src_txt = (REPO / "src" / "serve.py").read_text(encoding="utf-8")
    assert "uvicorn.run" not in src_txt.split("def main()")[0],         "bootstrap must not bind a port"


def test_chunk_meta_is_json_serialisable():
    """The build embeds first and serialises second, so a bound method in the

    meta dict costs the whole GPU run before it surfaces. Chunk.chunk_id is a

    method while Chunk.n_words beside it is a property, which is the trap."""
    from src.chunkers.base import Document
    from src.chunkers.strategies import build as build_chunker

    doc = Document.from_blocks(
        "q1", "kn", ["a corporation is a legal entity " * 12,
                     "shareholders elect a board " * 12], ["q1:0", "q1:1"])
    chunks = build_chunker("FW").chunk(doc)
    assert chunks, "fixture produced no chunks"

    meta = [{"chunk_id": c.chunk_id(), "doc_id": doc.doc_id,
             "block_ids": list(c.block_ids or [])} for c in chunks]
    json.dumps({"texts": [c.text for c in chunks], "meta": meta},
               ensure_ascii=False)

    assert callable(chunks[0].chunk_id), "chunk_id stopped being a method"
    assert isinstance(chunks[0].n_words, int), "n_words stopped being a property"

    src = (REPO / "src" / "index_build.py").read_text(encoding="utf-8")
    assert '"chunk_id": c.chunk_id()' in src, "index_build lost the parens again"


def test_zerogpu_function_is_declared():
    """ZeroGPU refuses to start a Space with no @spaces.GPU function, restarts

    it, and the second process dies binding 7860 -- so the crash reads as a

    port clash and the cause reads as a warning. Declaring one costs nothing:

    quota is billed per call, and nothing calls this one."""
    src = (REPO / "app.py").read_text(encoding="utf-8")
    assert "@spaces.GPU" in src, "ZeroGPU startup check will fail the Space"
    assert "import spaces" in src, "@spaces.GPU without the import is a no-op"
    # It is now referenced on purpose -- ZeroGPU only finds a function a gradio
    # event reaches. What must never appear is a CALL, which is the only thing
    # that spends the 5-minute daily allowance. Passing it to .click() is not
    # a call; "_gpu_probe(" is, and the def line is the sole legitimate one.
    assert src.count("_gpu_probe(") == 1, "the probe is invoked -- that burns quota"
    assert ".click(_gpu_probe" in src, "unreferenced: ZeroGPU will not detect it"


def test_zerogpu_shape_is_launch_not_uvicorn():
    """ZeroGPU is Gradio-SDK-only and its startup scan runs off gradio's

    launch, so serving uvicorn ourselves fails the Space even when the port

    binds cleanly -- which is exactly what happened. On a Space gradio must

    launch and the API rides inside it; off-Space the simpler shape stands."""
    src = (REPO / "app.py").read_text(encoding="utf-8")
    assert "demo.launch(" in src, "no launch: ZeroGPU will not detect the Space"
    assert "ON_SPACE" in src, "the two shapes are not separated"
    # A Mount, not a route splice: routes would lose the API's CORS middleware,
    # and middleware cannot be re-added after the app has started.
    assert 'Mount("/", app=app)' in src, "API must mount, to keep its middleware"


def test_gpu_probe_survives_a_missing_spaces_package():
    """Off-Space the import fails, and a bare `except: print` would leave

    _gpu_probe undefined for the console to reference -- a NameError that only

    ever fires where nobody is watching."""
    src = (REPO / "app.py").read_text(encoding="utf-8")
    blk = src[src.index("try:" + chr(10) + "    import spaces"):src.index("from deploy.boot")]
    assert "_gpu_probe = None" in blk, "except branch leaves _gpu_probe undefined"
    ns = {}
    exec(compile(blk.replace("import spaces", "import spaces_absent_xyz"),
                 "app.py", "exec"), ns)
    assert ns["_gpu_probe"] is None


def test_gradio_ssr_is_off_before_the_import():
    """Gradio 5+ SSR spawns a Node server that takes the PUBLIC port and

    proxies to Python. That is what produced "[Errno 98] address already in

    use" on 7860. The env var must be set BEFORE gradio is imported, or gradio

    has already resolved the default."""
    src = (REPO / "app.py").read_text(encoding="utf-8")
    assert "GRADIO_SSR_MODE" in src, "SSR default restored; Node will take 7860"
    assert (src.index('os.environ["GRADIO_SSR_MODE"]') < src.index("import gradio")),         "GRADIO_SSR_MODE is set after the gradio import, which is too late"
    assert "ssr_mode=False" in src, "launch/mount must also pass ssr_mode"


def test_pages_read_timing_keys_the_harness_emits():
    """The harness names every timing after its stage: speak_ms, transcribe_ms.

    Both pages read tts_ms and asr_ms, which never exist -- so the budget line

    said "retrieval + guardrails + reader" over a number carrying 877 ms of

    speech synthesis. A missing key reads as absent, not as an error, which is

    why it survived. Any t.<x>_ms a page reads must be a key harness emits."""
    import re

    # Stage names are declared inline in _stages; read them from the source so
    # this test needs no built index to run.
    hsrc = (REPO / "src" / "harness.py").read_text(encoding="utf-8")
    stages = re.findall(r'Stage\("([a-z_]+)"', hsrc)
    assert len(stages) >= 7, f"expected 7 stages, found {stages}"
    allowed = {n + "_ms" for n in stages} | {"total_ms", "pipeline_ms", "budget_ms"}

    for rel in ("web/index.html", "src/serve.py"):
        text = (REPO / rel).read_text(encoding="utf-8")
        used = set(re.findall(r"t\.([a-z_]+_ms)", text))
        unknown = used - allowed
        assert not unknown, f"{rel} reads timing keys the harness never emits: {sorted(unknown)}"


def test_typed_questions_do_not_pay_for_synthesis():
    """MMS runs on CPU on the Space: 3365 ms for one Kannada sentence, against

    110 ms for retrieval + guardrails + reader. Typed questions asked for it

    unconditionally, so the page reported a 3.5 s pipeline for work nobody

    requested. Speech out belongs to the speech path; the voice route still

    sends no audio flag and the server default speaks."""
    page = (REPO / "web" / "index.html").read_text(encoding="utf-8")
    typed = page[page.index("function ask()"):page.index("/api/voice")]
    assert "audio:true" not in typed.replace(" ", ""),         "the typed path hardcodes audio again"
    assert 'audio:$("#speak").checked' in typed.replace(" ", ""),         "the typed path no longer honours the speak toggle"
    assert 'id="speak"' in page, "the toggle it reads does not exist"


def test_gate_3b_refuses_a_coherent_answer_about_the_wrong_thing():
    """Gate 3 asks whether the corpus has anything coherent to say. It cannot

    ask what the coherent thing is ABOUT, and a live Kannada query for the

    capital of India retrieved a peaked neighbourhood of crore, coordinates and

    tourism -- allowed by gate 3, confirmed verbatim by gate 4, and wrong.

    Grounded is not relevant."""
    from src.guardrails import GuardrailConfig, Guardrails
    from src.router import Passage

    g = Guardrails(GuardrailConfig(entity_gate=True))
    india = "ಭಾರತದ ರಾಜಧಾನಿ"
    crore = "ಅಥವಾ ಕೋಟಿ ಹತ್ತು ದಶಲಕ್ಷ"
    tour  = "ಉತ್ತಮ ತಾಣ ಪ್ರವಾಸ"
    bad = [Passage("c1", crore, .485), Passage("c2", tour, .478),
           Passage("c3", crore, .471)]
    cov, _ = g.rare_term_coverage(india, bad)
    assert cov == 0.0, f"expected no coverage, got {cov}"
    v = g.check_topical(bad, query=india)
    assert v.blocked and v.gate == "entity_mismatch", (v.gate, v.action)

    # And the query that legitimately worked must still get through, including
    # across inflection: the passage carries a suffixed form of the term.
    corp = "ಕಾರ್ಪೋರೇಷನ್ ಎಂದರೇನು"
    hit  = "ಕಾರ್ಪೋರೇಷನ್‌ಗಳು ಎಂದು"
    good = [Passage("k1", hit, .673), Passage("k2", crore, .61), Passage("k3", tour, .60)]
    v2 = g.check_topical(good, query=corp)
    assert not v2.blocked, f"a good answer was refused: {v2.gate}"

    # A text query with no query string must not be silently refused.
    assert not g.check_topical(good, query="").blocked, "empty query must skip 3b"

    # And the reason it ships off: with IDF over only the retrieved passages,
    # question words look rarer than entities and a good question is refused.
    hi = [Passage("h0", "कैंटालूप की बेलों को बढ़ने में १० दिन", .91),
          Passage("h1", "परागित फूलों से कैंटालूप विकसित", .62),
          Passage("h2", "तरबूज की खेती गर्म", .20)]
    good_q = "कैंटालूप को पकने में कितना समय लगता है"
    cov3, _ = g.rare_term_coverage(good_q, hi)
    assert cov3 == 0.0, (
        "the known false refusal disappeared -- if IDF is now corpus-level, "
        "re-measure the false-refusal rate and flip entity_gate on")
    assert GuardrailConfig().entity_gate is False,         "gate 3b must stay off until its IDF comes from the corpus"


if __name__ == "__main__":
    tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
    for t in tests:
        t()
        print(f"ok  {t.__name__}")
    print(f"\n{len(tests)} passed")