File size: 12,893 Bytes
c7893fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""SystemOne: the public inference API (see SPEC.md).

    m = SystemOne.load("checkpoints/jevlike-base")
    m.predict(state, {"dept": Choice(...), "urgency": Score(...), "churn": Noul(...)})

All questions of a call (or of a whole ``predict_batch``) are scored in one batched forward
pass (split only by a token budget). Probabilities are temperature-scaled with
``calibration.json`` when present (``calibrated=False`` returns raw T=1 outputs).

Escalate semantics: the escalate head predicts whether the argmax answer is correct; after
Platt scaling (fit on val by ``calibrate.py``) ``escalate_prob = 1 - P(argmax correct)`` and
``escalate = escalate_prob > threshold`` (``escalate_threshold`` in config.json, default 0.5,
i.e. hand off when the model thinks its own answer is more likely wrong than right).

Long states
-----------
``SystemOne.load(path, max_len=4096)`` overrides the checkpoint's context length (ModernBERT
handles up to 8192 positions; checkpoints trained at 512 have not seen longer inputs). States
longer than the state budget are handled per call by ``long_state`` (default set at load):

* ``"truncate"`` (default): cut in the middle, keeping ~25% head / 75% tail (serialize.py).
* ``"chunk"``: split into overlapping windows (``chunk_overlap`` tokens, at most ``max_chunks``
  windows), score every (window, question) pair in the same batched forward pass, and pool the
  per-window *calibrated* distributions per question with ``chunk_agg`` (see ``CHUNK_AGG``):

  - ``"mean"``  log-linear pool: p_k ∝ exp(mean_c log p_c,k) (geometric mean, renormalized;
    = softmax of the mean window logits). Flat windows do not change which answer wins, but
    evidence found in only one of n windows is diluted (≈ its log-odds / n): conservative,
    best when the answer depends on the document as a whole (topic, tone).
  - ``"max"``   noul: P(true) = max_c P_c(true) ("true if any window shows it", a noisy-OR
    that does not compound across windows). choice/score: p_k ∝ max_c p_c,k.
  - ``"noisy_or"`` noul only: P(true) = 1 - prod_c (1 - P_c(true)) (compounds; overconfident
    with many windows).
  - ``"linear"`` arithmetic mean of the window distributions.

  ``chunk_agg="auto"`` = ``{"choice": "mean", "score": "mean", "noul": "max"}``; a dict
  overrides per type. ``escalate_prob`` of a pooled answer is that of the window giving the
  pooled answer the highest probability (the deciding window). Results then carry an extra
  top-level ``"chunks": {question: n_windows}``.
"""
from __future__ import annotations

import json
import math
import os
import time
from typing import Mapping, Sequence

import torch
from transformers import AutoTokenizer

from jevlike.calibrate import temperature
from jevlike.model import DecisionModel
from jevlike.serialize import Serializer, TokenBudgetBatchSampler, collate
from jevlike.types import PublicQuestion, Question

Questions = Mapping[str, "PublicQuestion | Question"]


LONG_STATE = ("truncate", "chunk")
CHUNK_AGG = {"choice": ("mean", "max", "linear"), "score": ("mean", "max", "linear"),
             "noul": ("max", "mean", "noisy_or", "linear")}  # first = "auto"


def resolve_chunk_agg(agg: "str | Mapping[str, str]") -> dict[str, str]:
    """``"auto"`` / one rule for all types / {type: rule} -> {type: rule}, validated."""
    rules = {t: opts[0] for t, opts in CHUNK_AGG.items()}
    if isinstance(agg, str):
        if agg != "auto":
            rules = {t: agg for t in CHUNK_AGG}
            if agg == "noisy_or":  # noul-only rule: keep the defaults for choice/score
                rules.update({"choice": "mean", "score": "mean"})
    else:
        rules.update(agg)
    for t, r in rules.items():
        if t not in CHUNK_AGG or r not in CHUNK_AGG[t]:
            raise ValueError(f"chunk_agg: {r!r} is not a rule for {t!r} (options: {CHUNK_AGG.get(t)})")
    return rules


def pool_chunks(logp: torch.Tensor, qtype: str, rule: str) -> torch.Tensor:
    """Pool per-window log-probabilities [C, K] of one question into a distribution [K]."""
    if logp.shape[0] == 1:
        return logp[0].exp()
    if rule == "mean":
        return logp.mean(0).softmax(-1)
    if rule == "linear":
        return logp.exp().mean(0)
    if qtype == "noul" and rule in ("max", "noisy_or"):
        p_true = logp[:, 1].exp().max() if rule == "max" else -torch.expm1(logp[:, 0].sum())
        return torch.stack([1 - p_true, p_true])
    if rule == "max":
        return logp.max(0).values.softmax(-1)
    raise ValueError(f"unknown chunk aggregation rule {rule!r} for {qtype}")


class SystemOne:
    def __init__(self, model: DecisionModel, tokenizer, calibration: dict | None, device: torch.device,
                 max_tokens: int = 16384, long_state: str = "truncate", chunk_agg: "str | dict" = "auto",
                 chunk_overlap: int = 128, max_chunks: int = 64):
        self.model, self.cal, self.device = model.eval(), calibration, device
        cfg = model.cfg
        self.max_tokens = max(max_tokens, cfg.max_len)  # a micro-batch always fits one full-length sequence
        self.ser = Serializer(tokenizer, cfg.max_len, cfg.head_max_len)
        self.name = cfg.name
        self.threshold = cfg.escalate_threshold
        if long_state not in LONG_STATE:
            raise ValueError(f"long_state must be one of {LONG_STATE}, got {long_state!r}")
        self.long_state, self.chunk_agg = long_state, resolve_chunk_agg(chunk_agg)
        self.chunk_overlap, self.max_chunks = chunk_overlap, max_chunks

    @property
    def max_len(self) -> int:
        return self.ser.max_len

    @classmethod
    def load(cls, path: str, device: str | torch.device | None = None, max_len: int | None = None,
             **kw) -> "SystemOne":
        """``max_len`` overrides the checkpoint's context length (default: keep it; at most the
        backbone's ``max_position_embeddings``, 8192 for ModernBERT). Other keyword arguments
        (``max_tokens``, ``long_state``, ``chunk_agg``, ``chunk_overlap``, ``max_chunks``) go to
        the constructor."""
        device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
        cal_path = os.path.join(path, "calibration.json")
        cal = json.load(open(cal_path)) if os.path.exists(cal_path) else None
        model = DecisionModel.from_pretrained(path, device)
        if max_len:
            set_max_len(model.cfg, max_len)
        return cls(model, AutoTokenizer.from_pretrained(path), cal, device, **kw)

    def predict(self, state: str, questions: Questions, calibrated: bool = True, long_state: str | None = None,
                chunk_agg: "str | dict | None" = None) -> dict:
        return self.predict_batch([(state, questions)], calibrated=calibrated, long_state=long_state,
                                  chunk_agg=chunk_agg)[0]

    @torch.inference_mode()
    def predict_batch(self, items: Sequence[tuple[str, Questions]], calibrated: bool = True,
                      long_state: str | None = None, chunk_agg: "str | dict | None" = None) -> list[dict]:
        """``latency_ms`` of each result is the wall time of the whole batched call.
        ``long_state`` / ``chunk_agg`` default to the values given at load (see module doc)."""
        t0 = time.perf_counter()
        long_state = long_state or self.long_state
        if long_state not in LONG_STATE:
            raise ValueError(f"long_state must be one of {LONG_STATE}, got {long_state!r}")
        rules = self.chunk_agg if chunk_agg is None else resolve_chunk_agg(chunk_agg)
        flat: list[tuple[int, str, Question]] = []
        for i, (_, qs) in enumerate(items):
            for name, q in qs.items():
                q = q if isinstance(q, Question) else Question.from_public(q)
                try:
                    q.validate()
                except AssertionError as e:
                    raise ValueError(f"question {name!r}: {e}") from None
                flat.append((i, name, q))
        pairs = [(items[i][0], q) for i, _, q in flat]
        if long_state == "chunk":
            groups = self.ser.encode_chunked(pairs, self.chunk_overlap, self.max_chunks)
        else:
            groups = [[e] for e in self.ser.encode_many(pairs)]
        encs = [e for g in groups for e in g]
        logits, esc = self._forward(encs)
        cal = self.cal if calibrated else None
        if cal:
            esc = cal["escalate"]["a"] * esc + cal["escalate"]["b"]
        p_wrong = 1 - torch.sigmoid(esc)
        results = [{"answers": {}, "model": self.name, "latency_ms": 0.0} for _ in items]
        if long_state == "chunk":
            for r in results:
                r["chunks"] = {}
        start = 0
        for (i, name, q), g in zip(flat, groups):
            sl = slice(start, start + len(g))
            start += len(g)
            logp = torch.stack(logits[sl]) / temperature(cal, q.type, q.n_options)
            logp = logp.log_softmax(-1)
            p = pool_chunks(logp, q.type, rules[q.type])
            deciding = int(logp[:, int(p.argmax())].argmax())  # window giving the answer the most mass
            results[i]["answers"][name] = self._answer(q, p, float(p_wrong[sl][deciding]))
            if long_state == "chunk":
                results[i]["chunks"][name] = len(g)
        ms = (time.perf_counter() - t0) * 1000
        for r in results:
            r["latency_ms"] = ms
        return results

    def batches(self, lengths: Sequence[int]) -> list[list[int]]:
        """Length-sorted micro-batches with B * T_max <= max_tokens (a longer single sequence goes alone)."""
        return TokenBudgetBatchSampler(lengths, self.max_tokens, max_batch=256, shuffle=False).batches()

    def _forward(self, encs: list) -> tuple[list[torch.Tensor], torch.Tensor]:
        """Raw per-sequence option logits and escalate logits, token-budgeted micro-batches."""
        logits: list[torch.Tensor] = [None] * len(encs)  # type: ignore[list-item]
        esc = torch.zeros(len(encs))
        for idx in self.batches([len(e) for e in encs]) if encs else []:
            b = collate([encs[j] for j in idx], None, self.ser.pad)
            k_max = int(b["n_options"].max())
            b = {k: v.to(self.device, non_blocking=True) for k, v in b.items()}
            with torch.autocast(self.device.type, dtype=torch.bfloat16, enabled=self.device.type == "cuda"):
                out = self.model(**b, k_max=k_max)
            lg, e = out["logits"].float().cpu(), out["escalate_logit"].float().cpu()
            for r, j in enumerate(idx):
                logits[j] = lg[r, :len(encs[j].markers)]
            esc[idx] = e
        return logits, esc

    def _answer(self, q: Question, p: torch.Tensor, p_wrong: float) -> dict:
        probs = p.tolist()
        top = int(p.argmax())
        esc = {"escalate": p_wrong > self.threshold, "escalate_prob": p_wrong}
        if q.type == "choice":
            return {"type": "choice", "choice": q.options[top].key,
                    "probabilities": {o.key: pi for o, pi in zip(q.options, probs)}, "confidence": probs[top], **esc}
        if q.type == "score":
            k = len(probs)
            expected = sum(i * pi for i, pi in enumerate(probs))
            return {"type": "score", "level": top, "label": q.options[top].text or q.options[top].key,
                    "score": 10.0 * expected / (k - 1), "probabilities": probs, "confidence": probs[top], **esc}
        return {"type": "noul", "noul": probs[1], "confidence": max(probs), **esc}


def set_max_len(cfg, max_len: int) -> None:
    """Set a DecisionConfig's context length (validated against the backbone's position limit);
    shrinks ``head_max_len`` to half of it if it no longer fits."""
    limit = int(cfg.backbone_config.get("max_position_embeddings") or 8192)
    if not 64 <= max_len <= limit:
        raise ValueError(f"max_len must be in [64, {limit}] (backbone max_position_embeddings), got {max_len}")
    cfg.max_len = int(max_len)
    if cfg.head_max_len >= cfg.max_len:
        cfg.head_max_len = cfg.max_len // 2


def _demo(path: str) -> None:
    from jevlike.types import Choice, Noul, Score
    m = SystemOne.load(path)
    res = m.predict("Hi, I was charged twice this month and the app keeps crashing. Fix this or I'm leaving.",
                    {"dept": Choice("Which team should handle this?", {"billing": "payments, invoices, refunds",
                                                                      "technical": "bugs, crashes, errors"}),
                     "urgency": Score("How urgent is this?", ["not urgent", "soon", "blocking"]),
                     "churn": Noul("The user threatens to cancel")})
    print(json.dumps(res, indent=1))
    assert all(math.isfinite(a["confidence"]) for a in res["answers"].values())


if __name__ == "__main__":
    import sys
    _demo(sys.argv[1] if len(sys.argv) > 1 else "checkpoints/jevlike-base")