File size: 11,734 Bytes
97c5704
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""The System One API: named questions over a state in, typed answers out.

    model.system_one(state, {"refund": {"type": "noul", "instructions": "Is this a refund request?"}})
    # {"answers": {"refund": {"type": "noul", "noul": p}}, "usage": {"input_tokens": n, "output_tokens": 0}}

A state is a string, any JSON value, or None with media alone; `media` holds its pictures, audio clips and videos
(`media`). A question is a dict in the Decision Index's schema (`type`, `instructions`, `criteria`; spoken
instructions a media dict) or one of `prompt`'s classes.
"""

from __future__ import annotations

from collections.abc import Mapping, Sequence
from typing import Any

from .media import Media, media_list
from .prompt import (
    DEFAULT_STATE_STYLE,
    Box,
    Boxes,
    Choice,
    Estimate,
    Noul,
    Question,
    Read,
    as_question,
    check_question,
    state_text,
    typed,
)

# How a score question's distribution becomes its answer. `median`, the default: the first level whose cumulative
# probability reaches one half, optimal for absolute error (ordinal MAE). `mean`: the expected level, which a caller
# rounds (the Decision Index's rule), optimal only for squared error. `argmax`: the likeliest level. `median` and
# `argmax` answer a level, which rounding leaves as it is.
SCORE_READOUTS = ("mean", "median", "argmax")
SCORE_READOUT = "median"
# An explicit "I don't know" option: a choice option whose text (its description, else its name) says the answerer
# does not know. Not an option saying the evidence is insufficient or that a request cannot be served: those are
# answers. `abstain_below` (off by default) picks it when the best other option's probability, renormalised over the
# other options, is below the threshold.
IDK_TEXTS = frozenset({"i don't know", "i do not know", "i don't know the answer", "i do not know the answer"})


def abstain_option(criteria: Mapping[str, str | None] | None) -> str | None:
    """The name of a choice's explicit "I don't know" option (the English `IDK_TEXTS`), or None."""
    if not isinstance(criteria, Mapping):
        return None
    for name, text in criteria.items():
        if str(text or name).strip().lower().replace("\u2019", "'").rstrip(".!") in IDK_TEXTS:
            return name
    return None


def choice_index(probs: Sequence[float], abstain: int | None = None, abstain_below: float | None = None) -> int:
    """A choice's answer: the likeliest option; with `abstain` (the "I don't know" option's index) and
    `abstain_below`, that option when the best other option holds less than `abstain_below` of the other options'
    mass."""
    best = max(range(len(probs)), key=probs.__getitem__)
    if abstain is None or abstain_below is None:
        return best
    others = [p for i, p in enumerate(probs) if i != abstain]
    total = sum(others)
    return abstain if total <= 0 or max(others) < abstain_below * total else best


def score_level(probs: Sequence[float], readout: str = SCORE_READOUT) -> int:
    """A score question's answer as a level index under `readout` (`mean`: rounded, clamped to the levels)."""
    total = sum(probs) or 1.0
    if readout == "mean":
        return max(0, min(len(probs) - 1, int(round(sum(i * p / total for i, p in enumerate(probs))))))
    if readout == "median":
        mass = 0.0
        for i, p in enumerate(probs):
            mass += p / total
            if mass >= 0.5:
                return i
        return len(probs) - 1
    if readout == "argmax":
        return max(range(len(probs)), key=probs.__getitem__)
    raise ValueError(f"score_readout {readout!r}: one of {SCORE_READOUTS}")


def answer(q: Question, probs: Sequence[float] | Sequence[Sequence[float]] | Mapping, score_readout: str = SCORE_READOUT,
           abstain_below: float | None = None, context: str = "", calibration=None, edges: str = "median") -> dict:
    """A noul's P(yes); a choice's pick (`choice_index`) and its probabilities; a score's level under
    `score_readout` (`mean`: the expected level, unrounded); a typed answer's probabilities through `calibration`'s
    tables (a read is found, a box drawn, an option chosen, at 0.5 after them; an estimate's quantiles read at the
    levels its table gives). `edges`: how a box's edges are read (`box.EDGE_READS`, the checkpoint's `d1_box_edges`)."""
    through = calibration.through if calibration is not None else lambda name, value: value
    if isinstance(q, Box):
        from .box import answer as box_answer

        return box_answer(probs, through, edges)
    if isinstance(q, Boxes):
        from .box import answer_many

        return answer_many(probs, through, edges)
    if isinstance(q, Estimate):
        value = q.grid.read(probs, None if calibration is None else lambda share: calibration.level("estimate", share))
        return {"type": "number", "number": value["median"], "interval": [value["p10"], value["p90"]], "unit": q.unit}
    if isinstance(q, Read):
        from .parsing import parse_date, parse_number, written_unit

        found = through(f"found:{q.type}", probs["found"])
        present = found >= 0.5
        text = probs["text"] if present else None
        span = probs["span"] if present else None
        value, unit = text, q.unit
        if text is not None and q.type == "number":
            unit = unit or written_unit(text)
            value = parse_number(text, context, unit=q.unit)
        elif text is not None and q.type == "date":
            value, _ = parse_date(text, context)
        result = {"type": q.type, q.type: value, "span": span, "found": found}
        if q.type != "text":
            result["text"] = text
        if present and "path" in probs:
            result["path"] = probs["path"]
        if q.type == "number":
            result["unit"] = unit
        return result
    if isinstance(q, Noul):
        return {"type": "noul", "noul": probs[0]}
    if isinstance(q, Choice) and q.multiple:
        probs = [through("multiple", p) for p in probs]
        return {"type": "choice", "choices": [name for name, p in zip(q.criteria, probs) if p >= 0.5],
                "probabilities": dict(zip(q.criteria, probs))}
    best = max(range(len(probs)), key=probs.__getitem__)
    if isinstance(q, Choice):
        names = list(q.criteria)
        idk = abstain_option(q.criteria) if abstain_below is not None else None
        pick = choice_index(probs, None if idk is None else names.index(idk), abstain_below)
        return {"type": "choice", "choice": names[pick], "confidence": probs[pick],
                "probabilities": dict(zip(names, probs))}
    level = (sum(i * p for i, p in enumerate(probs)) if score_readout == "mean"
             else float(score_level(probs, score_readout)))
    return {"type": "score", "score": level, "confidence": probs[best],
            "probabilities": {str(i): p for i, p in enumerate(probs)},
            "legend": {str(i): text for i, text in enumerate(q.criteria)}}


def request_media(request: tuple) -> list[Media]:
    """A `(state, questions)` or `(state, questions, media)` request's media."""
    return media_list(request[2] if len(request) > 2 else None)


class SystemOneApi:
    """`system_one` and `system_one_batch`, and the plain probabilities under them, over a model's
    `run(requests)`: each `(state, questions, media)` request's probabilities (in its questions' order)
    and the tokens it read."""

    serves_typed = True  # an engine without the slots and state positions of slots.py says False

    def ask(self, q: Mapping | Question) -> Question:
        """A caller's question, refused with what to change, or when this engine cannot serve its type."""
        check_question(q, getattr(self, "tokenizer", None))
        q = as_question(q)
        if typed(q) and not self.serves_typed:
            raise ValueError(f"{type(self).__name__} serves noul, choice and score questions only")
        return q

    def named(self, questions: Mapping[str, Any]) -> dict[str, Question]:
        """A request's named questions (`ask`), an error naming the question it is about."""
        if not isinstance(questions, Mapping):
            raise ValueError(f'questions are a dict of name to question, {{"refund": {{"type": "noul", "instructions": '
                             f'"Is it a refund request?"}}}}, not {type(questions).__name__}')
        out = {}
        for name, q in questions.items():
            try:
                out[name] = self.ask(q)
            except ValueError as e:
                raise ValueError(f"question {name!r}: {e}") from None
        return out

    def answer(self, state: Any, q: Question, probs, score_readout: str = SCORE_READOUT,
               abstain_below: float | None = None) -> dict:
        """One question's answer as `system_one` gives it: a read's value parsed in its state, a typed answer's
        probabilities through the served calibration's tables."""
        context = state_text(state, getattr(self, "state_style", DEFAULT_STATE_STYLE)) if isinstance(q, Read) else ""
        return answer(q, probs, score_readout, abstain_below, context, getattr(self, "calibration", None),
                      getattr(getattr(self, "tokenizer", None), "d1_box_edges", "median"))

    def run(self, requests: Sequence[tuple[Any, list[Question], Sequence[Media]]],
            set_size: int | None = None) -> list[tuple[list[list[float]], int]]:
        raise NotImplementedError

    def probabilities(self, state: Any, questions: Sequence, media: Sequence | None = None) -> list[list[float]]:
        """Each question's option probabilities; a multiple choice returns independent P(yes) per option."""
        return self.run([(state, [self.ask(q) for q in questions], media_list(media))])[0][0]

    def system_one(self, state: Any, questions: Mapping[str, Any], media: Sequence | None = None,
                   score_readout: str = SCORE_READOUT, abstain_below: float | None = None,
                   set_size: int | None = None) -> dict:
        """Named questions over one state, and its media if any (pictures, audio clips, videos), in one forward
        pass."""
        return self.system_one_batch([(state, questions, media)], score_readout, abstain_below, set_size)[0]

    def system_one_batch(self, requests: Sequence[tuple], score_readout: str = SCORE_READOUT,
                         abstain_below: float | None = None, set_size: int | None = None) -> list[dict]:
        """Many `(state, questions)` or `(state, questions, media)` requests in one call, batched as the engine
        reads them (`run`); a request with several questions reads its state once a pass.
        `score_readout` (`SCORE_READOUTS`) sets how a score question answers; `abstain_below` (off by default)
        when a choice with an "I don't know" option picks it (`choice_index`); `set_size` how many questions are
        read together after the state, for this call (the model's trained size by default)."""
        if score_readout not in SCORE_READOUTS:
            raise ValueError(f"score_readout {score_readout!r}: one of {SCORE_READOUTS}")
        if abstain_below is not None and not 0 < abstain_below <= 1:
            raise ValueError(f"abstain_below {abstain_below!r}: a share in (0, 1]")
        named = [(r[0], self.named(r[1]), request_media(r)) for r in requests]
        done = self.run([(state, list(qs.values()), media) for state, qs, media in named], set_size=set_size)
        return [{"answers": {n: self.answer(state, q, p, score_readout, abstain_below) for (n, q), p in zip(qs.items(), probs)},
                 "usage": {"input_tokens": read, "output_tokens": 0}}
                for (state, qs, _), (probs, read) in zip(named, done)]