Skip to content

typedecide.prompt

The canonical prompt and the answer-slot probe. This module mirrors web/prompt.js; see Prompt parity and Answer-slot discovery before changing anything that affects its output.

typedecide.prompt

The canonical prompt, mirroring web/prompt.js exactly.

If these two drift, the fine-tune optimises a prompt the browser never sends and accuracy quietly collapses. tests/test_prompt_parity.py proves they agree, and it is wired into CI, because this is the most expensive silent failure available here.

INSTRUCTION module-attribute

INSTRUCTION = "Apply the criterion to the evidence and choose exactly one listed option. Answer with only the uppercase letter of that option."

Tokenizer

Bases: Protocol

The slice of a Hugging Face tokenizer this module needs.

encode

encode(
    text: str, add_special_tokens: bool = ...
) -> list[int]
Source code in src/typedecide/prompt.py
def encode(self, text: str, add_special_tokens: bool = ...) -> list[int]: ...

head_text

head_text(state: str) -> str

Everything up to and including the state. Identical across every criterion.

Source code in src/typedecide/prompt.py
def head_text(state: str) -> str:
    """Everything up to and including the state. Identical across every criterion."""
    return f"{INSTRUCTION}\n\nEvidence:\n{state}\n"

tail_text

tail_text(criterion: Criterion | dict[str, Any]) -> str

One criterion's own question and options. Never shared.

Source code in src/typedecide/prompt.py
def tail_text(criterion: Criterion | dict[str, Any]) -> str:
    """One criterion's own question and options. Never shared."""
    lines = [f"\nCriterion: {_question_of(criterion)}", "Options:"]
    for letter, (_, description) in zip(LETTERS, _options_of(criterion), strict=False):
        lines.append(f"{letter}. {description}")
    lines.append("Answer:")
    return "\n".join(lines)

full_prompt

full_prompt(
    state: str, criterion: Criterion | dict[str, Any]
) -> str
Source code in src/typedecide/prompt.py
def full_prompt(state: str, criterion: Criterion | dict[str, Any]) -> str:
    return head_text(state) + tail_text(criterion)

state_prefix_ids

state_prefix_ids(
    tokenizer: Tokenizer, state: str
) -> list[int]

Token ids of the shared head, trimmed by one.

The last token is dropped because the character that follows the state can merge into it, which would stop the head being an exact token-level prefix of every full prompt built on it.

Source code in src/typedecide/prompt.py
def state_prefix_ids(tokenizer: Tokenizer, state: str) -> list[int]:
    """Token ids of the shared head, trimmed by one.

    The last token is dropped because the character that follows the state can merge
    into it, which would stop the head being an exact token-level prefix of every
    full prompt built on it.
    """
    ids = tokenizer.encode(head_text(state), add_special_tokens=False)
    if len(ids) < 2:
        raise PromptError("The state prefix is too short to reuse.")
    return list(ids[:-1])

answer_slots

answer_slots(
    tokenizer: Tokenizer, prompt: str, count: int
) -> tuple[list[int], str]

Token ids of the answer letters at this exact prompt boundary.

These are not contiguous in any vocabulary we have checked, and whether the natural continuation is " A" or "A" depends on the preceding character, so both are probed and we keep whichever appends exactly one token without disturbing the prompt's own tokenization.

Source code in src/typedecide/prompt.py
def answer_slots(tokenizer: Tokenizer, prompt: str, count: int) -> tuple[list[int], str]:
    """Token ids of the answer letters at this exact prompt boundary.

    These are not contiguous in any vocabulary we have checked, and whether the
    natural continuation is " A" or "A" depends on the preceding character, so both
    are probed and we keep whichever appends exactly one token without disturbing
    the prompt's own tokenization.
    """
    base = tokenizer.encode(prompt, add_special_tokens=False)
    if not base:
        raise PromptError("Prompt encoded to zero tokens.")
    for separator in (" ", ""):
        slots: list[int] = []
        for letter in LETTERS[:count]:
            encoded = tokenizer.encode(prompt + separator + letter, add_special_tokens=False)
            if len(encoded) != len(base) + 1 or encoded[: len(base)] != base:
                slots = []
                break
            slots.append(encoded[-1])
        if slots and len(slots) == len(set(slots)):
            return slots, separator
    raise PromptError(
        "No answer-letter continuation keeps the prompt boundary stable for this "
        "tokenizer, so a readout would score tokens that are not the answer. The "
        "prompt tail needs adjusting for this model."
    )