jebadiah-4b-v2-GGUF / scripts /jebadiah_prompt.py
jbrashear's picture
Quantized weights, tokenizer, temperatures, scripts
0fda8e6 verified
Raw History Blame Contribute Delete
10.6 kB
"""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)}}