File size: 5,624 Bytes
0909e6c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Stop a page read that has started to loop, at serve time.

A looping page runs to max_tokens (4,096): on the proxy models those pages take a third or more of all
decode time, and the tokens after the loop starts are garbage that only raise CER. The guard watches
the text as it streams and reports where the loop began; the client then aborts the request (vLLM
frees its slot) and keeps the text up to the end of the first copy.

Two signals, both on the tail of the text:
  period   the tail is one unit of p characters repeated r times (exact), with p*r over a length that
           grows for short units: dot leaders and "-----" rules are legitimate short repeats. Units with
           table markup need far more (12 copies, 1,500 chars): empty rows and repeated header cells
           are real table content.
  lines    the same line (stripped, at least `min_line` chars) repeated `line_reps` times in a row.
           Tables and lists repeat structure, not whole identical lines.
Thresholds were set on eval v2 outputs (`python -m lightonocr_test.loop_guard tune ...`): no cut on a
page that stopped on its own (v1-ipo2), while catching most pages that hit the cap.
"""

from __future__ import annotations

import json
import sys


class LoopGuard:
    def __init__(self, min_span: int = 240, short_unit: int = 12, short_span: int = 600,
                 table_span: int = 1500, table_reps: int = 12,
                 line_reps: int = 4, min_line: int = 12, every: int = 32):
        self.min_span, self.short_unit, self.short_span = min_span, short_unit, short_span
        self.table_span, self.table_reps = table_span, table_reps
        self.line_reps, self.min_line, self.every = line_reps, min_line, every
        self._checked = 0

    def _period(self, t: str) -> int | None:
        n = len(t)
        for p in range(1, 401):
            span = self.short_span if p <= self.short_unit else self.min_span
            if 3 * p > n or span > n:
                continue
            unit = t[n - p:]
            r = 1
            while (r + 1) * p <= n and t[n - (r + 1) * p:n - r * p] == unit:
                r += 1
            need = 3
            if "<t" in unit or "</t" in unit:     # empty rows, repeated header cells: tables repeat legitimately
                span, need = max(span, self.table_span), self.table_reps
            if r >= need and r * p >= span:
                return n - r * p + p          # keep one copy of the unit
        return None

    def _lines(self, t: str) -> int | None:
        lines = t.split("\n")
        if len(lines) < self.line_reps + 1:
            return None
        body = lines[:-1]                     # the last line may still be growing
        last = body[-1].strip()
        if len(last) < self.min_line:
            return None
        r = 1
        while r < len(body) and body[-1 - r].strip() == last:
            r += 1
        if r >= self.line_reps:
            keep = "\n".join(lines[:len(body) - r + 1])
            return len(keep)
        return None

    def check(self, text: str) -> int | None:
        """Cut position if `text` ends in a loop, else None. Cheap to call on every streamed chunk:
        it only looks again after `every` new characters."""
        if len(text) - self._checked < self.every:
            return None
        self._checked = len(text)
        tail_start = max(0, len(text) - 4000)
        tail = text[tail_start:]
        for f in (self._period, self._lines):
            c = f(tail)
            if c is not None:
                return tail_start + c
        return None


def scan(text: str, step: int = 16, **kw) -> int | None:
    """Replay a finished output in `step`-character chunks, as a stream would arrive."""
    g = LoopGuard(**kw)
    for end in range(step, len(text) + step, step):
        c = g.check(text[:end])
        if c is not None:
            return c
    return None


def tune(paths: list[str]) -> None:
    """Per eval file: cuts on pages that stopped on their own (false alarms) and on capped pages,
    characters saved, and CER before/after cutting (needs the references: eval_by_length sets)."""
    from rapidfuzz.distance import Levenshtein

    from .eval_by_length import sets, texts
    refs = {}
    for name, rows in sets(["v2"]).items():
        for k, (_, ref, meta) in enumerate(rows):
            refs[(name, k)] = (ref, meta)
    for p in paths:
        rows = [json.loads(l) for l in open(p, encoding="utf-8")]
        fa = caught = capped = 0
        chars = saved = 0
        cer0 = cer1 = 0.0
        n = 0
        for r in rows:
            h = r.get("hyp") or ""
            c = scan(h)
            chars += len(h)
            if r["capped"]:
                capped += 1
                caught += c is not None
            elif c is not None:
                fa += 1
            key = (r["set"], r["k"])
            if key in refs:
                ref, meta = refs[key]
                R, H0 = texts(ref, meta, h)
                _, H1 = texts(ref, meta, h[:c] if c is not None else h)
                cer0 += Levenshtein.normalized_distance(R, H0)
                cer1 += Levenshtein.normalized_distance(R, H1)
                n += 1
            if c is not None:
                saved += len(h) - c
        print(f"{p}: pages {len(rows)} | false cuts {fa} of {len(rows) - capped} stopped pages | "
              f"caught {caught} of {capped} capped | chars saved {100 * saved / max(1, chars):.1f}% | "
              f"CER {cer0 / max(1, n):.4f} -> {cer1 / max(1, n):.4f}")


if __name__ == "__main__":
    if sys.argv[1] == "tune":
        tune(sys.argv[2:])