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 +24 -0
- bev/_core/__init__.py +0 -0
- bev/_core/features.py +313 -0
- bev/_core/network.py +342 -0
- bev/cli.py +128 -0
- bev/evaluate.py +54 -0
- bev/gating.py +32 -0
- bev/mcp_server.py +38 -0
- bev/model.py +123 -0
- bev/presets.py +81 -0
- bev/pro.py +156 -0
- bev/router.py +49 -0
- bev/schema.py +51 -0
- bev/server.py +72 -0
- bev_decisions-0.2.0.dist-info/METADATA +305 -0
- bev_decisions-0.2.0.dist-info/RECORD +20 -0
- bev_decisions-0.2.0.dist-info/WHEEL +5 -0
- bev_decisions-0.2.0.dist-info/entry_points.txt +2 -0
- bev_decisions-0.2.0.dist-info/licenses/LICENSE +204 -0
- bev_decisions-0.2.0.dist-info/top_level.txt +1 -0
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
|