File size: 23,765 Bytes
aad1ed3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""

The extractive reader: find the answer SPAN inside retrieved passages.



WHY THIS COMPONENT CARRIES THE LATENCY PLAN

-------------------------------------------

src/extractability.py measured that 58.6% of answerable queries have their answer

present verbatim (exact or token-subsequence) in the gold passage. Those queries

never need a language model -- copy the span and you are done in single-digit

milliseconds, on a MIG slice where generating 64 tokens costs 640-1000 ms.



The reader also produces the CONFIDENCE that src/router.py routes on, and that

src/router.calibrate_threshold turns into a guaranteed error bound. One score,

three jobs: answer, route, abstain.



TWO IMPLEMENTATIONS

-------------------

LexicalSpanReader   no model, no GPU, ~0.3 ms. IDF-weighted overlap + answer-type

                    priors. Works today, and is the honest baseline the neural

                    reader must beat.

NeuralSpanReader    bge-m3 encoder + start/end heads, trained by weak supervision

                    (src/span_labels.py locates `Answer` inside

                    `Translated_passages`). Loads a checkpoint if one exists and

                    falls back to lexical otherwise, so the pipeline never breaks

                    because training has not finished.



CALIBRATION MATTERS MORE THAN ACCURACY HERE

-------------------------------------------

Coverage at a fixed error bound is set by how well confidence separates right from

wrong, not by raw accuracy. A reader that is 70% accurate and well calibrated

beats one that is 75% accurate and badly calibrated, because the calibrated one

can answer far more queries at the same guaranteed error rate. So the score is

built from a MARGIN (best vs runner-up), which is what separates, rather than an

absolute similarity, which is not.

"""
from __future__ import annotations

import math
import re
import unicodedata
from dataclasses import dataclass
from typing import Sequence

from src.chunkers.base import split_sentences, words_of
from src.router import Passage, Span
from src.textnorm import normalise

__all__ = ["LexicalSpanReader", "NeuralSpanReader", "AnswerType", "detect_answer_type"]

_WS = re.compile(r"\s+")
# Digits across the scripts in MSMARCO-XI: ASCII, Devanagari, Bengali, Gurmukhi,
# Gujarati, Odia, Tamil, Telugu, Kannada, Malayalam, Arabic-Indic (Urdu).
_DIGITS = re.compile(r"[0-9०-९০-৯੦-੯૦-૯"
                     r"୦-୯௦-௯౦-౯೦-೯"
                     r"൦-൯٠-٩۰-۹]")


class AnswerType:
    NUMERIC = "NUMERIC"
    PERSON = "PERSON"
    LOCATION = "LOCATION"
    ENTITY = "ENTITY"
    DESCRIPTION = "DESCRIPTION"


# Cue words, English plus the Indic equivalents that survive translation.
_CUES = {
    AnswerType.NUMERIC: (
        "how many", "how much", "how long", "how old", "how far", "how fast",
        "how tall", "how deep", "how high", "how wide", "how heavy", "how big",
        "what year", "what age", "what speed", "what time", "what temperature",
        "what percentage", "what is the average", "when did", "when was", "when is",
        "cost", "price", "average", "percentage", "temperature", "salary", "rate",
        "कितन", "कितना", "कितने", "कब", "एत्तनै",
        "எத்தனை", "எவ்வளவு", "எப்போது", "എത്ര", "എപ്പോൾ",
        "ఎన్ని", "ఎంత", "ఎప్పుడు", "ಎಷ್ಟು", "ಯಾವಾಗ",
        "کتنا", "کتنے", "کب", "কত", "কখন", "કેટલા", "ਕਿੰਨਾ", "କେତେ"),
    AnswerType.PERSON: ("who is", "who was", "who did", "who are", "whose",
                        "कौन", "யார்", "ആര്", "ఎవరు", "ಯಾರು", "کون"),
    AnswerType.LOCATION: ("where is", "where are", "where did", "location of",
                          "कहां", "कहाँ", "எங்கே", "എവിടെ", "ఎక్కడ", "ಎಲ್ಲಿ", "کہاں"),
    AnswerType.ENTITY: ("what is the name", "which company", "which country",
                        "what type", "what kind"),
}


def detect_answer_type(query: str) -> str:
    """Cheap answer-type prior from the query text.



    NB: query_type exists in the dataset but NOT at inference time, so it has to

    be inferred. It is only a prior -- extractability showed the type spread is

    24 pp, too narrow to route on, so it nudges span scores rather than deciding

    anything.

    """
    q = query.lower()
    for t, cues in _CUES.items():
        if any(c in q for c in cues):
            return t
    return AnswerType.DESCRIPTION


_ES_STEMS = ("s", "x", "z", "ch", "sh")


_IND_PREFIX = 5


def _fold_indic(t: str) -> str:
    """Fold an Indic token to a prefix so inflected forms match.



    The comment below used to say exact match is enough because query and

    passage share a language. It is not: these morphologies are agglutinative,

    so a live Kannada query for ಕಾರ್ಪೊರೇಷನ್ met ಕಾರ್ಪೊರೇಷನ್‌ಗಳು in the passage

    and overlapped on nothing. Every candidate then scored 0, _confidence

    returned 0 for a best score of 0, and the reader shipped whichever sentence

    happened to sort first while reporting conf 0.00. It was not choosing.



    A prefix cannot end on a combining mark: Indic vowel signs and viramas are

    separate code points, and cutting before the base they attach to leaves a

    fragment that matches things it should not.

    """
    if len(t) <= _IND_PREFIX:
        return t
    import unicodedata

    p = t[:_IND_PREFIX]
    while p and unicodedata.category(p[-1]) in ("Mn", "Mc"):
        p = p[:-1]
    return p or t[:_IND_PREFIX]


def _stem(t: str) -> str:
    """Crude suffix strip for Latin script so 'eagle' matches 'eagles'.



    Order is load-bearing: stripping "es" before "s" turns 'eagles' into 'eagl'

    while 'eagle' stays 'eagle', so the two stop matching -- the exact bug this

    function exists to prevent. "es" is only a plural marker after s/x/z/ch/sh

    (boxes, dishes); everywhere else the plural is a bare "s".



    Deliberately not applied to Indic tokens: their morphology is agglutinative

    and chopping trailing characters destroys meaning. Query and passage are in

    the SAME language, so exact match already works there.

    """
    if not t.isascii():
        return _fold_indic(t)
    if len(t) < 4 or not t.isalpha():
        return t
    if t.endswith("ies") and len(t) > 4:
        return t[:-3] + "y"
    if t.endswith("es") and len(t) > 4 and t[:-2].endswith(_ES_STEMS):
        return t[:-2]
    if t.endswith("s") and not t.endswith("ss") and len(t) > 3:
        return t[:-1]
    if t.endswith("ing") and len(t) > 5:
        return t[:-3]
    if t.endswith("ed") and len(t) > 4:
        return t[:-2]
    return t


def _norm(s: str) -> str:
    # Shared with every metric in the repo. Was a `[^\w\s]` regex, which deleted
    # Indic vowel marks and split each word at the gap -- so this scorer was
    # matching consonant fragments, not words, in 13 of the 14 languages.
    return normalise(s, True)


def _norm_tokens(s: str) -> list[str]:
    return [_stem(t) for t in _norm(s).split()]


_STOP = {"the","a","an","of","is","are","was","were","to","in","for","on","and",
         "do","does","did","how","what","when","where","who","which","that","this",
         "it","its","be","been","by","with","as","at","from","or","not"}


def _idf(passages: Sequence[str]) -> dict[str, float]:
    """IDF over the retrieved set. Rare query terms should dominate the match;

    without this, stopwords decide the span."""
    n = max(1, len(passages))
    df: dict[str, int] = {}
    for p in passages:
        for t in set(_norm_tokens(p)):
            df[t] = df.get(t, 0) + 1
    return {t: math.log(1 + n / c) for t, c in df.items()}


# Median gold-answer length by type, measured on MSMARCO-XI (src/extractability.py).
_TARGET_WORDS = {
    AnswerType.NUMERIC: 9.0,
    AnswerType.LOCATION: 8.0,
    AnswerType.PERSON: 10.0,
    AnswerType.ENTITY: 12.0,
    AnswerType.DESCRIPTION: 16.0,
}


@dataclass(slots=True)
class _Cand:
    text: str
    score: float
    chunk_id: str
    start: int
    end: int


class LexicalSpanReader:
    """No model. Sentences scored by IDF-weighted query overlap, then a sub-span

    search inside the winner when the question wants a short answer.



    Confidence is the MARGIN between the best and runner-up spans, squashed to

    [0,1]. Margin separates correct from incorrect far better than an absolute

    score, which is what the conformal threshold needs.

    """
    name = "lexical"

    def __init__(self, max_span_words: int = 25, min_span_words: int = 1,

                 margin_scale: float = 6.0, prior_weight: float = 0.0,

                 answer_mode: str = "span"):
        self.max_span, self.min_span, self.scale = max_span_words, min_span_words, margin_scale
        # THE RETRIEVAL PRIOR. Passage.score carries the reranker's opinion about
        # which passage holds the answer, and this reader used to ignore it
        # completely -- every sentence in every candidate passage competed on
        # query overlap alone. Measured cost of ignoring it: the oracle over the
        # passage the reader CHOSE is 0.361 F1 while the oracle over ANY passage
        # is 0.715, i.e. half the achievable F1 is lost before span selection
        # even starts, in a candidate set where retrieval puts the gold passage
        # in the top 5 82% of the time.
        #
        # Blend multiplicatively on a [1-w, 1] ramp: at w=0 the prior is off, at
        # w=1 a passage the retriever scored 0 is fully suppressed. Multiplicative
        # rather than additive so a passage the retriever likes cannot rescue a
        # sentence with no lexical support at all.
        self.prior_weight = prior_weight

        # ANSWER MODE -- "span" trims to a sub-span of the winning sentence,
        # "sentence" returns the whole sentence.
        #
        # Two independent reasons "sentence" is the right default for SERVING:
        #
        #  1. IT MEASURES BETTER. The diagnosis put boundary loss at 0.120 F1 --
        #     oracle-within-the-chosen-sentence is 0.397 while the trimmed
        #     reader returns 0.276. The trimming is COSTING F1, because MS MARCO
        #     answers average 19.2 words, i.e. about a sentence. We were cutting
        #     sentences down to fragments to match targets that are sentences.
        #
        #  2. IT IS THE ONLY ONE THAT CAN BE SPOKEN. A trimmed span such as
        #     "को बीज से पके फल तक बढ़ने में ९०" starts mid-clause and stops
        #     mid-number. Through TTS that is not a slightly worse answer, it is
        #     an unusable one. Grounding is unaffected: a sentence of a retrieved
        #     passage is still a verbatim span of it, so guardrail gate 4 still
        #     passes by exact substring check.
        self.answer_mode = answer_mode if answer_mode in ("span", "sentence") else "span"

    # -- scoring ---------------------------------------------------------
    #
    # TWO DIFFERENT OBJECTIVES, and conflating them is the classic extractive-QA
    # mistake:
    #
    #   SENTENCE selection -> maximise QUERY OVERLAP. Find where the topic is
    #                         discussed.
    #   SPAN selection     -> the answer is the thing you DON'T know, so it
    #                         usually contains NONE of the query terms. Scoring
    #                         it by overlap picks the question back out of the
    #                         passage. Score by answer-type fit and proximity to
    #                         the query terms instead.

    def _score_tokens(self, span_tokens: list[str], q_tokens: set[str],

                      idf: dict[str, float], atype: str) -> float:
        if not span_tokens:
            return 0.0
        # Content words only: with stopwords included, every sentence matches
        # "how/does/the" equally and the length prior ends up choosing the answer.
        content = {t for t in q_tokens if t not in _STOP}
        if not content:
            content = q_tokens
        hit = sum(idf.get(t, 1.0) for t in set(span_tokens) & content)
        total = sum(idf.get(t, 1.0) for t in content) or 1.0
        cover = hit / total

        # Length prior: measured answers are ~12 words (median). Penalise spans
        # far from that, gently, so we neither return one word nor a paragraph.
        n = len(span_tokens)
        length_pen = math.exp(
            -((math.log(max(n, 1) / _TARGET_WORDS.get(atype, 12.0))) ** 2) / 2.0)

        prior = 1.0
        joined = " ".join(span_tokens)
        if atype == AnswerType.NUMERIC:
            prior = 1.35 if _DIGITS.search(joined) else 0.75
        elif atype in (AnswerType.PERSON, AnswerType.LOCATION, AnswerType.ENTITY):
            caps = sum(1 for t in span_tokens if t[:1].isupper())
            prior = 1.0 + 0.25 * min(caps, 3) / 3.0
        return cover * length_pen * prior

    def _score_subspan(self, span: list[str], span_at: int, sent: list[str],

                       q_tokens: set[str], atype: str) -> float:
        """Score a candidate answer span INSIDE the winning sentence."""
        n = len(span)
        if n == 0:
            return 0.0
        joined = " ".join(span)

        # 1. does it look like the requested answer type?
        if atype == AnswerType.NUMERIC:
            fit = 1.0 if _DIGITS.search(joined) else 0.05
        elif atype in (AnswerType.PERSON, AnswerType.LOCATION, AnswerType.ENTITY):
            caps = sum(1 for t in span if t[:1].isupper())
            fit = 0.20 + 0.80 * min(caps, 3) / 3.0
        else:
            fit = 1.0

        # 2. the answer is not the question -- penalise echoing query terms back
        overlap = len({_stem(_norm(t)) for t in span} & q_tokens) / n
        novelty = 1.0 - 0.70 * overlap

        # 3. answers sit near the query terms they answer
        anchors = [i for i, t in enumerate(sent) if _stem(_norm(t)) in q_tokens]
        if anchors:
            dist = min(abs(span_at - a) for a in anchors)
            prox = 1.0 / (1.0 + 0.12 * dist)
        else:
            prox = 0.6

        # 4. length prior, from the MEASURED median answer length per type
        #    (extractability.py over 42,000 real queries). Guessing "short answer
        #    = 4 words" produced spans like "fly 30 to 55" where the gold was
        #    "30 to 55 mph" -- the data says otherwise.
        target = _TARGET_WORDS.get(atype, 12.0)
        length = math.exp(-((math.log(max(n, 1) / target)) ** 2) / 2.0)

        # 5. phrase boundaries. Arbitrary word windows produce spans like
        #    "and dive at over" -- grammatical nonsense that tanks token-F1 and
        #    reads badly when spoken aloud. Real answers begin and end at phrase
        #    boundaries, so penalise spans that start or stop on a function word.
        boundary = 1.0
        if _norm(span[-1]) in _STOP:
            boundary *= 0.30
        if _norm(span[0]) in _STOP:
            boundary *= 0.60

        return fit * novelty * prox * length * boundary

    def read(self, query: str, passages: Sequence[Passage]) -> Span:
        if not passages:
            return Span("", 0.0, "")
        atype = detect_answer_type(query)
        texts = [p.text for p in passages]
        idf = _idf(texts)
        q_tokens = set(_norm_tokens(query))

        # Normalise the retrieval scores across THIS candidate set: reranker
        # scales differ per model and per query, so only the relative order is
        # meaningful.
        raw = [float(p.score) for p in passages]
        lo, hi = min(raw), max(raw)
        rng = hi - lo

        cands: list[_Cand] = []
        for p in passages:
            if self.prior_weight <= 0 or rng <= 0:
                prior_mult = 1.0
            else:
                rel = (float(p.score) - lo) / rng          # [0,1] within the set
                prior_mult = (1.0 - self.prior_weight) + self.prior_weight * rel
            words = words_of(p.text)
            norm_words = _norm_tokens(p.text)
            if len(norm_words) != len(words):        # normalisation split a token
                norm_words = [_stem(_norm(w)) or w.lower() for w in words]
            cursor = 0
            for sent in split_sentences(p.text):
                n = len(words_of(sent))
                if n == 0:
                    continue
                s, e = cursor, cursor + n
                cursor = e
                sc = self._score_tokens(norm_words[s:e], q_tokens, idf, atype) * prior_mult
                cands.append(_Cand(" ".join(words[s:e]), sc, p.chunk_id, s, e))

        if not cands:
            return Span("", 0.0, passages[0].chunk_id)
        cands.sort(key=lambda c: -c.score)
        best = cands[0]

        # For NUMERIC questions, prefer a competitive candidate that actually
        # contains a number. Query terms often do not appear near the figure
        # ("how fast does an eagle travel" vs "Eagles fly 30 to 55 mph"), so
        # pure overlap picks the wrong sentence.
        if atype == AnswerType.NUMERIC and not _DIGITS.search(best.text):
            withnum = [c for c in cands if _DIGITS.search(c.text)]
            if withnum and withnum[0].score >= 0.35 * best.score:
                best = withnum[0]

        # Short-answer types: tighten to a sub-span of the winning sentence.
        # MEASURED CONTEXT: gold answers average 10-18 words depending on type
        # (extractability.py), so a whole sentence is often already right. We only
        # tighten when a sub-span scores clearly better, and never for DESCRIPTION
        # -- 52% of traffic, mean answer 18.3 words, i.e. about a sentence.
        sentence_span = best
        if atype != AnswerType.DESCRIPTION and self.answer_mode == "span":
            p = next((x for x in passages if x.chunk_id == best.chunk_id), passages[0])
            words = words_of(p.text)[best.start:best.end]
            if 1 < len(words) <= 80:
                sub, sub_sc = None, 0.0
                for i in range(len(words)):
                    for L in range(self.min_span, min(self.max_span, len(words) - i) + 1):
                        sc = self._score_subspan(words[i:i + L], i, words, q_tokens, atype)
                        if sc > sub_sc:
                            sub_sc, sub = sc, (i, i + L)
                # Only accept a decisive sub-span; otherwise the sentence is safer.
                if sub and sub_sc >= 0.45:
                    i, j = sub
                    best = _Cand(" ".join(words[i:j]), best.score, best.chunk_id,
                                 best.start + i, best.start + j)

        # Confidence reflects SENTENCE selection -- did we find the right place --
        # because that is what the span's correctness actually depends on.
        runner = cands[1].score if len(cands) > 1 else 0.0
        conf = self._confidence(sentence_span.score, runner)
        return Span(best.text, conf, best.chunk_id, best.start, best.end)

    def _confidence(self, best: float, runner: float) -> float:
        """Margin -> [0,1]. Absolute scores are not comparable across queries;

        the gap to the runner-up is."""
        if best <= 0:
            return 0.0
        margin = (best - runner) / best          # relative margin in [0,1]
        strength = min(1.0, best)                 # did anything match at all
        return 1.0 / (1.0 + math.exp(-self.scale * (0.5 * margin + 0.5 * strength - 0.5)))

    # router.ReaderFn
    def __call__(self, query: str, passages: Sequence[Passage]) -> Span:
        return self.read(query, passages)


class NeuralSpanReader:
    """bge-m3 encoder + start/end heads. Falls back to lexical without a checkpoint.



    Kept deliberately thin: the router only needs (text, score, chunk_id), so the

    model can be swapped or trained later without touching anything downstream.

    """
    name = "neural"

    def __init__(self, model_path: str | None = None, checkpoint: str | None = None,

                 device: str = "cuda", max_len: int = 384,

                 fallback: LexicalSpanReader | None = None):
        self.fallback = fallback or LexicalSpanReader()
        self.model = None
        if not (model_path and checkpoint):
            return
        try:
            import torch
            from transformers import AutoModel, AutoTokenizer
            self.torch = torch
            self.tok = AutoTokenizer.from_pretrained(model_path)
            enc = AutoModel.from_pretrained(
                model_path, torch_dtype=torch.float16 if "cuda" in device else torch.float32)
            hidden = enc.config.hidden_size
            self.head = torch.nn.Linear(hidden, 2)
            state = torch.load(checkpoint, map_location="cpu")
            enc.load_state_dict(state["encoder"], strict=False)
            self.head.load_state_dict(state["head"])
            self.model = enc.to(device).eval()
            self.head = self.head.to(device).to(self.model.dtype).eval()
            self.device, self.max_len = device, max_len
        except Exception as exc:
            print(f"  reader: no checkpoint loaded ({exc}); using lexical fallback")
            self.model = None

    def read(self, query: str, passages: Sequence[Passage]) -> Span:
        if self.model is None or not passages:
            return self.fallback.read(query, passages)
        torch = self.torch
        best = None
        with torch.inference_mode():
            for p in passages[:4]:                      # deeper costs latency, not quality
                enc = self.tok(query, p.text, truncation="only_second",
                               max_length=self.max_len, return_offsets_mapping=True,
                               return_tensors="pt").to(self.device)
                offsets = enc.pop("offset_mapping")[0].tolist()
                h = self.model(**enc).last_hidden_state
                logits = self.head(h)[0].float()
                start_lp = torch.log_softmax(logits[:, 0], -1)
                end_lp = torch.log_softmax(logits[:, 1], -1)
                n = start_lp.shape[0]
                # best (i <= j) within a 40-token window
                scores = start_lp[:, None] + end_lp[None, :]
                mask = torch.triu(torch.ones(n, n, device=scores.device), 0) - \
                       torch.triu(torch.ones(n, n, device=scores.device), 41)
                scores = scores.masked_fill(mask <= 0, -1e9)
                flat = int(scores.argmax())
                i, j = flat // n, flat % n
                lp = float(scores.view(-1)[flat])
                if best is None or lp > best[0]:
                    a, b = offsets[i][0], offsets[j][1]
                    best = (lp, p.text[a:b], p.chunk_id, i, j)
        if best is None:
            return self.fallback.read(query, passages)
        lp, text, cid, i, j = best
        return Span(text.strip(), float(math.exp(min(0.0, lp))), cid, i, j)

    def __call__(self, query: str, passages: Sequence[Passage]) -> Span:
        return self.read(query, passages)