File size: 5,345 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
"""Confidence-gated ghost-text suggestions (reference implementation).

web/autocomplete.ts implements the same policy for the browser; the tests check
they agree. Nothing here is learned: the policy reads the compiled model's
next-token probabilities and only suggests when they clear fixed thresholds.

Given the text typed so far:
  1. The last pre-token is the word being typed (just "Ġ" after a space). Only
     vocabulary tokens that start with it are candidates ("fun" -> "function").
  2. Candidate probabilities are renormalized over the candidates and scaled by
     the chance that a word with this prefix is in the vocabulary at all
     (counted from the source text), so rare out-of-vocabulary words don't
     produce confident wrong completions.
  3. The suggestion is shown only if that probability >= tau and the longest
     matched context was seen >= min_support times. It then extends greedily,
     word by word, while the product of probabilities stays >= tau, stopping
     at end-of-text or after . ? ! :
"""

from collections import Counter
from dataclasses import dataclass

import numpy as np

from build_weights import EOS_ID, UNK_ID, NGram, Settings, pieces, weighted_sources

STOP_AFTER = {".", "?", "!", ":"}
MAX_WORDS = 8


@dataclass(frozen=True)
class Policy:
    tau: float = 0.6  # min probability of the whole suggestion
    min_support: float = 2  # min (weighted) count of the longest matched context
    max_words: int = MAX_WORDS


@dataclass(frozen=True)
class Step:
    text: str  # characters this step adds to the ghost text
    prob: float  # probability of the suggestion up to and including this step
    support: float  # count of the longest context matched for this step


def _byte_decoder() -> dict[str, int]:
    bs = list(range(33, 127)) + list(range(161, 173)) + list(range(174, 256))
    cs, n = bs[:], 0
    for b in range(256):
        if b not in bs:
            bs.append(b)
            cs.append(256 + n)
            n += 1
    return {chr(c): b for b, c in zip(bs, cs)}


BYTE_DECODER = _byte_decoder()


def decode_piece(p: str) -> str:
    return bytes(BYTE_DECODER[c] for c in p).decode("utf-8", errors="replace")


def oov_prefix_rates(s: Settings, sources: dict, vocab: list[str]) -> dict[str, float]:
    """P(word is out of vocabulary | word starts with prefix), from weighted source counts.

    Only prefixes with a non-zero rate are stored; prefixes of in-vocabulary words that
    never start an OOV word are omitted (rate 0).
    """
    in_vocab = set(vocab)
    total, oov = Counter(), Counter()
    for lines, w in weighted_sources(s, sources):
        for toks in lines:
            for p in toks:
                for i in range(1, len(p) + 1):
                    total[p[:i]] += w
                    if p not in in_vocab:
                        oov[p[:i]] += w
    return {pre: oov[pre] / total[pre] for pre in oov}


class Suggester:
    def __init__(self, vocab: list[str], ngram: NGram, oov_rates: dict[str, float]):
        self.vocab, self.ngram, self.oov = vocab, ngram, oov_rates
        self.index = {p: i for i, p in enumerate(vocab)}
        self.text = [decode_piece(p) for p in vocab]
        self._dist, self._cands = {}, {}

    def dist(self, ids: list[int]) -> tuple[np.ndarray, float]:
        key = self.ngram.window(ids)
        if key not in self._dist:
            logits, support, _ = self.ngram.predict(ids)
            p = np.exp(logits - logits.max())
            self._dist[key] = (p / p.sum(), support)
        return self._dist[key]

    def candidates(self, partial: str) -> np.ndarray:
        if partial not in self._cands:
            self._cands[partial] = np.array(
                [i for i, p in enumerate(self.vocab) if i > UNK_ID and p.startswith(partial)], dtype=np.int64
            )
        return self._cands[partial]

    def steps(self, text: str, max_words: int = MAX_WORDS) -> list[Step]:
        """Greedy suggestion chain with cumulative probabilities (before any thresholding)."""
        pcs = pieces(text)
        if not pcs:
            return []
        partial = pcs[-1]
        cands = self.candidates(partial)
        if not len(cands):
            return []
        ids = [EOS_ID] + [self.index.get(p, UNK_ID) for p in pcs[:-1]]
        probs, support = self.dist(ids)
        pc = probs[cands]
        best = int(cands[pc.argmax()])
        prob = (1.0 - self.oov.get(partial, 0.0)) * pc.max() / pc.sum()
        out = [Step(self.text[best][len(decode_piece(partial)) :], prob, support)]
        ids.append(best)
        while self.vocab[ids[-1]] not in STOP_AFTER and len(out) < max_words:
            probs, support = self.dist(ids)
            nxt = int(probs.argmax())
            if nxt == EOS_ID:
                break
            prob *= probs[nxt]
            out.append(Step(self.text[nxt], prob, support))
            ids.append(nxt)
        return out

    @staticmethod
    def apply(steps: list[Step], policy: Policy) -> str:
        ghost = ""
        for i, st in enumerate(steps[: policy.max_words]):
            if st.prob < policy.tau or st.support < policy.min_support:
                break
            ghost += st.text
        return ghost

    def suggest(self, text: str, policy: Policy = Policy()) -> str:
        return self.apply(self.steps(text, policy.max_words), policy)