bev-decisions 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bev/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """Bev: open-weight models that answer typed questions about a document with calibrated probabilities.
2
+
3
+ import bev
4
+ model = bev.load() # Bev-Large (laptop or GPU); bev.load(bev.PRO_MODEL) for Bev-Pro (GPU)
5
+ answers = model.decide(
6
+ "Invoice INV-2291. Total: $9,412.50. Approval limit: $8,900.",
7
+ {"over_limit": bev.presets.over_limit("$8,900")},
8
+ )
9
+ sure, review = bev.gate(answers, threshold=0.8)
10
+
11
+ Question types: "noul" (yes/no), "choice" (criteria: {key: description}), "score" (criteria: levels, lowest first).
12
+ Up to 16 questions per call, up to 300 options per question, 4,096 input tokens.
13
+ """
14
+
15
+ from . import presets
16
+ from .evaluate import evaluate, evaluate_file
17
+ from .gating import confidence, gate, value
18
+ from .model import DEFAULT_MODEL, PRO_MODEL, Bev, load
19
+ from .router import Router
20
+ from .schema import decide_as, questions_for
21
+
22
+ __all__ = ["DEFAULT_MODEL", "PRO_MODEL", "Bev", "Router", "confidence", "decide_as", "evaluate", "evaluate_file", "gate", "load",
23
+ "presets", "questions_for", "value"]
24
+ __version__ = "0.2.0"
bev/_core/__init__.py ADDED
File without changes
bev/_core/features.py ADDED
@@ -0,0 +1,313 @@
1
+ """Typed values and facts read from the document and the questions: amounts, dates, times, numbers, and the comparisons,
2
+ sums, counts and time windows they imply. The network uses them alongside the text."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import datetime as dt
7
+ import math
8
+ import re
9
+ from dataclasses import dataclass, field
10
+
11
+ MONTHS = {m: i + 1 for i, m in enumerate(["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"])}
12
+ STOP = set(
13
+ "a an the of to in on at by for from with and or is are was were be been did does do how many much what which who whose whether any at least more than less each this that these those it its as per within single there their ask asks".split()
14
+ )
15
+ _money = re.compile("(?:USD\\s*|\\$)\\s?(\\d[\\d,]*(?:\\.\\d+)?)(k)?|(\\d[\\d,]*(?:\\.\\d+)?)\\s*USD", re.I)
16
+ _iso_ts = re.compile("\\b(\\d{4})-(\\d{2})-(\\d{2})(?:[ T](\\d{2}):(\\d{2})(?::(\\d{2}))?)?\\b")
17
+ _dmy = re.compile("\\b(\\d{1,2})\\s+([A-Za-z]{3,9})\\.?\\s+(\\d{4})\\b")
18
+ _mdy = re.compile("\\b([A-Za-z]{3,9})\\.?\\s+(\\d{1,2})(?:st|nd|rd|th)?,?\\s+(\\d{4})\\b")
19
+ _clock = re.compile("(?<![\\d-])\\b(\\d{1,2}):(\\d{2})\\b(?!:\\d{2}\\s*\\d)")
20
+ _num = re.compile("(?<![\\w.])(\\d+(?:\\.\\d+)?)(?=(?:ms|%|\\s|$|[^\\w.]))")
21
+ _ip = re.compile("\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b")
22
+ _word = re.compile("[A-Za-z][A-Za-z0-9_\\-]*")
23
+
24
+
25
+ @dataclass
26
+ class Value:
27
+ kind: str
28
+ value: float
29
+ start: int
30
+ end: int
31
+ line: int
32
+
33
+
34
+ @dataclass
35
+ class Fact:
36
+ kind: str
37
+ feats: list[float]
38
+ spans: list[tuple[int, int]] = field(default_factory=list)
39
+ where: str = "state"
40
+ result: float | None = None
41
+
42
+
43
+ FACT_KINDS = ["cmp", "pair", "sum", "count", "window", "run"]
44
+ FACT_DIM = 8
45
+ VAL_DIM = 4 + 2 + 16
46
+
47
+
48
+ def _month(s: str) -> int | None:
49
+ return MONTHS.get(s[:3].lower())
50
+
51
+
52
+ def _minutes(d: dt.datetime) -> float:
53
+ return (d - dt.datetime(2000, 1, 1)).total_seconds() / 60
54
+
55
+
56
+ def _line_of(text: str) -> list[int]:
57
+ starts = [0] + [m.end() for m in re.finditer("\n", text)]
58
+ return starts
59
+
60
+
61
+ def extract(text: str) -> list[Value]:
62
+ out: list[Value] = []
63
+ taken: list[tuple[int, int]] = []
64
+ starts = _line_of(text)
65
+
66
+ def line(pos: int) -> int:
67
+ lo, hi = (0, len(starts) - 1)
68
+ while lo < hi:
69
+ mid = (lo + hi + 1) // 2
70
+ if starts[mid] <= pos:
71
+ lo = mid
72
+ else:
73
+ hi = mid - 1
74
+ return lo
75
+
76
+ def free(a: int, b: int) -> bool:
77
+ return all((b <= x or a >= y for x, y in taken))
78
+
79
+ def add(kind: str, v: float, a: int, b: int) -> None:
80
+ if free(a, b):
81
+ out.append(Value(kind, v, a, b, line(a)))
82
+ taken.append((a, b))
83
+
84
+ for m in _money.finditer(text):
85
+ raw = m.group(1) or m.group(3)
86
+ v = float(raw.replace(",", ""))
87
+ if m.group(2):
88
+ v *= 1000
89
+ add("money", v, m.start(), m.end())
90
+ for m in _iso_ts.finditer(text):
91
+ y, mo, d, hh, mm, ss = m.groups()
92
+ try:
93
+ t = dt.datetime(int(y), int(mo), int(d), int(hh or 0), int(mm or 0), int(ss or 0))
94
+ except ValueError:
95
+ continue
96
+ add("date", _minutes(t), m.start(), m.end())
97
+ for rx, order in ((_dmy, "dmy"), (_mdy, "mdy")):
98
+ for m in rx.finditer(text):
99
+ g = m.groups()
100
+ d, mo, y = (g[0], g[1], g[2]) if order == "dmy" else (g[1], g[0], g[2])
101
+ mon = _month(mo)
102
+ if mon is None:
103
+ continue
104
+ try:
105
+ add("date", _minutes(dt.datetime(int(y), mon, int(d))), m.start(), m.end())
106
+ except ValueError:
107
+ continue
108
+ for m in _clock.finditer(text):
109
+ add("time", int(m.group(1)) * 60 + int(m.group(2)), m.start(), m.end())
110
+ for m in _num.finditer(text):
111
+ add("number", float(m.group(1)), m.start(), m.end())
112
+ out.sort(key=lambda v: v.start)
113
+ return out
114
+
115
+
116
+ def key_terms(question: str, state_vocab: set[str]) -> list[str]:
117
+ terms = []
118
+ for w in _word.findall(question.lower()):
119
+ if w in STOP or len(w) < 3:
120
+ continue
121
+ stem = w[:-1] if w.endswith("s") and len(w) > 4 else w
122
+ if any(v.startswith(stem) for v in state_vocab):
123
+ terms.append(stem)
124
+ terms += _ip.findall(question)
125
+ return sorted(set(terms))
126
+
127
+
128
+ def _lines_with(lines: list[str], terms: list[str]) -> list[int]:
129
+ low = [x.lower() for x in lines]
130
+ return [i for i, x in enumerate(low) if all(t.lower() in x for t in terms)]
131
+
132
+
133
+ def _signed_log(x: float) -> float:
134
+ return math.copysign(math.log1p(abs(x)), x)
135
+
136
+
137
+ def facts(state: str, question: str) -> list[Fact]:
138
+ sv, qv = (extract(state), extract(question))
139
+ lines = state.split("\n")
140
+ vocab = {w for w in _word.findall(state.lower())}
141
+ terms = key_terms(question, vocab)
142
+ return _question_facts(sv, qv, lines, vocab, terms, question)
143
+
144
+
145
+ def state_facts(state: str) -> list[Fact]:
146
+ sv = extract(state)
147
+ fs: list[Fact] = []
148
+ for kind in ("date", "money"):
149
+ vals = [s for s in sv if s.kind == kind][:12]
150
+ for i in range(len(vals)):
151
+ for j in range(len(vals)):
152
+ if i != j:
153
+ diff = vals[j].value - vals[i].value
154
+ unit = diff / 1440 if kind == "date" else diff
155
+ fs.append(
156
+ Fact(
157
+ "pair",
158
+ [float(diff > 0), float(diff == 0), _signed_log(unit), float(kind == "date"), 0, 0, 0, 0],
159
+ [(vals[i].start, vals[i].end), (vals[j].start, vals[j].end)],
160
+ )
161
+ )
162
+ return fs
163
+
164
+
165
+ def _question_facts(sv: list[Value], qv: list[Value], lines: list[str], vocab: set[str], terms: list[str], question: str) -> list[Fact]:
166
+ fs: list[Fact] = []
167
+ for q in qv:
168
+ same = [s for s in sv if s.kind == q.kind]
169
+ for s in same[:32]:
170
+ diff = s.value - q.value
171
+ fs.append(
172
+ Fact(
173
+ "cmp",
174
+ [float(diff > 0), float(diff == 0), _signed_log(diff), _signed_log(s.value), 0, 0, 0, 0],
175
+ [(s.start, s.end), (q.start, q.end)],
176
+ "both",
177
+ )
178
+ )
179
+ money = [s for s in sv if s.kind == "money"]
180
+ if money:
181
+ tot = sum(s.value for s in money)
182
+ for q in [q for q in qv if q.kind == "money"] or [None]:
183
+ ref = q.value if q else 0.0
184
+ fs.append(
185
+ Fact(
186
+ "sum",
187
+ [
188
+ float(tot > ref) if q else 0.0,
189
+ _signed_log(tot - ref),
190
+ _signed_log(tot),
191
+ float(len(money)),
192
+ float(q is not None),
193
+ 0,
194
+ 0,
195
+ 0,
196
+ ],
197
+ [(q.start, q.end)] if q else [],
198
+ "question" if q else "state",
199
+ )
200
+ )
201
+ quoted = [m.lower() for m in re.findall("'([^']+)'|\\\"([^\\\"]+)\\\"", question) for m in m if m]
202
+ if terms or quoted:
203
+ k = len(_lines_with(lines, sorted(set(terms + quoted))))
204
+ fs.append(Fact("count", [float(k), math.log1p(k), float(len(terms)), 0, 0, 0, 0, 0], result=float(k)))
205
+ allw = sorted(
206
+ {w[:-1] if w.endswith("s") and len(w) > 4 else w for w in _word.findall(question.lower()) if w not in STOP and len(w) > 2}
207
+ | set(quoted)
208
+ | set(_ip.findall(question))
209
+ )
210
+ k_all = len(_lines_with(lines, allw))
211
+ fs.append(Fact("count", [float(k_all), math.log1p(k_all), float(len(allw)), 0, 0, 1.0, 0, 0], result=float(k_all)))
212
+ for drop in allw:
213
+ sub = [w for w in allw if w != drop]
214
+ if sub:
215
+ kd = len(_lines_with(lines, sub))
216
+ fs.append(Fact("count", [float(kd), math.log1p(kd), float(len(sub)), 0, 0, 0, 1.0, 0], result=float(kd)))
217
+ for t in terms:
218
+ kt = len(_lines_with(lines, [t]))
219
+ fs.append(Fact("count", [float(kt), math.log1p(kt), 1.0, 1.0, 0, 0, 0, 0], result=float(kt)))
220
+ for q in qv:
221
+ same = [s for s in sv if s.kind == q.kind]
222
+ if same:
223
+ above = sum(s.value > q.value for s in same)
224
+ fs.append(
225
+ Fact(
226
+ "count",
227
+ [float(above), math.log1p(above), 0, 0, 1.0, float(len(same)), 0, 0],
228
+ [(q.start, q.end)],
229
+ "question",
230
+ result=float(above),
231
+ )
232
+ )
233
+ wq = re.search("(\\d+)\\s*-?\\s*minute", question)
234
+ if wq and terms:
235
+ w = float(wq.group(1))
236
+ stamps: dict[str, list[float]] = {}
237
+ content_terms = [t for t in terms if not _ip.fullmatch(t)]
238
+ for i in _lines_with(lines, content_terms):
239
+ ts = [v.value for v in sv if v.line == i and v.kind == "date"]
240
+ ip = _ip.findall(lines[i])
241
+ if ts:
242
+ stamps.setdefault(ip[0] if ip else "*", []).append(ts[0])
243
+ best = 0
244
+ for ts in stamps.values():
245
+ ts.sort()
246
+ j = 0
247
+ for i in range(len(ts)):
248
+ while ts[i] - ts[j] > w:
249
+ j += 1
250
+ best = max(best, i - j + 1)
251
+ fs.append(Fact("window", [float(best), math.log1p(best), w, 0, 0, 0, 0, 0], result=float(best)))
252
+ ents = [t for t in terms if t in vocab or "-" in t]
253
+ for q in [q for q in qv if q.kind == "number"]:
254
+ for ent in ents[:3]:
255
+ idx = _lines_with(lines, [ent])
256
+ if len(idx) < 2:
257
+ continue
258
+ per_line = [[v.value for v in sv if v.line == i and v.kind == "number"] for i in idx]
259
+ width = min((len(x) for x in per_line), default=0)
260
+ for col in range(min(width, 4)):
261
+ run = best = 0
262
+ for nums in per_line:
263
+ run = run + 1 if nums[col] > q.value else 0
264
+ best = max(best, run)
265
+ fs.append(
266
+ Fact(
267
+ "run",
268
+ [float(best), math.log1p(best), float(col), float(len(idx)), 0, 0, 0, 0],
269
+ [(q.start, q.end)],
270
+ "question",
271
+ result=float(best),
272
+ )
273
+ )
274
+ return fs[:128]
275
+
276
+
277
+ def option_feats(state: str, options: list[str]) -> list[list[float]]:
278
+ low = state.lower()
279
+ lines = low.split("\n")
280
+ vocab = set(_word.findall(low))
281
+ out = []
282
+ for o in options:
283
+ ol = o.lower()
284
+ toks = [t for t in _word.findall(ol) if t not in STOP]
285
+ overlap = sum(t in vocab for t in toks) / max(len(toks), 1)
286
+ try:
287
+ num = float(o)
288
+ except ValueError:
289
+ num = -1.0
290
+ first = 1.0
291
+ idx = [i for i, x in enumerate(lines) if ol in x] if len(ol) > 2 else []
292
+ if len(idx) >= 4:
293
+ cols = [[float(n) for n in _num.findall(lines[i])] for i in idx]
294
+ width = min(len(c) for c in cols)
295
+ for col in range(min(width, 4)):
296
+ series = [c[col] for c in cols]
297
+ head = sorted(series[: max(3, len(series) // 4)])
298
+ base = head[len(head) // 2]
299
+ hit = next((k for k, v in enumerate(series) if base > 0 and v > 2.5 * base and (v - base > 0.5)), None)
300
+ if hit is not None:
301
+ first = min(first, idx[hit] / max(len(lines), 1))
302
+ out.append([float(ol in low), overlap, num, first, float(num >= 0)])
303
+ return out
304
+
305
+
306
+ def value_features(v: Value) -> list[float]:
307
+ """A typed value as 22 numbers: its kind, signed log magnitude, a day ordinal for dates, and 16 Fourier features."""
308
+ kinds = ["money", "date", "time", "number"]
309
+ raw = math.log1p(abs(v.value)) if math.isfinite(v.value) else 709.78 if not math.isnan(v.value) else 0.0
310
+ lv = math.copysign(min(raw, 709.78), v.value) if not math.isnan(v.value) else 0.0
311
+ four = [f(lv * 2**k / 4) for k in range(8) for f in (math.sin, math.cos)]
312
+ ordinal = (v.value / 1440 - 9500) / 1000 if v.kind == "date" else 0.0
313
+ return [float(v.kind == k) for k in kinds] + [lv / 10, ordinal] + four
bev/_core/network.py ADDED
@@ -0,0 +1,342 @@
1
+ """The Bev-Large network (inference only).
2
+
3
+ A ModernBERT encoder reads the questions, their options and the document in one sequence; typed values (amounts, dates,
4
+ times, numbers) are added to the token embeddings. Each question becomes a vector that gathers evidence from the
5
+ document, is refined over three recursive steps together with the facts read from the text, and scores its options.
6
+ The three steps are combined with learned halting weights, and the per-type temperature turns scores into calibrated
7
+ probabilities.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import bisect
13
+ import math
14
+ import re
15
+
16
+ import torch
17
+ from torch import nn
18
+
19
+ from .features import FACT_DIM, FACT_KINDS, VAL_DIM, extract, facts, option_feats, state_facts, value_features
20
+
21
+ D = 256 # width of the decision layers
22
+ OPT_FEATS = 5
23
+ STEPS = 3
24
+ QTYPES = {"noul": 0, "choice": 1, "score": 2}
25
+ MIN_STATE_TOKENS = 160 # long option lists are shortened so that at least this much of the document fits
26
+ ALL_QUESTIONS = 15 # fact tag for facts about the document as a whole
27
+
28
+
29
+ class Prompt:
30
+ """One request as a single token sequence: [Q1] type: instructions [O] option ... [STATE] document."""
31
+
32
+ def __init__(self, tok, item: dict, max_len: int) -> None:
33
+ self.names = list(item["questions"])
34
+ qs = item["questions"]
35
+ self.late: set[str] = set() # questions whose options are encoded separately (too many to fit)
36
+ self.compact: set[str] = set() # questions whose option descriptions are shortened
37
+ while True:
38
+ self._build(item)
39
+ n_head = len(tok(self.head, add_special_tokens=False)["input_ids"])
40
+ joint = [n for n in self.names if qs[n]["type"] != "noul" and n not in self.late]
41
+ if n_head <= max_len - MIN_STATE_TOKENS or not joint:
42
+ break
43
+ big = max(joint, key=lambda n: len(qs[n]["criteria"]))
44
+ (self.late if big in self.compact else self.compact).add(big)
45
+ self.state_off = len(self.head)
46
+ enc = tok(self.head + item["state"], truncation=True, max_length=max_len, return_offsets_mapping=True)
47
+ self.ids, self.offsets = enc["input_ids"], enc["offset_mapping"]
48
+ self.ends, m = [], 0
49
+ for _, e in self.offsets:
50
+ m = max(m, e)
51
+ self.ends.append(m)
52
+ self.values = [(v, self.state_off) for v in extract(item["state"])]
53
+ for base, txt in self.value_texts:
54
+ self.values += [(v, base) for v in extract(txt)]
55
+
56
+ def _build(self, item: dict) -> None:
57
+ parts: list[str] = []
58
+ pos = 0
59
+ self.qspans, self.instr_off, self.ospans, self.value_texts = [], [], {}, []
60
+
61
+ def add(s: str) -> int:
62
+ nonlocal pos
63
+ start = pos
64
+ parts.append(s)
65
+ pos += len(s)
66
+ return start
67
+
68
+ for i, n in enumerate(self.names):
69
+ q = item["questions"][n]
70
+ add(f"[Q{i + 1}] {q['type']}: ")
71
+ s = add(q["instructions"])
72
+ self.instr_off.append(s)
73
+ self.value_texts.append((s, q["instructions"]))
74
+ self.qspans.append((s, pos))
75
+ crit = q.get("criteria")
76
+ if q["type"] == "noul":
77
+ c = crit if isinstance(crit, dict) else {}
78
+ spans = []
79
+ for txt in (f"yes: {c.get('true', 'true')}", f"no: {c.get('false', 'false')}"):
80
+ add(" [O] ")
81
+ o = add(txt)
82
+ spans.append((o, o + len(txt)))
83
+ self.ospans[n] = spans
84
+ elif n not in self.late:
85
+ spans = []
86
+ for k, desc in crit.items():
87
+ add(" [O] ")
88
+ txt = f"{k}: {desc}"
89
+ if n in self.compact:
90
+ same = re.sub(r"[\W_]+", " ", str(k)).strip().lower() == re.sub(r"[\W_]+", " ", str(desc)).strip().lower()
91
+ txt = str(k) if same else f"{k}: {' '.join(str(desc).split()[:6])}"
92
+ o = add(txt)
93
+ spans.append((o, o + len(txt)))
94
+ self.value_texts.append((o, txt))
95
+ self.ospans[n] = spans
96
+ add(" ")
97
+ add("[STATE] ")
98
+ self.head = "".join(parts)
99
+
100
+ def tokens_in(self, a: int, b: int) -> list[int]:
101
+ """Token indices covering characters a..b of the prompt."""
102
+ out = []
103
+ k = bisect.bisect_right(self.ends, a)
104
+ while k < len(self.offsets) and self.offsets[k][0] < b:
105
+ s, e = self.offsets[k]
106
+ if e > s:
107
+ out.append(k)
108
+ k += 1
109
+ return out
110
+
111
+
112
+ class BevNetwork(nn.Module):
113
+ """Bev-Large: ModernBERT-large encoder plus the decision layers. Build it from the encoder configuration that ships
114
+ with the weights (no download), then load model.safetensors."""
115
+
116
+ def __init__(self, encoder_dir: str) -> None:
117
+ super().__init__()
118
+ from transformers import AutoConfig, AutoModel
119
+
120
+ self.enc = AutoModel.from_config(AutoConfig.from_pretrained(encoder_dir), attn_implementation="sdpa")
121
+ H = self.enc.config.hidden_size
122
+ self.H, self.pad_id = H, self.enc.config.pad_token_id
123
+ self.val_proj = nn.Linear(VAL_DIM, H)
124
+ self.q_proj, self.o_proj, self.o_late = nn.Linear(H, D), nn.Linear(H, D), nn.Linear(H, D)
125
+ self.qtype, self.qid = nn.Embedding(3, D), nn.Embedding(16, D)
126
+ self.ev_k, self.ev_v, self.ev_out = nn.Linear(H, D), nn.Linear(H, D), nn.Linear(2 * D, D)
127
+ self.fact_in = nn.Sequential(nn.Linear(FACT_DIM + len(FACT_KINDS) + 2 * H, D), nn.GELU(), nn.Linear(D, D))
128
+ self.core = nn.TransformerEncoderLayer(D, 8, 4 * D, dropout=0.0, batch_first=True, norm_first=True)
129
+ self.core_gate = nn.Parameter(torch.zeros(1))
130
+ self.halt = nn.Linear(D, 1)
131
+ self.opt_mlp = nn.Sequential(nn.Linear(3 * D, D), nn.GELU(), nn.Linear(D, 1))
132
+ self.op_gate, self.opt_lex = nn.Linear(D, 1), nn.Linear(OPT_FEATS, 1)
133
+ self.fact_att = nn.Linear(D, D, bias=False)
134
+ self.num_w = nn.Parameter(torch.tensor(1.0))
135
+ self.fact_q, self.fact_mix = nn.Linear(D, D, bias=False), nn.Linear(D, D)
136
+ self.dec_sel = nn.Sequential(nn.Linear(3 * D, D), nn.GELU(), nn.Linear(D, 1))
137
+ self.dec_none, self.dec_w = nn.Linear(D, 1), nn.Linear(D, 1)
138
+ self.temp = nn.ParameterDict({t: nn.Parameter(torch.tensor(1.0), requires_grad=False) for t in QTYPES})
139
+
140
+ # yes/no questions whose answer follows from an exact comparison of numbers or dates found in the text
141
+ def _exact_yes_no(self, base: torch.Tensor, zq: torch.Tensor, Fc: torch.Tensor, bits: torch.Tensor) -> torch.Tensor:
142
+ zz = zq.unsqueeze(0).expand(Fc.shape[0], -1)
143
+ a = torch.softmax(torch.cat([self.dec_none(zq), self.dec_sel(torch.cat([zz, Fc, zz * Fc], -1)).squeeze(-1)]), 0)
144
+ comp = torch.sigmoid(torch.cat([base, base + self.dec_w(zq) * (2 * bits - 1)]))
145
+ p = (a * comp).sum().clamp(1e-06, 1 - 1e-06)
146
+ return torch.log(p / (1 - p)).reshape(1)
147
+
148
+ def _facts(self, it: dict, e: Prompt, L: int):
149
+ nq = len(e.names)
150
+ fl: list[tuple[int, object]] = [(ALL_QUESTIONS, f) for f in state_facts(it["state"])]
151
+ for i, n in enumerate(e.names):
152
+ fl += [(i, f) for f in facts(it["state"], it["questions"][n]["instructions"])]
153
+ if not fl:
154
+ return None
155
+ feats = torch.zeros(len(fl), FACT_DIM + len(FACT_KINDS))
156
+ smat = torch.zeros(2 * len(fl), L)
157
+ tags, res_v = [], []
158
+ for r_, (i, f) in enumerate(fl):
159
+ feats[r_] = torch.tensor(f.feats + [float(f.kind == k) for k in FACT_KINDS])
160
+ wheres = {"state": ("state", "state"), "both": ("state", "question"), "question": ("question", "question")}[f.where]
161
+ for sidx, ((s0, s1), where) in enumerate(zip(f.spans[:2], wheres, strict=False)):
162
+ base = e.state_off if where == "state" else e.instr_off[i if i < nq else 0]
163
+ tk = e.tokens_in(base + s0, base + s1)
164
+ if tk:
165
+ smat[2 * r_ + sidx, tk] = 1.0 / len(tk)
166
+ tags.append(i)
167
+ res_v.append(f.result if f.result is not None else float("nan"))
168
+ return feats, smat, tags, res_v
169
+
170
+ def _encode(self, prompts: list[Prompt], dev: torch.device, L: int) -> torch.Tensor:
171
+ ids = torch.full((len(prompts), L), self.pad_id, dtype=torch.long)
172
+ mask = torch.zeros((len(prompts), L), dtype=torch.long)
173
+ vfeat = torch.zeros((len(prompts), L, VAL_DIM))
174
+ for b, e in enumerate(prompts):
175
+ ids[b, : len(e.ids)] = torch.tensor(e.ids)
176
+ mask[b, : len(e.ids)] = 1
177
+ for v, off in e.values:
178
+ for k in e.tokens_in(off + v.start, off + v.end):
179
+ vfeat[b, k] = torch.tensor(value_features(v))
180
+ ids, mask, vfeat = ids.to(dev), mask.to(dev), vfeat.to(dev)
181
+ emb = self.enc.get_input_embeddings()(ids) + self.val_proj(vfeat)
182
+ return self.enc(inputs_embeds=emb, attention_mask=mask).last_hidden_state
183
+
184
+ def _encode_options(self, texts: list[str], tok, dev: torch.device) -> torch.Tensor:
185
+ """Mean-pooled encoder vectors of option texts encoded on their own (questions with very many options)."""
186
+ uniq = sorted(set(texts))
187
+ out = {}
188
+ for i in range(0, len(uniq), 256):
189
+ chunk = uniq[i : i + 256]
190
+ n_real = len(chunk)
191
+ chunk = chunk + [""] * (next(bk for bk in (32, 64, 128, 256) if bk >= n_real) - n_real)
192
+ t = tok(chunk, padding="max_length", truncation=True, max_length=32, return_tensors="pt").to(dev)
193
+ h = self.enc(input_ids=t["input_ids"], attention_mask=t["attention_mask"]).last_hidden_state
194
+ pooled = (h * t["attention_mask"].unsqueeze(-1)).sum(1) / t["attention_mask"].sum(1, keepdim=True)
195
+ for s, v in zip(chunk[:n_real], pooled[:n_real], strict=True):
196
+ out[s] = v
197
+ return torch.stack([out[s] for s in texts])
198
+
199
+ def forward(self, items: list[dict], tok, max_len: int = 4096) -> list[dict[str, torch.Tensor]]:
200
+ """Per request: {question: logits per step, "__halt__": halting weights per step and question}."""
201
+ dev = next(self.parameters()).device
202
+ prompts = [Prompt(tok, it, max_len) for it in items]
203
+ L = max(256, -(-max(len(e.ids) for e in prompts) // 256) * 256)
204
+ fdata = [self._facts(it, e, L) for it, e in zip(items, prompts, strict=True)]
205
+ hid = self._encode(prompts, dev, L)
206
+ late_texts, late_index = [], []
207
+ for b, (it, e) in enumerate(zip(items, prompts, strict=True)):
208
+ for n in e.late:
209
+ for k, desc in it["questions"][n]["criteria"].items():
210
+ late_index.append((b, n))
211
+ late_texts.append(f"{k}: {desc}")
212
+ late_emb = self.o_late(self._encode_options(late_texts, tok, dev)) if late_texts else None
213
+ late_map: dict[tuple[int, str], list[int]] = {}
214
+ for j, key in enumerate(late_index):
215
+ late_map.setdefault(key, []).append(j)
216
+ outs = []
217
+ for b, it in enumerate(items):
218
+ e, h = prompts[b], hid[b]
219
+ nq = len(e.names)
220
+ rows = [e.tokens_in(*sp) or [0] for sp in e.qspans]
221
+ orows, owner = [], []
222
+ for i, n in enumerate(e.names):
223
+ for sp in e.ospans.get(n, []):
224
+ orows.append(e.tokens_in(*sp) or [0])
225
+ owner.append(i)
226
+ pm = torch.zeros(nq + len(orows), L)
227
+ for r_, toks in enumerate(rows + orows):
228
+ pm[r_, toks] = 1.0 / len(toks)
229
+ pooled = pm.to(dev) @ h
230
+ qtypes = torch.tensor([QTYPES[it["questions"][n]["type"]] for n in e.names], device=dev)
231
+ q = self.q_proj(pooled[:nq]) + self.qid.weight[:nq] + self.qtype(qtypes)
232
+ o_joint = self.o_proj(pooled[nq:]) if orows else None
233
+ valid = torch.zeros(L, dtype=torch.bool, device=dev)
234
+ valid[: len(e.ids)] = True
235
+ att = torch.softmax((q @ self.ev_k(h).T / math.sqrt(D)).masked_fill(~valid.unsqueeze(0), float("-inf")), -1)
236
+ q = self.ev_out(torch.cat([q, att @ self.ev_v(h)], -1)) # question + evidence from the document
237
+ F = fq_t = fres_t = None
238
+ if fdata[b] is not None:
239
+ feats, smat, tags, res_v = fdata[b]
240
+ span_h = (smat.to(dev) @ h).view(len(tags), 2 * self.H)
241
+ F = self.fact_in(torch.cat([feats.to(dev), span_h], 1)) + self.qid.weight[torch.tensor(tags, device=dev)]
242
+ fq_t = torch.tensor(tags, device=dev)
243
+ fres_t = torch.tensor(res_v, device=dev, dtype=torch.float32)
244
+ x = (torch.cat([q, F]) if F is not None else q).unsqueeze(0)
245
+ zs, lams = [], []
246
+ for _ in range(STEPS): # recursive refinement with learned halting
247
+ x = self.core(x)
248
+ zs.append(q + self.core_gate * x[0, :nq])
249
+ lams.append(torch.sigmoid(self.halt(zs[-1])).squeeze(-1))
250
+ remain, ps = torch.ones(nq, device=dev), []
251
+ for k, lam in enumerate(lams):
252
+ ps.append(remain * (lam if k < len(lams) - 1 else torch.ones_like(lam)))
253
+ remain = remain * (1 - lam)
254
+ p_halt = torch.stack(ps)
255
+ dec_ok = dec_bits = None
256
+ if F is not None: # each question reads the facts that belong to it
257
+ own = (fq_t.unsqueeze(0) == torch.arange(nq, device=dev).unsqueeze(1)) | (fq_t == ALL_QUESTIONS).unsqueeze(0)
258
+ has = own.any(1, keepdim=True)
259
+ zs = [
260
+ z
261
+ + has.float()
262
+ * self.fact_mix(
263
+ torch.softmax((self.fact_q(z) @ F.T / math.sqrt(D)).masked_fill(~own, float("-inf")), -1).nan_to_num(0.0) @ F
264
+ )
265
+ for z in zs
266
+ ]
267
+ ft = fdata[b][0].to(dev)
268
+ kind = ft[:, FACT_DIM : FACT_DIM + len(FACT_KINDS)]
269
+ dec_ok = (
270
+ (kind[:, FACT_KINDS.index("cmp")] > 0)
271
+ | (kind[:, FACT_KINDS.index("pair")] > 0)
272
+ | (kind[:, FACT_KINDS.index("sum")] > 0) & (ft[:, 4] > 0)
273
+ )
274
+ dec_bits = ft[:, 0]
275
+ res: dict[str, torch.Tensor] = {}
276
+ for i, n in enumerate(e.names):
277
+ qd = it["questions"][n]
278
+ if qd["type"] == "noul":
279
+ o = o_joint[torch.tensor([j for j, w in enumerate(owner) if w == i], device=dev)]
280
+ else:
281
+ o = (
282
+ late_emb[torch.tensor(late_map[b, n], device=dev)]
283
+ if n in e.late
284
+ else o_joint[torch.tensor([j for j, w in enumerate(owner) if w == i], device=dev)]
285
+ )
286
+ ofe = torch.tensor(option_feats(it["state"], list(qd["criteria"])), dtype=torch.float32, device=dev)
287
+ step_logits = []
288
+ for z in zs:
289
+ zq = z[i]
290
+ zz = zq.unsqueeze(0).expand(o.shape[0], -1)
291
+ lg = self.opt_mlp(torch.cat([zz, o, zz * o], -1)).squeeze(-1)
292
+ if qd["type"] == "noul":
293
+ base = (lg[0] - lg[1]).reshape(1)
294
+ if dec_ok is not None:
295
+ cand = dec_ok & ((fq_t == i) | (fq_t == ALL_QUESTIONS))
296
+ if bool(cand.any()):
297
+ base = self._exact_yes_no(base, zq, F[cand], dec_bits[cand])
298
+ step_logits.append(base)
299
+ continue
300
+ lex = ofe.clone()
301
+ lex[:, 2] = 0
302
+ op = self.opt_lex(lex).squeeze(-1)
303
+ numeric = ofe[:, 4] > 0
304
+ if F is not None and bool(numeric.any()): # numeric options matched against computed results
305
+ sel = (fq_t == i) & ~torch.isnan(fres_t)
306
+ if bool(sel.any()):
307
+ a_ = torch.softmax(F[sel] @ self.fact_att(zq) / math.sqrt(D), 0)
308
+ match = torch.exp(-(ofe[:, 2:3] - fres_t[sel].unsqueeze(0)).abs())
309
+ op = op + self.num_w * numeric.float() * (match @ a_) * 10
310
+ step_logits.append(lg + torch.sigmoid(self.op_gate(zq)) * op)
311
+ res[n] = torch.stack(step_logits)
312
+ res["__halt__"] = p_halt
313
+ outs.append(res)
314
+ return outs
315
+
316
+
317
+ def probabilities(model: BevNetwork, out: dict[str, torch.Tensor], qtype: str, name: str) -> torch.Tensor:
318
+ """Calibrated probabilities of one question: per step, temperature-scaled; steps combined by their halting weights."""
319
+ i = [k for k in out if k != "__halt__"].index(name)
320
+ w = out["__halt__"][:, i : i + 1]
321
+ t = model.temp[qtype]
322
+ if qtype == "noul":
323
+ return (w.squeeze(-1) * torch.sigmoid(out[name].squeeze(-1) / t)).sum().reshape(1)
324
+ return (w * torch.softmax(out[name] / t, -1)).sum(0)
325
+
326
+
327
+ def predict(model: BevNetwork, tok, items: list[dict], batch: int, max_len: int) -> list[dict]:
328
+ """[{"id", "answers": {question: {"pred", "probs"}}}] for items {"id", "state", "questions"}."""
329
+ rows = []
330
+ for k in range(0, len(items), batch):
331
+ chunk = items[k : k + batch]
332
+ for it, out in zip(chunk, model(chunk, tok, max_len), strict=True):
333
+ ans = {}
334
+ for n, q in it["questions"].items():
335
+ p = probabilities(model, out, q["type"], n).float().cpu().numpy()
336
+ if q["type"] == "noul":
337
+ ans[n] = {"pred": int(p[0] >= 0.5), "probs": {"1": float(p[0]), "0": float(1 - p[0])}}
338
+ else:
339
+ opts = list(q["criteria"])
340
+ ans[n] = {"pred": opts[int(p.argmax())], "probs": {o: float(v) for o, v in zip(opts, p, strict=True)}}
341
+ rows.append({"id": it["id"], "answers": ans})
342
+ return rows