File size: 10,358 Bytes
6cc3500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""TWLAT V3 推論管線。

    輸入 → protect(PUA 遮罩保護段)→ safe_normalize(twv)
         → lattice(字典硬過濾在此發生)→ 無邊 → fast path
         → clean/masked 雙 pass forward → Viterbi + τ → 最小 splice
         → restore → 輸出

超長輸入:滑動視窗(stride 384),每條邊由「它最居中」的視窗評分——
不再有 V2 的 INPUT_TOO_LONG 降級路徑。
"""
from __future__ import annotations

import pathlib
from dataclasses import dataclass, field

import numpy as np
import torch


from twlat import decoder                                        # noqa: E402
from twlat.features import (FEAT_DIM, L_MAX, LexTables,          # noqa: E402
                            assemble_cands, make_feat, site_arrays,
                            text_arrays)
from twlat.lattice import LatticeBuilder, build_masked_view      # noqa: E402
from twlat.model_v3 import TWLATV3, make_config                  # noqa: E402
from twlat.normalize import safe_normalize                       # noqa: E402
from twlat.paths import data_file                                # noqa: E402
from twlat.protect import protect, restore                       # noqa: E402

import json                                                       # noqa: E402

WIN, STRIDE = 512, 384


@dataclass
class ResultV3:
    output: str
    decisions: list = field(default_factory=list)
    fast_path: bool = False
    error: str | None = None


class TWLATV3Runtime:
    def __init__(self, ckpt: str, device: str | None = None,
                 tau: dict[str, float] | None = None,
                 lexicon_path=None, fo_bonus: float = 0.0):
        lex_p = lexicon_path or data_file("dict/lattice_lexicon.json")
        self.lb = LatticeBuilder(lex_p)
        self.lex = LexTables(lex_p)
        self.vocab = json.loads(
            data_file("dict/char_vocab_v3.json").read_text(encoding="utf-8"))
        self.fo_bonus = fo_bonus
        tp = data_file("model/tau.json")
        self.tau = tau if tau is not None else (
            json.loads(tp.read_text()) if tp.exists() else decoder.DEFAULT_TAU)
        self.device = torch.device(
            device or ("cuda" if torch.cuda.is_available()
                       else "mps" if torch.backends.mps.is_available() else "cpu"))
        ck = torch.load(ckpt, map_location="cpu", weights_only=False)
        self.model = TWLATV3(make_config(**ck["mcfg"]))
        self.model.load_state_dict(ck["model"])
        self.model.eval().to(self.device)
        # observed 相依特徵(is_keep/長度差)只有 finetune 階段見過;
        # 對 pretrain-only checkpoint 餵入會是分佈外輸入
        self.reveal = ck.get("phase") == "finetune"
        ck_lex = ck.get("lexicon_version")
        if ck_lex and ck_lex != self.lb.version:
            # 熱更新是功能不是錯誤:記錄但不擋(held-out 評測依賴此路徑)
            self.lexicon_mismatch = (ck_lex, self.lb.version)
        else:
            self.lexicon_mismatch = None

    # ------------------------------------------------------------------ #

    @torch.no_grad()
    def score_batch(self, texts: list[str], batch_size: int = 32):
        """評分階段:→ list[(base, sv, lat, logits[n_edges,C] | None)]。
        τ 校準重用此結果做多組解碼,不必重跑模型。"""
        stages = []
        jobs = []
        for ti, t in enumerate(texts):
            q, sv = protect(t)
            base = safe_normalize(q, do_twv=True)
            lat = self.lb.build(base)
            stages.append([base, sv, lat, None])
            if not lat.edges:
                continue
            wins = self._windows(len(base))
            assign = self._assign(lat, wins)
            for wi, (ws, we) in enumerate(wins):
                eixs = assign[wi]
                if not eixs:
                    continue
                jobs.append(self._make_job(ti, base[ws:we], ws,
                                           [lat.edges[i] for i in eixs], eixs))

        logit_map: dict[int, dict[int, np.ndarray]] = {}
        jobs.sort(key=lambda j: len(j["text"]))
        for s in range(0, len(jobs), batch_size):
            chunk = jobs[s:s + batch_size]
            out = self.model(self._stack(chunk))
            lg = out["cand_logits"].float().cpu().numpy()
            for bi, j in enumerate(chunk):
                m = logit_map.setdefault(j["ti"], {})
                for k, eix in enumerate(j["eixs"]):
                    m[eix] = lg[bi, k]

        for ti, st in enumerate(stages):
            lat = st[2]
            if not lat.edges:
                continue
            lm = logit_map.get(ti, {})
            cmax = max((v.shape[0] for v in lm.values()), default=1)
            logits = np.full((len(lat.edges), cmax), -np.inf, np.float32)
            for eix, row in lm.items():
                logits[eix, :len(row)] = row
            for i, e in enumerate(lat.edges):
                if i not in lm:                  # 不應發生:未覆蓋 → keep
                    logits[i, e.obs_ix] = 0.0
            st[3] = logits
        return stages

    def decode_stage(self, stage, tau=None) -> ResultV3:
        base, sv, lat, logits = stage
        if logits is None:
            return ResultV3(output=restore(base, sv), fast_path=True)
        edits = decoder.decode(self.lb, lat, logits,
                               self.tau if tau is None else tau,
                               fo_bonus=self.fo_bonus)
        return ResultV3(
            output=restore(decoder.splice(base, edits), sv),
            decisions=[{"span": [e.start, e.end], "from": e.observed,
                        "to": e.replacement, "utility": round(e.utility, 4),
                        "rule_type": e.rule_type} for e in edits])

    @torch.no_grad()
    def convert_batch(self, texts: list[str], batch_size: int = 32
                      ) -> list[ResultV3]:
        return [self.decode_stage(s)
                for s in self.score_batch(texts, batch_size)]

    # ------------------------------------------------------------------ #

    @staticmethod
    def _windows(n: int) -> list[tuple[int, int]]:
        if n <= WIN:
            return [(0, n)]
        wins, s = [], 0
        while True:
            wins.append((s, min(s + WIN, n)))
            if s + WIN >= n:
                return wins
            s += STRIDE

    @staticmethod
    def _assign(lat, wins) -> dict[int, list[int]]:
        """每條邊 → 它最居中的視窗。"""
        out: dict[int, list[int]] = {wi: [] for wi in range(len(wins))}
        for i, e in enumerate(lat.edges):
            best_wi, best_d = None, None
            for wi, (ws, we) in enumerate(wins):
                if e.start >= ws and e.end <= we:
                    c = (ws + we) / 2
                    d = abs((e.start + e.end) / 2 - c)
                    if best_d is None or d < best_d:
                        best_wi, best_d = wi, d
            if best_wi is not None:
                out[best_wi].append(i)
        return out

    def _make_job(self, ti, text, offset, edges, eixs):
        ids, script, prot = text_arrays(text, self.vocab)
        # 邊座標平移到視窗座標
        shifted = []
        for e in edges:
            se = type(e)(e.start - offset, e.end - offset, e.gid, e.obs_ix,
                         e.cand_kill, e.word_crossing, e.word_contained)
            shifted.append(se)
        sa = site_arrays(self.lb, shifted, text)
        mids, m_span, _ = build_masked_view(ids, sa["span"], sa["maskable"])
        mscript, _, _ = build_masked_view(script, sa["span"], sa["maskable"],
                                          mask_id=4)
        mprot, _, _ = build_masked_view(prot.astype(np.uint8), sa["span"],
                                        sa["maskable"], mask_id=0)
        cand_tok, cand_mask, cand_kill, cand_feat = assemble_cands(
            self.lex, sa["gid"], sa["obs"], sa["clue"], sa["eng"],
            sa["flags"], sa["kill"], reveal_observed=self.reveal)
        return {"ti": ti, "eixs": eixs, "text": text,
                "ids": ids, "script": script, "prot": prot,
                "feat": make_feat(script, prot, sa["span"], len(text)),
                "mids": mids, "m_span": m_span.astype(np.int64),
                "mfeat": make_feat(mscript, mprot, m_span, len(mids)),
                "c_span": sa["span"], "obs": sa["obs"],
                "cand_tok": cand_tok, "cand_mask": cand_mask,
                "cand_kill": cand_kill, "cand_feat": cand_feat}

    def _stack(self, jobs):
        B = len(jobs)
        T = max(len(j["ids"]) for j in jobs)
        Tm = max(len(j["mids"]) for j in jobs)
        S = max(len(j["c_span"]) for j in jobs)
        C = max(j["cand_tok"].shape[1] for j in jobs)
        out = {
            "ids": np.zeros((B, T), np.int64),
            "feat": np.zeros((B, T, 4), np.int64),
            "pad": np.zeros((B, T), bool),
            "mids": np.zeros((B, Tm), np.int64),
            "mfeat": np.zeros((B, Tm, 4), np.int64),
            "mpad": np.zeros((B, Tm), bool),
            "c_span": np.zeros((B, S, 2), np.int64),
            "m_span": np.zeros((B, S, 2), np.int64),
            "site_mask": np.zeros((B, S), bool),
            "cand_tok": np.zeros((B, S, C, L_MAX), np.int64),
            "cand_mask": np.zeros((B, S, C), bool),
            "cand_kill": np.zeros((B, S, C), bool),
            "cand_feat": np.zeros((B, S, C, FEAT_DIM), np.float32),
        }
        for b, j in enumerate(jobs):
            n, tm = len(j["ids"]), len(j["mids"])
            ns, c = len(j["c_span"]), j["cand_tok"].shape[1]
            out["ids"][b, :n] = j["ids"]
            out["feat"][b, :n] = j["feat"]
            out["pad"][b, :n] = True
            out["mids"][b, :tm] = j["mids"]
            out["mfeat"][b, :tm] = j["mfeat"]
            out["mpad"][b, :tm] = True
            out["c_span"][b, :ns] = j["c_span"]
            out["m_span"][b, :ns] = j["m_span"]
            out["site_mask"][b, :ns] = True
            out["cand_tok"][b, :ns, :c] = j["cand_tok"]
            out["cand_mask"][b, :ns, :c] = j["cand_mask"]
            out["cand_kill"][b, :ns, :c] = j["cand_kill"]
            out["cand_feat"][b, :ns, :c] = j["cand_feat"]
        return {k: torch.from_numpy(v).to(self.device) for k, v in out.items()}