File size: 9,561 Bytes
0a2e8cf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""The one-pass readout: letter scores -> calibrated probabilities -> answers (choice / score / noul).

The calibration math, the two-stage fallback above 52 options and the confidence formulas are the ones of OpenJev's released helper
(helper/shim.py, sha 81a22f1b): with the same profile constants this module produces the same answers, and tests/test_compat.py
holds that equality on fixtures.
"""

from __future__ import annotations

import asyncio
import json
import math
import random
from dataclasses import dataclass, field
from typing import Any, ClassVar

from .backends.base import Backend
from .backends.letters import LETTERS
from .profile import Profile
from .prompt import State, build_prompt, make_state, prefix_text

MAX_ONE_PASS = len(LETTERS)


class ReadoutIncomplete(RuntimeError):
    """The backend returned no finite score for one or more candidates. Never floored: the answer would be a guess."""


class BadQuestion(ValueError):
    """A question the API cannot answer (wrong type, missing instructions, bad criteria). Reported as HTTP 422."""


def softmax(z: list[float]) -> list[float]:
    m = max(z)
    e = [math.exp(v - m) for v in z]
    s = sum(e)
    return [v / s for v in e]


def choice_confidence(p: list[float]) -> float:
    """TypeSafe's official formula (system-one-adapter-python confidence_metrics.py)."""
    if len(p) == 1:
        return 1.0
    u = 1.0 / len(p)
    return max(0.0, (max(p) - u) / (1.0 - u))


def score_confidence(p: list[float]) -> float:
    if len(p) == 1:
        return 1.0
    mode = max(range(len(p)), key=p.__getitem__)
    dist = sum(pi * abs(i - mode) for i, pi in enumerate(p))
    c = (len(p) - 1) / 2
    umad = sum(abs(i - c) for i in range(len(p))) / len(p)
    return max(0.0, 1.0 - dist / umad)


def compose_chunks(parts: list[tuple[list[float], int]], final: list[float]) -> list[float]:
    """Two-stage distribution above 52 options: p(i) is proportional to p_final(chunk of i) * p_chunk(i) / p_chunk(winner of that chunk)."""
    raw = [final[c] * pi / p[w] for c, (p, w) in enumerate(parts) for pi in p]
    s = sum(raw)
    return [v / s for v in raw]


@dataclass
class Readout:
    backend: Backend
    profile: Profile
    stats: dict[str, int] = field(default_factory=lambda: {"calls": 0, "prompt_tokens": 0})

    async def _once(self, state_text: State, instructions: str, options: list[tuple[str, str]]) -> tuple[list[float], int]:
        """One lettering, one prefill: temperature-scaled probabilities aligned with `options`."""
        ids = self.backend.letter_ids()
        allowed = [ids[LETTERS[i]] for i in range(len(options))]
        if len(set(allowed)) != len(allowed):
            raise ReadoutIncomplete("candidate token ids are not unique for this tokenizer")
        content = build_prompt(state_text, instructions, options, self.profile, self.backend)
        raw, tokens = await self.backend.logprobs(content, allowed)
        if len(raw) != len(allowed) or any(v is None or not math.isfinite(v) for v in raw):
            n = sum(1 for v in raw if v is None or not math.isfinite(v))
            raise ReadoutIncomplete(f"{n} of {len(allowed)} candidate scores missing or not finite")
        self.stats["calls"] += 1
        self.stats["prompt_tokens"] += tokens
        return softmax([v / self.profile.temp for v in raw]), tokens

    async def readout(self, state_text: State, instructions: str, options: list[tuple[str, str]]) -> tuple[list[float], int]:
        """Averages over `profile.perms` letterings; probabilities stay aligned with the caller's option order."""
        if self.profile.perms <= 1:
            return await self._once(state_text, instructions, options)
        orders = []
        for j in range(self.profile.perms):
            order = list(range(len(options)))
            random.Random(j).shuffle(order)
            orders.append(order)
        results = await asyncio.gather(*(self._once(state_text, instructions, [options[i] for i in order]) for order in orders))
        acc = [0.0] * len(options)
        tokens = 0
        for order, (p, t) in zip(orders, results, strict=True):
            tokens += t
            for pos, i in enumerate(order):
                acc[i] += p[pos] / self.profile.perms
        return acc, tokens

    async def distribution(self, state_text: State, instructions: str, opts: list[tuple[str, str]]) -> tuple[list[float], int]:
        """Up to 52 options in one pass; above that per-chunk readouts (run concurrently), then one over the chunk winners, composed."""
        if len(opts) <= MAX_ONE_PASS:
            return await self.readout(state_text, instructions, opts)
        k = -(-len(opts) // MAX_ONE_PASS)
        size = -(-len(opts) // k)
        chunks = [opts[i : i + size] for i in range(0, len(opts), size)]
        chunk_results = await asyncio.gather(*(self.readout(state_text, instructions, chunk) for chunk in chunks))
        parts = [(p, max(range(len(p)), key=p.__getitem__)) for p, _ in chunk_results]
        tokens = sum(t for _, t in chunk_results)
        final, t = await self.readout(state_text, instructions, [chunk[w] for chunk, (_, w) in zip(chunks, parts, strict=True)])
        return compose_chunks(parts, final), tokens + t

    # ---- answer types

    def _instr(self, q: dict) -> str:
        i = q.get("instructions")
        if i is None:
            raise BadQuestion("instructions is required")
        if isinstance(i, str):
            return i
        return str(i) if self.profile.instr_style == "pyrepr" else json.dumps(i, ensure_ascii=False)

    @staticmethod
    def _desc(v) -> str:
        return "" if v is None else (v if isinstance(v, str) else json.dumps(v, ensure_ascii=False))

    async def answer_choice(self, state_text: State, q: dict) -> tuple[dict, int]:
        crit = q.get("criteria")
        if not isinstance(crit, dict) or not crit:
            raise BadQuestion("choice.criteria must be a non-empty map of option -> description|null")
        opts = [(k, self._desc(v)) for k, v in crit.items()]
        p, tokens = await self.distribution(state_text, self._instr(q), opts)
        choice = opts[max(range(len(p)), key=lambda i: p[i])][0]  # winner picked before rounding
        return {
            "type": "choice",
            "choice": choice,
            "probabilities": {k: round(v, 4) for (k, _), v in zip(opts, p, strict=True)},
            "confidence": round(choice_confidence(p), 4),
        }, tokens

    async def answer_score(self, state_text: State, q: dict) -> tuple[dict, int]:
        levels = q.get("criteria")
        if not isinstance(levels, list) or len(levels) < 2:
            raise BadQuestion("score.criteria must be an ordered array of at least two levels")
        opts = [(str(i), self._desc(level)) for i, level in enumerate(levels)]
        p, tokens = await self.distribution(state_text, self._instr(q) + " Rate along the ordered levels below (lowest first).", opts)
        return {
            "type": "score",
            "score": round(sum(i * pi for i, pi in enumerate(p)), 4),
            "legend": {str(i): level for i, level in enumerate(levels)},
            "probabilities": {str(i): round(pi, 4) for i, pi in enumerate(p)},
            "confidence": round(score_confidence(p), 4),
        }, tokens

    async def answer_noul(self, state_text: State, q: dict) -> tuple[dict, int]:
        crit = q.get("criteria") or {}
        yes = self._desc(crit.get("true")) or "The statement is true."
        no = self._desc(crit.get("false")) or "The statement is false."
        p, tokens = await self.distribution(state_text, self._instr(q), [("yes", yes), ("no", no)])
        py = min(max(p[0], 1e-4), 1 - 1e-4)
        z = math.log(py / (1 - py)) / self.profile.noul_t + self.profile.noul_bias
        return {"type": "noul", "noul": round(1 / (1 + math.exp(-z)), 4)}, tokens

    ANSWER: ClassVar[dict[str, str]] = {"choice": "answer_choice", "score": "answer_score", "noul": "answer_noul"}

    async def answer(self, state_text: State, q: dict) -> tuple[dict, int]:
        kind = q.get("type") if isinstance(q, dict) else None
        if kind not in self.ANSWER:
            raise BadQuestion("type must be one of choice, score, noul")
        return await getattr(self, self.ANSWER[kind])(state_text, q)

    async def answer_all(self, state: Any, questions: dict[str, dict]) -> tuple[dict[str, dict], int]:
        """Every question against one state. Questions run concurrently; on a long state the first runs alone so its prefix is cached first."""
        state_text = make_state(state, self.profile)
        items = list(questions.items())
        for qid, q in items:
            if not isinstance(q, dict) or q.get("type") not in self.ANSWER:
                raise BadQuestion(f"questions.{qid}.type must be one of choice, score, noul")
        results: list[tuple[dict, int]] = []
        if self.profile.stagger and len(state_text) >= self.profile.stagger_min_chars and len(items) > 1:
            results.append(await self.answer(state_text, items[0][1]))
            results += list(await asyncio.gather(*(self.answer(state_text, q) for _, q in items[1:])))
        else:
            results = list(await asyncio.gather(*(self.answer(state_text, q) for _, q in items)))
        return {qid: a for (qid, _), (a, _) in zip(items, results, strict=True)}, int(sum(t for _, t in results))

    async def prewarm(self, state: Any) -> int:
        return await self.backend.prewarm(prefix_text(make_state(state, self.profile), self.profile, self.backend))