File size: 30,788 Bytes
b5a142b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
999c9f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b5a142b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
r"""TAV'IEL-DECIDE -- single-pass typed decisions for YahBible's AI ("Jev mode").

The current pipeline (taviel_reason.py) makes every decision INSIDE prose
generation: which groundings to pull, what kind of question this is, which
gauntlet round matches, whether a cited verse is real -- all decided while the
model is already writing, across two 360-token autoregressive generations.

This module moves those decisions in front of generation, using the
parallel-decision fork of llama.cpp (D:\llama.cpp-decision, branch
parallel-decision): every field of a finite schema is scored as token paths
forking from ONE shared KV cache, so all questions are answered in a single
llama_decode with a calibrated probability each, and the JSON is assembled by
code -- it cannot be malformed. Same mechanism harshatheg benchmarked at
5.6-7.0x over autoregressive JSON on this exact base model
(Qwen2.5-1.5B-Instruct).

Server: the fork's llama-server with --decision-seqs, port 8110 (root tier,
Qwen2.5-1.5B-Instruct-Q4_K_M -- Tav'iel's existing mobile-tier GGUF; one
resident model serves BOTH /v1/chat/completions and /v1/decision).

Machine-first: nothing here loads a model into this process; it is an HTTP
client. Start/stop the server with serve_up()/serve_down().
"""
from __future__ import annotations

import json
import os
import subprocess
import time
import urllib.request

PORT = int(os.environ.get("TAV_DECIDE_PORT", "8110"))
URL = f"http://127.0.0.1:{PORT}/v1/decision"
SERVER = r"D:\llama.cpp-decision\build-cuda-mmq\bin\llama-server.exe"
GGUF = os.environ.get("TAV_DECIDE_GGUF",
                      r"D:\0000_Raw_LLM Models\Qwen2.5-1.5B-Instruct-Q4_K_M.gguf")
NGL = int(os.environ.get("TAV_DECIDE_NGL", "99"))

EMB_PORT = int(os.environ.get("TAV_EMB_PORT", "8111"))
EMB_SERVER = r"D:\0000_Raw_LLM Models\llama.cpp-tools\llama.cpp\llama-server.exe"
# a REAL contrastive embedder: causal-LM mean-pooling (Qwen 0.6B) proved
# recall-blind -- 4/20 paraphrases, the true round absent from top-8. bge-small
# is 33M params, CPU-instant, trained for exactly this.
# bge-BASE (110M) over bge-small (33M): measured on 630 held-out askings,
# R@8 86% -> 88%, spatial 53->59, R@3 83->84. Still CPU-only, still zero
# VRAM taken from the decision model.
EMB_GGUF = os.environ.get(
    "TAV_EMB_GGUF", r"D:\0000_Raw_LLM Models\bge-base-en-v1.5-q8_0.gguf")

_proc = None
_emb_proc = None


# ---------------------------------------------------------------- server ----
def serve_up(ngl=NGL, ctx=4096, seqs=16, wait_s=180):
    """Start the decision server (one resident model, 1.5B fits the 1650 whole)."""
    global _proc
    if _up():
        return True
    # the fork asserts (ggml_can_mul_mat) when --embeddings meets the unified
    # decision KV, so recall gets its own tiny embedder: the third_eye tier
    # (0.6B) on CPU ONLY -- zero VRAM taken from the decision model.
    global _emb_proc
    _emb_proc = subprocess.Popen(
        [EMB_SERVER, "-m", EMB_GGUF, "-ngl", "0", "-c", "512",
         "--embeddings", "--port", str(EMB_PORT)],
        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    # ctx 8192 x 24 unified decision sequences proved unstable on the 4 GB
    # card across long runs; 4096 x 16 comfortably covers the router's 8
    # candidates plus instructions and has not died since.
    _proc = subprocess.Popen(
        [SERVER, "-m", GGUF, "-ngl", str(ngl), "-fa", "on", "-c", str(ctx),
         "--decision-seqs", str(seqs), "--port", str(PORT)],
        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    t0 = time.time()
    while time.time() - t0 < wait_s:
        if _up() and _emb_up():
            return True
        time.sleep(1.0)
    raise RuntimeError("server(s) did not come up in %ss (decision=%s embed=%s)"
                       % (wait_s, _up(), _emb_up()))


_AVAIL = {"at": 0.0, "ok": False}
AVAIL_TTL = 20.0


def available(timeout=0.4, ttl=AVAIL_TTL):
    """Is Jev actually here? A CHEAP probe that never starts anything.

    This exists because of what serve_up() costs when the answer is no. Its
    retry path waits up to 180 seconds for a server to come up, and decide()
    calls it three times -- so on a machine without the fork binary, or with
    one that cannot allocate, a single user question could hang for minutes
    before falling back. A speedup whose absence costs six minutes is not a
    speedup.

    So every caller that would LIKE to use the decision server asks here
    first. The probe is a HEAD-weight /health with a short timeout, cached for
    a few seconds so a chat turn does not pay for it repeatedly, and it never
    spawns a process. A machine without Jev behaves exactly as it did before
    Jev existed, at the cost of one refused connection every ttl seconds.

    Both servers are required: the router needs the decision endpoint to judge
    and the embedder to retrieve, and a half-present Jev routes worse than no
    Jev at all.
    """
    now = time.time()
    if now - _AVAIL["at"] < ttl:
        return _AVAIL["ok"]
    ok = False
    try:
        urllib.request.urlopen(f"http://127.0.0.1:{PORT}/health", timeout=timeout)
        urllib.request.urlopen(f"http://127.0.0.1:{EMB_PORT}/health",
                               timeout=timeout)
        ok = True
    except Exception:
        ok = False
    _AVAIL.update(at=now, ok=ok)
    return ok


def unavailable():
    """Mark Jev as gone without waiting for the cache to lapse.

    Called when a decision actually fails mid-turn, so the next question does
    not repeat a doomed round-trip.
    """
    _AVAIL.update(at=time.time(), ok=False)


def serve_down():
    global _proc, _emb_proc
    for p in (_proc, _emb_proc):
        if p:
            p.terminate()
    _proc = _emb_proc = None


def _up():
    try:
        urllib.request.urlopen(f"http://127.0.0.1:{PORT}/health", timeout=2)
        return True
    except Exception:
        return False


def _emb_up():
    try:
        urllib.request.urlopen(f"http://127.0.0.1:{EMB_PORT}/health", timeout=2)
        return True
    except Exception:
        return False


# ---------------------------------------------------------------- client ----
def decide(schema, contexts, instructions="", timeout=120, _tries=3):
    """POST /v1/decision. contexts: str or list of 1-256 strings sharing one
    schema + one cached instruction prefix. Returns the parsed response.

    RESILIENT BY DESIGN: the fork's server has died mid-run three times
    across long evaluations, and each time the numbers it corrupted looked
    like a model failure rather than a dead socket (the always-answer law
    once scored 11/40 purely from connection-refused). A refused connection
    now restarts the server and retries, so a measurement reports the system
    rather than the server's mood."""
    if isinstance(contexts, str):
        contexts = [contexts]
    body = json.dumps({"instructions": instructions, "schema": schema,
                       "contexts": contexts}).encode()
    last = None
    for attempt in range(_tries):
        try:
            req = urllib.request.Request(
                URL, data=body, headers={"Content-Type": "application/json"})
            with urllib.request.urlopen(req, timeout=timeout) as r:
                return json.loads(r.read())
        except Exception as ex:
            last = ex
            if attempt == _tries - 1:
                break
            import sys
            import time as _t
            print(f"[taviel_decide] decision server unreachable "
                  f"({type(ex).__name__}); restarting", file=sys.stderr)
            try:
                if _proc:
                    _proc.terminate()
            except Exception:
                pass
            globals()["_proc"] = None
            _t.sleep(2)
            serve_up()
    raise last


def decisions_of(resp):
    """[{field: value}] per context, in order."""
    return [r["decision"] for r in resp["results"]]


def fields_of(resp, i=0):
    """{field: {value, probability, ...}} for context i."""
    return resp["results"][i]["fields"]


# ---------------------------------------------------------------- schemas ---
# 1) THE ANSWER PLAN -- the full shape of the answer, decided before the first
#    generated token. One decode answers all nine.
# There is deliberately NO "route" question. v1 asked the model to route with
# bare labels and "decline" won at p=0.96 on a core objection; v2's descriptive
# phrases still lost at p=0.61. Routing is a COMPOUND judgment a 1.5B fumbles,
# while the primitive judgments below measure strong (genre=objection p=0.97).
# So the model answers primitives, and plan() COMPOSES the route in code --
# "with code owning the action", as the aeon-pack doctrine has it.
ANSWER_PLAN = {
    "wants_passage": {"type": "boolean",
                      "description": "Do they only want a Bible passage's text "
                                     "quoted to them, not an explanation?"},
    "genre": {"type": "enum",
              "choices": ["objection", "word_study", "doctrine", "comfort",
                          "history", "practice"],
              "description": "What kind of question is this?"},
    "register": {"type": "enum", "choices": ["skeptic", "believer", "child"],
                 "description": "Who is asking, by their wording?"},
    "depth": {"type": "enum", "choices": ["brief", "moderate", "full"],
              "description": "How thorough must the answer be to satisfy the asker?"},
    "need_scripture": {"type": "boolean",
                       "description": "Would verse quotations strengthen the answer?"},
    "need_hebrew_greek": {"type": "boolean",
                          "description": "Does answering require the original "
                                         "Hebrew or Greek behind a word?"},
    "need_apologetics": {"type": "boolean",
                         "description": "Is this an objection or challenge to the "
                                        "faith that a recorded argument answers?"},
    "christ_first": {"type": "boolean",
                     "description": "Should the answer open with what Yeshua the "
                                    "Christ Himself said or did on this?"},
    "in_scope": {"type": "boolean",
                 "description": "Is this within Scripture study, doctrine, or the "
                                "life of faith at all?"},
}

PLAN_INSTRUCTIONS = ("You are Tav'iel, the study guide of YahBible, a Scripture "
                     "study app. Judge the user's question below and answer each "
                     "field from the question's own wording.")


def _true(v):
    return v in (True, "true")


def plan(question):
    """One decode of primitive judgments; the route is COMPOSED in code."""
    r = decide(ANSWER_PLAN, "Question: " + question, PLAN_INSTRUCTIONS)
    dec, fl = decisions_of(r)[0], fields_of(r)
    if not _true(dec.get("in_scope")) and fl["in_scope"]["probability"] >= 0.6:
        dec["route"] = "decline"
    elif _true(dec.get("wants_passage")):
        dec["route"] = "verse_lookup"
    elif dec.get("genre") == "objection" or _true(dec.get("need_apologetics")):
        dec["route"] = "vetted"        # the router confirms; its NONE -> reason
    else:
        dec["route"] = "reason"
    return dec, fl, r.get("timings", {})


# 2) THE GAUNTLET ROUTER -- which of the 100 vetted rounds answers this
#    question. v1 (a 100-way enum of round IDS) proved 9/20: id tokens carry no
#    meaning. v2 (100 independent booleans) proved 1/20 at 4.7s: "covers" says
#    yes to everything topical, and independent judgments cannot discriminate.
#    v3: the existing LEXICAL net shortlists a handful, and the decision is one
#    enum whose CHOICES ARE THE CANDIDATE QUESTIONS THEMSELVES (plus NONE) --
#    the candidates compete inside one softmax, which is where a decision
#    model's discrimination actually lives.
_KB = None


def _kb():
    global _KB
    if _KB is None:
        p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gauntlet_kb.json")
        _KB = json.load(open(p, encoding="utf-8"))
    return _KB


_STOP = set("a an the is are was be been do does did what why how who when where "
            "isn didn doesn can could would should i you it they of in on to for "
            "with about that this if not no or and but there s t re".split())


def _shortlist(question, k=8):
    qw = {w for w in "".join(c if c.isalnum() else " " for c in question.lower())
          .split() if w not in _STOP}
    scored = []
    for e in _kb():
        ew = set()
        for field in ("words", "keys"):
            v = e.get(field) or []
            ew |= {str(x).lower() for x in (v if isinstance(v, list) else [v])}
        ew |= {w for w in "".join(c if c.isalnum() else " " for c in
                                  e["question"].lower()).split() if w not in _STOP}
        s = len(qw & ew)
        if s:
            scored.append((s, e))
    scored.sort(key=lambda x: -x[0])
    return [e for _, e in scored[:k]]


def embed(texts):
    """CLS-pooled embeddings from the bge server (/v1/embeddings).

    NOT mean-pooled -- this docstring said so and was wrong. The GGUF declares
    `bert.pooling_type = 2`, which is CLS, and serve_up passes no --pooling
    flag, so the model's own default stands. Every vector in the precomputed
    index was therefore built with CLS.

    This matters far beyond a docstring: transformers.js and most ONNX
    examples default to MEAN pooling. A port that accepts that default puts
    its queries in a different space from the keys and recall collapses with
    no error and no exception -- just quietly worse answers. Any
    reimplementation must pool CLS, must truncate at 512 tokens (`-c 512`),
    and must NOT prepend bge's "Represent this sentence for searching
    relevant passages: " instruction, because llama-server does not.
    """
    if isinstance(texts, str):
        texts = [texts]
    body = json.dumps({"input": texts}).encode()
    last = None
    for attempt in range(3):
        try:
            req = urllib.request.Request(
                f"http://127.0.0.1:{EMB_PORT}/v1/embeddings", data=body,
                headers={"Content-Type": "application/json"})
            with urllib.request.urlopen(req, timeout=180) as r:
                data = json.loads(r.read())["data"]
            return [d["embedding"] for d in sorted(data, key=lambda d: d["index"])]
        except Exception as ex:
            # same lesson as decide(): building the key cache issues ~180
            # batched calls, and one dropped connection used to abort a whole
            # proof run with a bare URLError.
            last = ex
            if attempt == 2:
                break
            import sys
            import time as _t
            print(f"[taviel_decide] embedder unreachable ({type(ex).__name__}); "
                  "restarting", file=sys.stderr)
            try:
                if _emb_proc:
                    _emb_proc.terminate()
            except Exception:
                pass
            globals()["_emb_proc"] = None
            _t.sleep(2)
            serve_up()
    raise last


def _cos(a, b):
    num = sum(x * y for x, y in zip(a, b))
    da = sum(x * x for x in a) ** 0.5
    db = sum(x * x for x in b) ** 0.5
    return num / (da * db + 1e-9)


_ROUND_EMB = None


def _round_embs():
    """Views per round: the question, its key words, and -- when the variant
    file exists -- every generated way of asking it (registers, synonyms,
    antonym framings, symbolic/metaphorical, ballpark). A user question
    routes by its NEAREST view, so nobody needs the exact wording. Variant
    embeddings are cached to disk keyed on the variant file's mtime."""
    global _ROUND_EMB
    if _ROUND_EMB is None:
        base = os.path.dirname(os.path.abspath(__file__))
        views = {}                       # n -> [text, ...]
        for e in _kb():
            words = e.get("words") or []
            views[e["n"]] = [e["question"].strip('"'),
                             " ".join(str(w) for w in words[:16]) or
                             e["question"].strip('"')]
        vfile = os.path.join(base, "gauntlet_variants.json")
        if os.path.exists(vfile):
            var = json.load(open(vfile, encoding="utf-8"))
            for k_, vs in var.items():
                views.setdefault(int(k_), []).extend(vs)
        # flat embed with a disk cache (numpy npz keyed by content signature)
        import hashlib
        import numpy as np
        flat, owners, isvar = [], [], []
        nbase = {n: 0 for n in views}
        for n in sorted(views):
            for i, t in enumerate(views[n]):
                flat.append(t)
                owners.append(n)
                isvar.append(0 if i < 2 else 1)   # first two are question+words
        sig = hashlib.sha1("\x1f".join(flat).encode("utf-8")).hexdigest()[:16]
        cache = os.path.join(base, f"round_embs_{sig}.npz")
        if os.path.exists(cache):
            z = np.load(cache)
            arr = z["e"]
        else:
            out = []
            for i in range(0, len(flat), 64):
                out.extend(embed(flat[i:i + 64]))
            arr = np.asarray(out, dtype="float32")
            arr /= (np.linalg.norm(arr, axis=1, keepdims=True) + 1e-9)
            np.savez_compressed(cache, e=arr)
        _ROUND_EMB = (np.asarray(owners), np.asarray(isvar), arr)
    return _ROUND_EMB


# MEASURED on the paraphrase set + off-scope traps (tune_recall.py):
#   knn k10 p6 ranks best (19/20 R@1) but its scores do NOT separate scope
#     (worst on-topic 0.202 BELOW best off-scope 0.417);
#   base_max (question+words only) ranks worse (17/20) but is the only
#     strategy with positive separation (0.616 on-topic vs 0.583 off-scope).
# So the two jobs take the two signals: BASE-MAX GATES, KNN RANKS. Variants
# then help recall without letting one noisy generated line admit an
# off-scope question.
KNN_K, KNN_POWER = 10, 6


def _semantic_shortlist(question, k=8):
    """[(knn_score, entry)] best-first. The gate score rides on the list as
    .scope (see _scope_of) so callers can judge in/out of scope separately."""
    import numpy as np
    owners, isvar, arr = _round_embs()
    q = np.asarray(embed(question)[0], dtype="float32")
    q /= (np.linalg.norm(q) + 1e-9)
    sims = arr @ q

    base_mask = isvar == 0
    scope = float(sims[base_mask].max()) if base_mask.any() else 0.0

    kk = min(KNN_K, sims.shape[0])
    idx = np.argpartition(-sims, kk - 1)[:kk]
    acc = {}
    for i in idx:
        acc[int(owners[i])] = acc.get(int(owners[i]), 0.0) + \
            float(sims[i]) ** KNN_POWER
    byn = {e["n"]: e for e in _kb()}
    top = sorted(acc.items(), key=lambda x: -x[1])[:k]
    out = [(s, byn[n]) for n, s in top]
    _semantic_shortlist.last_scope = scope
    return out


def _scope_of(question=None):
    """The gate score of the most recent shortlist (base-view max cosine)."""
    return getattr(_semantic_shortlist, "last_scope", 0.0)


def suggest(question, k=3, exclude=None):
    """Better questions near this one -- ALWAYS k of them, from k DISTINCT
    rounds. 'Poor answering without better suggestions for questions is poor
    answering.'

    It cannot reuse the router's shortlist: that ranks by a knn vote over
    individual vectors, so a near-verbatim question fills all ten neighbours
    with ONE round's own variants. Excluding the answered round then left
    nothing to suggest -- measured, seven of forty perfect vetted answers
    arrived with no follow-up questions at all, the best matches being the
    emptiest. Suggestions therefore rank whole rounds by their best view,
    which always yields distinct alternatives."""
    import numpy as np
    try:
        owners, isvar, arr = _round_embs()
        q = np.asarray(embed(question)[0], dtype="float32")
        q /= (np.linalg.norm(q) + 1e-9)
        sims = arr @ q
        best = {}
        for n, s in zip(owners, sims):
            n = int(n)
            if s > best.get(n, -1.0):
                best[n] = float(s)
    except Exception:
        return []
    byn = {e["n"]: e for e in _kb()}
    out = []
    for n, _ in sorted(best.items(), key=lambda x: -x[1]):
        if n == exclude or n not in byn:
            continue
        out.append((n, byn[n]["question"].strip('"')))
        if len(out) >= k:
            break
    return out


def route(question, threshold=0.60, cos_gate=0.60, k=8, direct_floor=0.70):
    """(round_number|None, probability, timings). None = fall through to reason().

    v6 -- two successive questions, each GATED where its signal lives:
    recall asks "what is this ABOUT" (embeddings -- lexical overlap capped it
    at 3/20 and causal-LM pooling was recall-blind); the judge asks "which one
    EXACTLY" among the top-k candidates' own text. A NONE choice no longer
    sits in the judge's softmax: as candidates grow plausible the mass SPLITS
    among them and NONE wins by default (measured -- the refined suffering
    question lost to NONE at p=0.43 with round 2 sitting at recall rank 1).
    Out-of-scope is the RECALL's signal: an off-topic question embeds far from
    every round, so the cosine gate rejects it before any judging. The judge's
    threshold is a margin over uniform (1/k), not a majority bar."""
    try:
        scored = _semantic_shortlist(question, k)
    except Exception as ex:
        # fall back, but NEVER silently -- a masked recall failure cost two
        # proof runs that were secretly re-measuring the lexical net.
        import sys
        print(f"[taviel_decide] semantic recall failed ({ex!r}); lexical fallback",
              file=sys.stderr)
        scored = [(1.0, e) for e in _shortlist(question, k)]
    # the GATE is the base-view cosine (the only separating signal); the
    # ORDER is the knn vote. Two jobs, two signals.
    scope = _scope_of()
    if not scored or scope < cos_gate:
        return None, scope, {}
    bynq = {e["question"].strip('"'): e["n"] for _, e in scored}
    schema = {"same": {"type": "enum", "choices": list(bynq),
                       "description": "Which recorded objection is asking the "
                                      "same thing as the user, in other words?"}}
    r = decide(schema, "User's question: " + question,
               "The user's question is a REWORDING of one recorded objection. "
               "Pick the objection that means the same thing even when the "
               "words differ.")
    f = fields_of(r)["same"]
    n = bynq.get(f["value"])
    p = f["probability"]
    # TWO WITNESSES. To commit is to hand the inquirer a RECORDED answer --
    # and if it is the wrong one, an answer to a question they never asked.
    # So retrieval and the judge must INDEPENDENTLY name the same round, and
    # the judge must be sure. Measured on 225 held-out askings
    # (sweep_thresholds.py): the old permissive rule (p>=0.30, pick anywhere
    # in the top 8, with a dominance override) answered 70.2% correctly but
    # was OFF TOPIC 17.8% of the time -- one asking in six got another
    # question's answer. Requiring agreement at p>=0.60 gives 54.2% correct
    # and 1.8% wrong: a tenfold cut in the only truly bad outcome, at 96.8%
    # precision. Deferral is not silence -- the front door reasons from
    # scripture grounding and offers better questions, which under his law
    # is a good answer of a different kind. Bounding WRONG is the objective;
    # maximising CORRECT is not.
    if n is None or n != scored[0][1]["n"]:
        return None, p, r.get("timings", {})
    if p < threshold:
        return None, p, r.get("timings", {})

    # A THIRD WITNESS THAT NEVER SAW THE SHORTLIST.
    #
    # The two-witness rule assumed retrieval and the judge were independent.
    # They are not: retrieval hands the judge its candidate list. Measured on
    # 600 held-out askings, 10 of the 14 worst wrong commits had the true
    # round ABSENT from the top-8 entirely -- so the judge chose confidently,
    # several at p=1.00, among candidates that could not contain the right
    # answer, and both witnesses agreed on the same wrong page. No threshold
    # on the judge can reach that failure.
    #
    # So the last check compares the asking DIRECTLY against the chosen
    # round's own text, which depends on nothing the shortlist did. Measured
    # separation: correct commits median 0.761, wrong 0.663. A floor of 0.70
    # keeps 77% of correct commits and blocks 73% of wrong ones, taking the
    # off-topic rate from 3.5% to about 1%. The commits it blocks are not
    # lost -- they become deferrals, which answer by reasoning with better
    # questions offered, and under his law that is a good answer of a
    # different kind.
    if not _resembles(question, scored[0][1], floor=direct_floor):
        return None, p, {**(r.get("timings") or {}), "blocked": "direct_floor"}
    return n, p, r.get("timings", {})


def _resembles(question, entry, floor=0.70):
    """Does the CHOSEN round actually look like what was asked?"""
    try:
        qv, cv = embed([question, entry["question"].strip('"')])
    except Exception:
        return True          # no embedder: do not block on a check we cannot run
    num = sum(x * y for x, y in zip(qv, cv))
    na = sum(x * x for x in qv) ** .5
    nb = sum(x * x for x in cv) ** .5
    return (num / (na * nb + 1e-9)) >= floor


def literalize(question, timeout=120):
    """Say plainly what is being asked underneath a figurative asking."""
    prompt = ("Someone asked this in a figurative or roundabout way:\n"
              f"\"{question}\"\n\n"
              "Restate ONLY the plain question underneath it, in one short "
              "line, using ordinary words about God, the Bible, or faith. "
              "No metaphors, no preamble, no quotation marks.")
    body = json.dumps({
        "messages": [{"role": "system",
                      "content": "You restate questions plainly. One line."},
                     {"role": "user", "content": prompt}],
        "max_tokens": 48, "temperature": 0.1}).encode()
    req = urllib.request.Request(
        f"http://127.0.0.1:{PORT}/v1/chat/completions", data=body,
        headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        t = json.loads(r.read())["choices"][0]["message"]["content"]
    return t.strip().strip('"').split("\n")[0][:200]


def route_best(question, k=8, anchor=3, lit_threshold=0.70):
    """route(), and when it defers, ASK A BETTER QUESTION OF THE QUESTION.

    His principle turned inward: "asking better questions, or questions that
    build on questions, is what leads to the best answer." Metaphorical
    askings fail retrieval because their words sit far from every recorded
    question -- musical 44%, spatial 58% recall@1 -- so the system restates
    the asking plainly and routes on the restatement, the way a librarian
    says "so what you're really asking is whether God is just."

    Three attempts to close this gap by moving the KEYS toward the question
    all failed (intelligence-field variants, the KB's own prose as views, a
    bigger embedder). Moving the QUESTION toward the keys works.

    THE THIRD WITNESS: a restatement may only land on a round the ORIGINAL
    asking already had in view. Restating is interpretation, and
    interpretation can invent -- unanchored, it turned 7 deferrals into
    confident wrong answers. Anchored, it keeps 22 of 24 rescues and breaks
    2. Measured on 160 held-out askings from the four weakest fields:
    correct 34.4% -> 48.1%, WRONG 2.5% -> 3.8%, with spatial 22% -> 40% at
    zero wrong.

    Returns (round|None, p, {source}). Fires only on deferral, so a question
    both witnesses already agreed on is never second-guessed.
    """
    n, p, t = route(question, k=k)
    if n is not None:
        return n, p, {"via": "direct", **(t or {})}
    try:
        # ANCHOR DEPTH 3, not 8. Swept offline over 300 held-out askings
        # (collect_literalize + sweep_literalize): a top-8 anchor at p>=0.60
        # scored correct 58.0% / WRONG 2.7%, while top-3 at p>=0.70 scores
        # 57.7% / 2.3% -- and 2.3% is EXACTLY the baseline wrongness without
        # literalization at all. So restating buys +7 points of correct
        # answers at ZERO added off-topic risk, which is the only version of
        # this feature his standard permits. Giving up 0.3 points of correct
        # to add no risk is the right trade when the rule is "never off
        # topic"; the 3% bar also sits near the sampling noise floor, so the
        # rule that ties baseline is the robust one, not the nominal best.
        near = [e["n"] for _, e in _semantic_shortlist(question, k)][:anchor]
        plain = literalize(question)
        n2, p2, t2 = route(plain, k=k)
        if n2 is not None and n2 in near and p2 >= lit_threshold:
            return n2, p2, {"via": "literalized", "plain": plain, **(t2 or {})}
    except Exception:
        pass
    return None, p, {"via": "deferred"}


# 3) VERSE VERIFICATION -- every candidate ref of a draft, one batched round.
#    Replaces the second 360-token "refine" generation on drafts whose only sin
#    is a doubtful citation.
VERIFY_SCHEMA = {
    "supports": {"type": "boolean",
                 "description": "Does the verse, as quoted, actually say what the "
                                "claim uses it to say?"},
    "quoted_exactly": {"type": "boolean",
                       "description": "Is the quotation word-for-word from the "
                                      "verse text given?"},
}


def verify_refs(claims_and_verses):
    """claims_and_verses: [(claim, ref, verse_text)] -> [{ref, supports, exact, p}].
    All pairs are scored in ONE decision round (shared schema + cached prefix).

    NOT SHIPPED as the refine replacement: prove_taviel_decide2 measured 3/6
    discrimination at the 1.5B tier (embedding cosine also 3/6) -- the refine
    generation stays until a bigger tier or a true NLI cross-encoder retries."""
    contexts = [f"Claim: {c}\nVerse ({ref}): {v}" for c, ref, v in claims_and_verses]
    r = decide(VERIFY_SCHEMA, contexts,
               "Judge each claim against the verse quoted with it.")
    out = []
    for (c, ref, v), res in zip(claims_and_verses, r["results"]):
        f = res["fields"]
        out.append({"ref": ref,
                    "supports": f["supports"]["value"] in (True, "true"),
                    "exact": f["quoted_exactly"]["value"] in (True, "true"),
                    "p": min(f["supports"]["probability"],
                             f["quoted_exactly"]["probability"])})
    return out