File size: 6,877 Bytes
a1dd5ba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""ITM cross-encoder rerank — the discarded head, wired for live queries.

InternVideo2's checkpoint ships an `itm_head` that every load discarded
("Some weights ... were not used"). It is the cross-encoder half of the
model's own retrieval recipe (BLIP-2 arXiv 2301.12597): text tokens
cross-attend to video tokens, a 2-class head reads the fused CLS. It
out-ranks everything else in this store — measured at k=1.5xsupport,
ITM alone 0.38/0.25 against the cosine ensemble's 0.32/0.21 and the
shipped RRF path's 0.29/0.23.

Two measurements decide the shape of this module:

  NO POOLING. Vision tokens are 1,025 x 1,408 per episode = 3.24 GB
  corpus-wide, four times the raw source, so storing them in the store
  is out. Pooling them is worse than out: 16x16 -> 8x8 per frame drops
  Spearman against the full-token score to 0.18, and to NEGATIVE on two
  of three probe queries. Cross-attention does not tolerate a reduced
  key set. So the tokens are a disposable CACHE outside the store's
  byte budget, never a table.

  CASCADE, NOT SCAN. Reranking the top-N of the cheap fused ranking
  reaches the full-scan number exactly: N=500 gives 0.40/0.27, N=150
  already gives 0.38/0.25, and every query with support under ~200 is
  saturated at N=150. So N scales with the ceiling k rather than the
  corpus, which is L7 ("ITM is evidence inside a candidate set, not
  authority over the corpus") arrived at from the cost side.

ITM IS A STAGE, NOT A CHANNEL — and that distinction was measured, not
chosen. Making it a weighted RRF voter and refitting cost 0.38 -> 0.27
for two compounding reasons: RRF converts every channel to RANKS, which
throws away the logit margin's scale, and that scale is where the
cross-encoder's separation lives (the 0.40 offline number came from
adding z-scores, not ranks); and the k-ladder refit then optimized the
ladder mean away from the 1.5x-support operating point the product
metric uses. So ITM stays a rerank STAGE alongside NMS and the
confidence cut - stages the fitter switches on and off rather than
weights - and the fit models it as such.

The cache is content-addressed by (episode, model) and capped; deleting
it costs time, never correctness.
"""
from __future__ import annotations

import hashlib
from pathlib import Path

import numpy as np

_S: dict = {}
# 3.24 GB holds the whole 1,122-episode corpus. This is a DISPOSABLE
# cache under the store's _cache/, not a table: it never enters the
# store's byte ledger, and deleting it costs recompute time, never
# correctness. Override with ELIDEDB_ITM_CACHE_GB.
CACHE_CAP_GB = float(__import__("os").environ.get(
    "ELIDEDB_ITM_CACHE_GB", "4.0"))
NF = 4


def _head():
    """The checkpoint's own 2-class matcher — AutoModel drops it because
    the vendored class never declares the attribute."""
    if "head" in _S:
        return _S["head"], _S["m"], _S["dev"]
    import torch

    from .iv2 import MDIR, load_model
    sd = torch.load(f"{MDIR}/pytorch_model.bin", map_location="cpu",
                    weights_only=True, mmap=True)
    m, dev = load_model()
    h = torch.nn.Linear(sd["itm_head.weight"].shape[1], 2)
    h.load_state_dict({"weight": sd["itm_head.weight"],
                       "bias": sd["itm_head.bias"]})
    h = h.to(dev, m.dtype).eval()
    _S["head"], _S["m"], _S["dev"] = h, m, dev
    return h, m, dev


def _cache_dir(store):
    d = Path(store.dir) / "_cache" / "itm_tokens"
    d.mkdir(parents=True, exist_ok=True)
    return d


def _cache_path(store, stream, ts):
    key = hashlib.sha1(f"{stream}|{ts}|iv2-1b|{NF}".encode()).hexdigest()
    return _cache_dir(store) / f"{key}.npy"


def _evict(store):
    """Keep the disposable cache under CACHE_CAP_GB, oldest first."""
    d = _cache_dir(store)
    files = sorted(d.glob("*.npy"), key=lambda p: p.stat().st_mtime)
    total = sum(p.stat().st_size for p in files)
    cap = CACHE_CAP_GB * 1e9
    while total > cap and files:
        p = files.pop(0)
        total -= p.stat().st_size
        try:
            p.unlink()
        except OSError:
            pass


def _vision_tokens(store, frames_tbl, key):
    """Cached (1, T, C) vision tokens for one episode."""
    import cv2
    import torch

    from .iv2 import V_MEAN, V_STD
    from .video import FrameSet
    s, a, b = key
    p = _cache_path(store, s, a)
    _, m, dev = _head()
    if p.exists():
        arr = np.load(p)
        return torch.from_numpy(arr).to(dev, m.dtype)
    import pyarrow.compute as pc
    sel = frames_tbl.filter(pc.and_(
        pc.equal(frames_tbl.column("stream"), s),
        pc.and_(pc.greater_equal(frames_tbl.column("ts"), a),
                pc.less_equal(frames_tbl.column("ts"), b))))
    if len(sel) < 2:
        return None
    pi = np.linspace(0, len(sel) - 1, min(NF, len(sel))).round().astype(int)
    try:
        dec = FrameSet(store, "frames", sel.take(pi)).decode(width=224)
    except Exception:
        return None
    fr = [f for _, f in sorted(dec)]
    if len(fr) < 2:
        return None
    fs = [cv2.resize(f, (224, 224)) for f in fr]
    x = (np.stack(fs).astype(np.float32) / 255.0 - V_MEAN) / V_STD
    px = torch.from_numpy(x).permute(0, 3, 1, 2)[None].to(dev, m.dtype)
    with torch.no_grad():
        vis, _ = m.encode_vision(px, test=True)
    np.save(p, vis.cpu().numpy())
    _evict(store)
    return vis


def itm_scores(store, text, keys):
    """Logit margin P(match) - P(no match) for each candidate episode.

    Returns np.array aligned with `keys`; NaN where frames are
    undecodable. The vision pass dominates and is query-independent, so
    a repeated query over the same candidates is nearly free."""
    import torch
    head, m, dev = _head()
    frames_tbl = store.table("frames").scan()
    tok = m.tokenizer(text, padding="max_length", truncation=True,
                      max_length=m._config.max_txt_l,
                      return_tensors="pt").to(dev)
    out = np.full(len(keys), np.nan, np.float32)
    for i, k in enumerate(keys):
        vis = _vision_tokens(store, frames_tbl, k)
        if vis is None:
            continue
        with torch.no_grad():
            vam = torch.ones(vis.shape[:2], dtype=torch.long, device=dev)
            o = m.get_text_encoder()(
                tok.input_ids, attention_mask=tok.attention_mask,
                encoder_hidden_states=vis, encoder_attention_mask=vam,
                return_dict=True, mode="multi_modal")
            lg = head(o.last_hidden_state[:, 0]).float()[0]
        out[i] = float(lg[1] - lg[0])
    return out


def rerank_depth(k_max, n_total):
    """Candidates to rerank. Measured: N=150 saturates every query with
    support under ~200; only the 247-support query needed 500. Scaling
    with the ceiling rather than the corpus keeps the cost proportional
    to what the caller actually asked for."""
    return int(min(max(150, 2 * k_max), n_total))