File size: 5,453 Bytes
406a5e6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Second retrieval round seeded by JUDGE-CONFIRMED papers.



Everything measured here says the remaining loss is in two places: papers we

never retrieve at all, and selection accuracy. Blind widening does not fix

the first -- a wider fanout raises pool recall (0.533 -> 0.644) yet measured

NEUTRAL-to-negative end to end, because the extra candidates dilute the

window the selector can afford to look at.



The difference here is WHERE we widen. After the pool judge runs, we know which

papers it scored as perfectly relevant. Their citation neighbourhood is not a

blind widening: a paper cited by, or citing, a confirmed-relevant paper is

drawn from a distribution far denser in golds than another BM25 page. So this

round expands only around confirmed hits, then judges the newcomers with the

same cheap selector before letting any of them into the submission.



That is what Ai2's own "diligent" mode buys with its long search budget, in a

shape that stays cheap here: corpus calls are inexpensive compared to LLM

calls, so the whole expansion costs wall-clock time plus one extra judge pass

over the NEW papers only.



Integrity: seeds come from the judge's scores on retrieved text, never from

gold ids; emitted evidence is the paper's own verbatim abstract.



Env: PFBMAX_DILIGENT=1 enables, PFBMAX_DILIGENT_SEEDS (default 8),

     PFBMAX_DILIGENT_PER_SEED (default 60), PFBMAX_DILIGENT_JUDGE (default 200).

"""

from __future__ import annotations

import os

DOC_CHARS = 1500


def _envi(name, default):
    raw = (os.environ.get(name) or "").strip()
    try:
        return int(raw) if raw else default
    except ValueError:
        return default


def _cid(paper):
    for attr in ("corpusId", "corpus_id"):
        v = getattr(paper, attr, None)
        if v:
            return str(v)
    return None


def expand(query, criteria, pool, client, inserted_before, judge,

           trace=None):
    """Return [(cid, evidence, score)] triples of NEW papers the judge rates

    highly.



    The score is included so the caller can merge by QUALITY. An

    earlier version appended newcomers to the TAIL: recall@FULL rose

    (0.1998 -> 0.2078, so the expansion really does find new golds) but the

    score FELL 0.0427, because tail papers cannot reach the top-K window while

    still joining the judged set and dragging nDCG down. Position is the whole

    game when the scored window is K ~ 2P.



    ``judge`` is criterion_judge; it is called only on the newcomers, so the

    marginal cost is one judge pass over ``PFBMAX_DILIGENT_JUDGE`` papers.

    """
    tr = trace if trace is not None else {}
    order = list(pool.ranked())
    if not order:
        return []
    n_seeds = _envi("PFBMAX_DILIGENT_SEEDS", 8)
    per_seed = _envi("PFBMAX_DILIGENT_PER_SEED", 60)
    n_judge = _envi("PFBMAX_DILIGENT_JUDGE", 200)

    seen = set(order)
    seeds = order[:n_seeds]          # pool is judge-sorted at this point
    fresh = {}
    for seed in seeds:
        for direction in ("references", "citations"):
            try:
                papers = client.get_citations(
                    seed, direction=direction, limit=per_seed,
                    fields="corpusId,title,abstract,year") or []
            except Exception:
                papers = []
            for p in papers:
                c = _cid(p)
                if not c or c in seen or c in fresh:
                    continue
                abstract = (getattr(p, "abstract", "") or "").strip()
                title = (getattr(p, "title", "") or "").strip()
                if not abstract:
                    continue          # no verbatim text -> cannot furnish evidence
                fresh[c] = (title, abstract)
    tr["diligent_fresh"] = len(fresh)
    if not fresh:
        return []

    items = list(fresh.items())[:n_judge]
    docs = [(c, (t + ". " + a)[:DOC_CHARS]) for c, (t, a) in items]
    # score each newcomer with the SAME replicated-grader scale the pool was
    # ranked on, so the caller can merge by quality instead of by arrival.
    # Parallel: 200 sequential judge calls is ~5 minutes a query, which makes
    # the round untestable regardless of whether it helps.
    from concurrent.futures import ThreadPoolExecutor
    model = os.environ.get("PFBMAX_CJ_MODEL", "gpt-4o-mini")
    workers = _envi("PFBMAX_CJ_WORKERS", 8)

    def _one(item):
        cid, text = item
        try:
            return cid, judge.score_paper(query, criteria, text, model)
        except Exception:
            return cid, None

    scored = {}
    with ThreadPoolExecutor(max_workers=max(1, workers)) as ex:
        for cid, v in ex.map(_one, docs):
            if v is not None:
                scored[cid] = v
    lookup = dict(items)
    try:
        gate = float(os.environ.get("PFBMAX_DILIGENT_GATE") or 0.66)
    except ValueError:
        gate = 0.66
    keep = _envi("PFBMAX_DILIGENT_KEEP", 60)
    out = []
    for cid in sorted(scored, key=lambda c: -scored[c])[:keep]:
        if scored[cid] < gate:
            break   # below the grader's own "highly relevant" threshold: not worth a slot
        t, a = lookup.get(cid, ("", ""))
        ev = (a or t)[:1200]
        if ev:
            out.append((cid, ev, scored[cid]))
    tr["diligent_kept"] = len(out)
    tr["diligent_judged"] = len(scored)
    return out