"""Render a decision as model input, with one readout per question.
Options are listed under **neutral answer codes**, and the answer is read at a marker after the
options from the LM-head rows of those codes:
```
…state text, JSON or images…
Which team should handle this ticket?
BK = billing: Payments and refunds
QX = tech: Technical errors
Answer:⟨readout⟩
```
* **Codes.** Every question draws its codes from a pool of single-token strings, so no code
carries meaning across decisions.
* **Noul** (yes/no) is rendered as two options, ``holds`` and ``does_not_hold``, each with its
criterion, in random order under neutral codes.
* **Score** levels keep their order (codes are still neutral); the level index is written out.
* **Isolated** mode (default) gives each question its own sequence over the shared state, so
answers do not depend on which other questions were asked. **Joint** mode puts every question
in one sequence with one readout each.
* **Images** open the state block, in order, as the ids the image encoder returns
(``<|vision_start|>``, one ``<|image_pad|>`` per merged patch, ``<|vision_end|>``).
The readout index is the position of the last token of the ``Answer:`` marker.
"""
from __future__ import annotations
import json
import random
from dataclasses import dataclass, field
from typing import Any, Callable, Protocol, Sequence
from glance_runtime.schema import Decision, Question
NOUL_HOLDS, NOUL_NOT = "holds", "does_not_hold"
ANSWER_MARKER = "\nAnswer:"
class ImagesUnsupported(ValueError):
"""The decision has images and the model was given no image encoder."""
class Tokenizer(Protocol):
def encode(self, text: str, add_special_tokens: bool = ...) -> list[int]:
...
@dataclass
class Readout:
question: str
type: str
position: int # token index of the readout (last token of the answer marker)
codes: list[str] # code shown for each option, in display order
labels: list[str] # the question's labels, in display order
order: list[int] # display index -> index into the question's labels
@dataclass
class Example:
input_ids: list[int]
readouts: list[Readout]
text: str
decision_id: str = ""
vision: list[Any] = field(default_factory=list) # one image-encoder payload per image, in order
def _state_text(d: Decision) -> str:
return d.state if isinstance(d.state, str) else json.dumps(d.state, ensure_ascii=False, indent=1)
def _options(q: Question, rng: random.Random) -> tuple[list[str], list[str | None], list[int]]:
"""Display names, definitions and order (display index -> label index)."""
n = len(q.labels)
defs = list(q.definitions) if q.definitions else [None] * n
if q.type == "noul":
holds = defs[1] or "the statement in the question is true"
not_holds = defs[0] or "the statement in the question is false"
order = [1, 0] if rng.random() < 0.5 else [0, 1]
names = {1: NOUL_HOLDS, 0: NOUL_NOT}
texts = {1: holds, 0: not_holds}
return [names[i] for i in order], [texts[i] for i in order], order
order = list(range(n)) # choice options keep the caller's order; score keeps its order
if q.type == "score":
return [f"level {i} of {n - 1}" for i in order], [q.labels[i] for i in order], order
return [q.labels[i] for i in order], [defs[i] for i in order], order
def question_block(q: Question, codes: list[str], rng: random.Random) -> tuple[str, list[int]]:
names, defs, order = _options(q, rng)
lines = [f"", q.instructions.strip() or "Choose the option that fits."]
for code, nm, df in zip(codes, names, defs):
lines.append(f"{code} = {nm}: {df}" if df else f"{code} = {nm}")
return "\n".join(lines) + "\n", order
def render(d: Decision, tokenizer: Tokenizer, code_pool: Sequence[str], rng: random.Random, *,
joint: bool = False, max_tokens: int | None = None,
image_encoder: Callable[[dict[str, Any]], tuple[list[int], Any]] | None = None) -> list[Example]:
"""Render ``d`` into one example per question (isolated) or one example (joint)."""
if d.images and image_encoder is None:
raise ImagesUnsupported(f"{len(d.images)} image(s) and no image encoder")
encoded = [image_encoder(im) for im in d.images] if d.images else [] # once per decision, shared by its examples
state = f"\n{_state_text(d)}\n"
blocks = []
for name, q in d.questions.items():
n = len(q.labels)
if n > len(code_pool):
raise ValueError(f"{name}: {n} options but only {len(code_pool)} codes")
codes = rng.sample(list(code_pool), n)
text, order = question_block(q, codes, rng)
blocks.append((text, Readout(name, q.type, -1, codes, [q.labels[i] for i in order], order)))
groups = [blocks] if joint else [[b] for b in blocks]
out = []
for group in groups:
ids: list[int] = []
text_parts: list[str] = []
readouts = []
def add(s: str) -> None:
ids.extend(tokenizer.encode(s, add_special_tokens=False))
text_parts.append(s)
def add_state(suffix: str = "") -> None:
if not encoded:
add(state + suffix)
return
add("\n")
for image_ids, _ in encoded:
ids.extend(image_ids)
text_parts.append("")
add("\n")
add(f"{_state_text(d)}\n{suffix}")
add_state("\n")
for qtext, ro in group:
add(qtext)
add(ANSWER_MARKER)
ro.position = len(ids) - 1
readouts.append(ro)
add("\n")
if max_tokens is not None and len(ids) > max_tokens:
raise ValueError(f"{len(ids)} tokens exceeds {max_tokens}")
out.append(Example(ids, readouts, "".join(text_parts), d.id, [v for _, v in encoded]))
return out