File size: 10,576 Bytes
0fda8e6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""The Jebadiah prompt: AINode's own /v1/systemone -> /v1/decide rendering, then the base model's
chat template, then ONE label token. Train, eval and the local server all go through here, and
the served route (AINode) renders the same bytes because the renderer is AINode's, copied
verbatim (ainode_prompt_verbatim.py, hashed into the training config).

A question is the Jev wire shape {type, instructions, criteria}. AINode translates it to a list of
option lines with letter labels (A..Z, AA..) and the wire names each label answers to:
  choice -> the criteria keys, in criteria order (or the order the caller passes)
  noul   -> ["true", "false"]  (always this order, AINode's rule)
  score  -> the level texts in rubric order; we report them as level indices "0".."k-1"
The answer position is the token right after the generation prompt, and the candidate tokens are
the label strings themselves ("A", "B", ...), each one ordinary token at that boundary (checked
against the tokenizer at load time).
"""
from __future__ import annotations

import json
from dataclasses import dataclass

from ainode_prompt_verbatim import (
    MAX_CRITERIA,
    PROMPT_SOURCE_COMMIT,
    PROMPT_SOURCE_SHA256,
    Translated,
    build_messages,
    criteria_pairs,
    option_label,
    option_text,
    serialize_state,
    translate_one,
)

# Keys that must never appear inside a question object sent to the model. The linter rejects
# them; the server refuses them.
LABEL_KEYS = {"label", "labels", "expected", "passingAnswer", "passing_answer", "answer", "answers",
              "target", "targets", "gold", "reference", "truth", "correct"}

CHAT_TEMPLATE_KWARGS = {"add_generation_prompt": True, "enable_thinking": False, "thinking": False}


def flatten_text(v) -> str:
    """Instructions and descriptions must be strings on AINode's wire. Sources that ship an object
    or a list (Kev's {question, focus}) are flattened once, at conversion time, by joining the
    string values with a space. Never called on a state."""
    if v is None:
        return ""
    if isinstance(v, str):
        return v.strip()
    if isinstance(v, dict):
        return " ".join(s for s in (flatten_text(x) for x in v.values()) if s)
    if isinstance(v, list):
        return " ".join(s for s in (flatten_text(x) for x in v) if s)
    return json.dumps(v, ensure_ascii=False)


def wire_keys(q: dict, order: list | None = None) -> list[str]:
    """The answer keys of a question in the order the options are shown."""
    t = q["type"]
    crit = q.get("criteria")
    if t == "choice":
        keys = list(crit) if isinstance(crit, dict) else [str(c) for c in crit]
        if order is not None:
            assert sorted(order) == sorted(keys), "order must be a permutation of the option keys"
            keys = list(order)
        return keys
    if t == "noul":
        return ["true", "false"]
    if t == "score":
        return [str(i) for i in range(len(crit))]
    raise ValueError(f"unknown question type {t!r}")


def reorder_choice(q: dict, order: list) -> dict:
    """The same choice question with its criteria in `order` (AINode shows criteria order)."""
    crit = q["criteria"]
    if not isinstance(crit, dict):
        crit = {str(c): None for c in crit}
    return {**q, "criteria": {k: crit[k] for k in order}}


@dataclass
class Rendered:
    prompt: str            # the chat-templated text ending in the generation prompt
    keys: list[str]        # wire keys in display order
    letters: list[str]     # the label shown for each key, same order
    cand_ids: list[int]    # token id of each label at the answer boundary, same order
    truncated: bool        # the state was cut to fit the token budget
    label_scheme: str = "ainode"   # "ainode" (option_label, what /v1/decide emits) or "extended"


class Renderer:
    """Renders (state, question) exactly as AINode's /v1/systemone would, with this tokenizer's
    chat template, and knows the label token ids."""

    def __init__(self, tokenizer, max_tokens: int = 2048):
        self.tok = tokenizer
        self.max_tokens = max_tokens
        self._label_ids: dict[str, int] = {}
        # every label AINode can emit up to 255 options must be one ordinary token at the boundary
        probe = self.render_messages("x", "q?", ["o1", "o2"])
        base = tokenizer.encode(probe, add_special_tokens=False)
        specials = set(tokenizer.all_special_ids)
        for i in range(255):
            lab = option_label(i)
            comb = tokenizer.encode(probe + lab, add_special_tokens=False)
            suffix = comb[len(base):]
            if comb[:len(base)] != base or len(suffix) != 1 or suffix[0] in specials:
                break
            self._label_ids[lab] = suffix[0]
        self.max_options = len(self._label_ids)
        if self.max_options < 26:
            raise ValueError(f"only {self.max_options} labels are single tokens in this tokenizer")
        # Past AINode's single-token range (68 labels on the Qwen3.5 tokenizer: A..Z, AA..AZ,
        # BA..BP) the served route cannot answer anyway (its cap is 20). For the LOCAL read of a
        # wider question (Jevals Banking77, 77 options) we fall back to an extended alphabet: the
        # same A..Z, then every two-letter uppercase string that is one ordinary token at the
        # boundary, in alphabetical order. Rendered.label_scheme says which alphabet was used.
        import string
        self._extended: list[tuple[str, int]] = [(L, i) for L, i in self._label_ids.items() if len(L) == 1]
        for a in string.ascii_uppercase:
            for b in string.ascii_uppercase:
                lab = a + b
                comb = tokenizer.encode(probe + lab, add_special_tokens=False)
                suffix = comb[len(base):]
                if comb[:len(base)] == base and len(suffix) == 1 and suffix[0] not in specials:
                    self._extended.append((lab, suffix[0]))
        self.max_options_extended = len(self._extended)

    def render_messages(self, state_text: str, question: str, options: list[str],
                        letters: list[str] | None = None) -> str:
        messages = build_messages(state_text, None, question, options)
        if letters is not None:
            # the extended alphabet: swap AINode's letters for ours in the option lines, nothing else
            user = messages[1]["content"]
            head, _, rest = user.partition("\nOPTIONS:\n")
            lines = rest.split("\n")
            for i in range(len(options)):
                assert lines[i].startswith(f"{option_label(i)}. ")
                lines[i] = f"{letters[i]}. " + lines[i][len(option_label(i)) + 2:]
            messages[1]["content"] = head + "\nOPTIONS:\n" + "\n".join(lines)
        return self.tok.apply_chat_template(messages, tokenize=False, **CHAT_TEMPLATE_KWARGS)

    def render(self, state, q: dict, order: list | None = None) -> Rendered:
        if q["type"] == "choice" and order is not None:
            q = reorder_choice(q, order)
        crit = q.get("criteria")
        if q["type"] == "choice" and isinstance(crit, dict) and len(crit) > MAX_CRITERIA:
            # AINode's route refuses this many options (its top-20 logprob read); the local read
            # renders them the same way and scores every label directly off the logits
            pairs = criteria_pairs("q", crit, "choice")
            t = Translated("choice", str(q.get("instructions", "")).strip(),
                           [option_text(n, d) for n, d in pairs], [n for n, _ in pairs])
        else:
            t = translate_one("q", q)
        keys = wire_keys(q, order)
        n_opt = len(t.names)
        scheme = "ainode"
        if n_opt <= self.max_options:
            letters = [option_label(i) for i in range(n_opt)]
            cand_ids = [self._label_ids[L] for L in letters]
            shown = None
        elif n_opt <= self.max_options_extended:
            letters = [L for L, _ in self._extended[:n_opt]]
            cand_ids = [i for _, i in self._extended[:n_opt]]
            shown = letters
            scheme = "extended"
        else:
            raise ValueError(f"{n_opt} options exceed the {self.max_options_extended} single-token labels")
        state_text = serialize_state(state)
        prompt = self.render_messages(state_text, t.question, t.options, shown)
        truncated = False
        n = len(self.tok.encode(prompt, add_special_tokens=False))
        if n > self.max_tokens:
            # cut the state from its end so the question, options and template survive intact
            overhead = n - len(self.tok.encode(state_text, add_special_tokens=False))
            keep = max(self.max_tokens - overhead - 4, 16)
            ids = self.tok.encode(state_text, add_special_tokens=False)[:keep]
            state_text = self.tok.decode(ids) + " [truncated]"
            prompt = self.render_messages(state_text, t.question, t.options, shown)
            truncated = True
        return Rendered(prompt, keys, letters, cand_ids, truncated, scheme)


def answer_from_probs(q: dict, keys: list[str], probs: list[float]) -> dict:
    """The Jev wire answer for one question from its probabilities over `keys` (display order).
    choice: {type, choice, confidence, probabilities}; noul: {type, noul}; score: {type, score,
    confidence, legend, probabilities}. Ties: choice -> the option listed first; score -> the
    lower level (the Jevals rule). Confidence is (p_max - 1/n) / (1 - 1/n), the chance-corrected
    top probability AINode's route also reports."""
    t = q["type"]
    if t == "noul":
        return {"type": "noul", "noul": round(float(probs[keys.index("true")]), 6)}
    best = 0
    for i, p in enumerate(probs):
        if p > probs[best]:
            best = i
    n = len(keys)
    p_max = float(probs[best])
    confidence = 1.0 if n == 1 else (p_max - 1.0 / n) / (1.0 - 1.0 / n)
    if t == "choice":
        return {"type": "choice", "choice": keys[best], "confidence": round(confidence, 6),
                "probabilities": {k: round(float(p), 6) for k, p in zip(keys, probs)}}
    expected = sum(int(k) * float(p) for k, p in zip(keys, probs))
    crit = q.get("criteria") or []
    legend = {str(i): (c if isinstance(c, str) else str(c)) for i, c in enumerate(crit)} if isinstance(crit, list) \
        else {str(i): k for i, k in enumerate(crit)}
    return {"type": "score", "score": round(expected, 6), "confidence": round(confidence, 6),
            "legend": legend, "probabilities": {k: round(float(p), 6) for k, p in zip(keys, probs)}}