File size: 6,928 Bytes
4113449
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Choose compile settings and the confidence policy by simulated typing on held-out prompts.

The weights are always produced by the same closed-form compile; this script only
compares a handful of compile settings (context length, smoothing, source weights,
vocabulary, pruning) and policy thresholds, and reports which one saves the most
keystrokes while keeping precision >= TARGET_PRECISION.

Simulation: each held-out prompt (10% of data/prompts.txt, never compiled in) is
"typed" one character at a time. After every keystroke the policy shows ghost text
or nothing. A shown suggestion is correct only if the user's next characters start
with the whole ghost text. Keystrokes saved replays the prompt accepting every
correct suggestion with one Tab press.

    python scripts/sweep.py            # writes docs/sweep.json
"""

import json
import sys
import time
import zlib
from dataclasses import asdict, replace
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
import build_weights as bw  # noqa: E402
import suggest as sg  # noqa: E402

TARGET_PRECISION = 0.90
MAX_EXPORT_MB = 50
TAUS = [round(0.5 + 0.01 * i, 2) for i in range(46)]  # 0.50 .. 0.95
SUPPORTS = [0, 2, 5, 10]
BASE = bw.Settings(n_prev=3, smoothing="wb", phrase_weight=5, prompt_weight=1, reply_weight=1, max_vocab=8000, min_count=2)


def heldout_texts() -> list[str]:
    seen, out = set(), []
    for line in (bw.DATA_DIR / "prompts.txt").read_text().splitlines():
        toks = bw.pieces(line)
        if toks and zlib.crc32(" ".join(toks).encode()) % 100 < 10:
            text = bw.NORMALIZER.normalize_str(line.strip())
            if text not in seen:
                seen.add(text)
                out.append(text)
    return out


def train_sources() -> dict:
    src = bw.load_sources()
    src["prompts"], _ = bw.heldout_split(src["prompts"])
    return src


def record(sugg: sg.Suggester, texts: list[str]) -> list[tuple[str, list]]:
    """For every prompt and every keystroke position, the unthresholded suggestion chain."""
    return [(t, [None] + [sugg.steps(t[:i]) for i in range(1, len(t))]) for t in texts]


def evaluate(records, policy: sg.Policy) -> dict:
    shown = correct = positions = chars = strokes = 0
    for text, steps in records:
        ghosts = [None] + [sg.Suggester.apply(st, policy) for st in steps[1:]]
        for i in range(1, len(text)):
            positions += 1
            if ghosts[i]:
                shown += 1
                correct += text.startswith(ghosts[i], i)
        pos, n = 0, 0
        while pos < len(text):
            pos, n = pos + 1, n + 1
            g = ghosts[pos] if pos < len(text) else ""
            if g and text.startswith(g, pos):
                pos, n = pos + len(g), n + 1
        chars, strokes = chars + len(text), strokes + n
    return {
        "precision": round(correct / shown, 4) if shown else None,
        "coverage": round(shown / positions, 4),
        "keystrokes_saved": round(1 - strokes / chars, 4),
        "shown": shown,
    }


def best_policy(records) -> dict:
    results = []
    for tau in TAUS:
        for sup in SUPPORTS:
            m = evaluate(records, sg.Policy(tau=tau, min_support=sup))
            results.append({"tau": tau, "min_support": sup, **m})
    ok = [r for r in results if r["precision"] is not None and r["precision"] >= TARGET_PRECISION]
    best = max(ok, key=lambda r: r["keystrokes_saved"]) if ok else None
    return {"best": best, "grid": results}


def export_mb(ngram: bw.NGram, vocab: list[str], oov: dict) -> float:
    """Size of web/model.bin for these tables (see export_web.py)."""
    size = sum(len(p.encode()) + 1 for p in vocab) + 8 * len(vocab) + sum(len(p.encode()) + 5 for p in oov)
    for k in range(1, ngram.order + 1):
        for table in ngram.tables[k].values():
            size += 2 * k + 8 + 6 * len(table)
    return round(size / 1e6, 1)


def run(s: bw.Settings, src: dict, texts: list[str]) -> dict:
    t0 = time.time()
    vocab, ngram, stats = bw.compile_tables(s, src)
    oov = sg.oov_prefix_rates(s, src, vocab)
    recs = record(sg.Suggester(vocab, ngram, oov), texts)
    pol = best_policy(recs)
    out = {
        "settings": asdict(s),
        **stats,
        "export_mb": export_mb(ngram, vocab, oov),
        "best": pol["best"],
        "seconds": round(time.time() - t0, 1),
        "grid": pol["grid"],
    }
    b = out["best"] or {}
    print(
        f"n_prev={s.n_prev} {s.smoothing} w=({s.phrase_weight},{s.prompt_weight},{s.reply_weight}) "
        f"V={s.max_vocab} min={s.min_count} | {out['export_mb']} MB | "
        f"saved={b.get('keystrokes_saved')} prec={b.get('precision')} cov={b.get('coverage')} "
        f"tau={b.get('tau')} sup={b.get('min_support')} | {out['seconds']}s",
        flush=True,
    )
    return out


def score(r: dict) -> float:
    if r["best"] is None or r["export_mb"] > MAX_EXPORT_MB:
        return -1
    return r["best"]["keystrokes_saved"]


def main() -> None:
    src, texts = train_sources(), heldout_texts()
    print(f"held-out prompts: {len(texts)}, characters: {sum(map(len, texts))}", flush=True)
    runs = []

    print("== stage A: context length x smoothing", flush=True)
    stage = [run(replace(BASE, n_prev=n, smoothing=sm), src, texts) for sm in ["wb", "kn"] for n in [3, 4, 5, 7, 9]]
    runs += stage
    best = max(stage, key=score)

    print("== stage B: source weights", flush=True)
    base = bw.Settings(**best["settings"])
    stage = [
        run(replace(base, phrase_weight=pw, reply_weight=rw), src, texts)
        for pw in [1.0, 5.0]
        for rw in [0.0, 0.3, 1.0]
        if (pw, rw) != (base.phrase_weight, base.reply_weight)
    ]
    runs += stage
    best = max(runs, key=score)

    print("== stage C: vocabulary x pruning", flush=True)
    base = bw.Settings(**best["settings"])
    stage = [
        run(replace(base, max_vocab=v, min_count=mc), src, texts)
        for v in [3000, 8000, 16000]
        for mc in [2, 3]
        if (v, mc) != (base.max_vocab, base.min_count)
    ]
    runs += stage
    best = max(runs, key=score)

    print("== stage D: context length again, with the final settings", flush=True)
    base = bw.Settings(**best["settings"])
    stage = [run(replace(base, n_prev=n), src, texts) for n in [3, 4, 5, 7, 9] if n != base.n_prev]
    runs += stage
    best = max(runs, key=score)

    out = ROOT / "docs" / "sweep.json"
    out.write_text(json.dumps({
        "target_precision": TARGET_PRECISION,
        "max_export_mb": MAX_EXPORT_MB,
        "heldout_prompts": len(texts),
        "best": {k: v for k, v in best.items() if k != "grid"},
        "runs": [{k: v for k, v in r.items() if k != "grid"} for r in runs],
        "best_grid": best["grid"],
    }, indent=2) + "\n")
    print("BEST", json.dumps({k: v for k, v in best.items() if k != "grid"}, indent=2))


if __name__ == "__main__":
    main()