| """Turn (state, questions) into token ids + slot bookkeeping. |
| |
| Layout (one sequence, causal): |
| |
| <|ts_state|> {state text} |
| <|ts_q|><|ts_choice|> {instructions} |
| <|ts_opt_0|> {option name}: {description} |
| <|ts_opt_1|> {option name} |
| ... |
| <|ts_answer|> <- hidden state here -> SlotHead (256 logits) |
| <|ts_q|><|ts_noul|> {instructions} |
| <|ts_opt_0|> no |
| <|ts_opt_1|> yes |
| <|ts_answer|> |
| ... |
| |
| Slot i (0..254) means "the option introduced by <|ts_opt_i|>"; slot 255 = abstain. |
| All answers for all questions are read out from one forward pass. |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import random |
| from dataclasses import dataclass, field |
| from typing import Any, Dict, List, Optional, Sequence, Tuple, Union |
|
|
| from .types import Choice, Noul, Score, Question, MAX_OPTIONS |
|
|
| N_SLOTS = 256 |
| ABSTAIN_SLOT = 255 |
|
|
| TOK_STATE = "<|ts_state|>" |
| TOK_Q = "<|ts_q|>" |
| TOK_CHOICE = "<|ts_choice|>" |
| TOK_SCORE = "<|ts_score|>" |
| TOK_NOUL = "<|ts_noul|>" |
| TOK_ANSWER = "<|ts_answer|>" |
| TOK_OPT = [f"<|ts_opt_{i}|>" for i in range(N_SLOTS)] |
|
|
| SPECIAL_TOKENS: List[str] = [TOK_STATE, TOK_Q, TOK_CHOICE, TOK_SCORE, TOK_NOUL, TOK_ANSWER] + TOK_OPT |
|
|
| NOUL_OPTIONS = ("no", "yes") |
|
|
| DEFAULT_MAX_TOTAL_TOKENS = 65536 |
| DEFAULT_MAX_STATE_TOKENS = 32768 |
|
|
|
|
| def add_special_tokens(tokenizer) -> int: |
| """Register the TypeSafe control tokens. Returns number of tokens added.""" |
| existing = set(tokenizer.get_vocab()) |
| new = [t for t in SPECIAL_TOKENS if t not in existing] |
| if not new: |
| return 0 |
| return tokenizer.add_tokens(new, special_tokens=True) |
|
|
|
|
| def sanitize(text: str) -> str: |
| """Stop user content from smuggling control tokens into the sequence.""" |
| return text.replace("<|ts_", "<|ts_") if "<|ts_" in text else text |
|
|
|
|
| def state_to_text(state: Union[str, Dict[str, Any], List[Any]], *, indent: Optional[int] = None) -> str: |
| if isinstance(state, str): |
| return state |
| return json.dumps(state, ensure_ascii=False, indent=indent) |
|
|
|
|
| @dataclass |
| class QuestionSpec: |
| """A question flattened to option strings + slot bookkeeping.""" |
|
|
| qid: str |
| qtype: str |
| instructions: str |
| option_names: List[str] |
| option_descs: List[Optional[str]] |
| perm: List[int] |
| label_slot: Optional[int] = None |
|
|
|
|
| def question_to_spec( |
| qid: str, |
| q: Question, |
| *, |
| label: Optional[Union[str, int, bool]] = None, |
| shuffle: bool = False, |
| rng: Optional[random.Random] = None, |
| drop_label: bool = False, |
| perm: Optional[Sequence[int]] = None, |
| ) -> QuestionSpec: |
| """Flatten a typed question. |
| |
| label: for training. Choice -> option name; Score -> level index (int); Noul -> bool. |
| shuffle: permute option order (Choice only; Score/Noul order is semantic). |
| drop_label: remove the correct option from a Choice so the target becomes ABSTAIN_SLOT. |
| perm: explicit option order for a Choice (list of original indices), e.g. a cyclic shift for order-invariant inference. |
| """ |
| if isinstance(q, Choice): |
| names = list(q.criteria.keys()) |
| descs = [q.criteria[n] for n in names] |
| idx = list(range(len(names))) |
| label_idx = None |
| if label is not None: |
| if label not in q.criteria: |
| raise ValueError(f"label {label!r} is not one of the options") |
| label_idx = names.index(str(label)) |
| if drop_label and label_idx is not None: |
| if len(idx) < 2: |
| raise ValueError("cannot drop the only option") |
| idx.remove(label_idx) |
| label_idx = None |
| if perm is not None: |
| idx = [i for i in perm if i in idx] |
| elif shuffle: |
| (rng or random).shuffle(idx) |
| names_p = [names[i] for i in idx] |
| descs_p = [descs[i] for i in idx] |
| if label is None: |
| slot = None |
| elif label_idx is None: |
| slot = ABSTAIN_SLOT |
| else: |
| slot = idx.index(label_idx) |
| return QuestionSpec(qid, "choice", q.instructions, names_p, descs_p, idx, slot) |
|
|
| if isinstance(q, Score): |
| names = [str(i) for i in range(len(q.criteria))] |
| descs = list(q.criteria) |
| slot = int(label) if label is not None else None |
| if slot is not None and not (0 <= slot < len(descs)): |
| raise ValueError("score label out of range") |
| return QuestionSpec(qid, "score", q.instructions, names, descs, list(range(len(names))), slot) |
|
|
| if isinstance(q, Noul): |
| c = q.criteria or {} |
| descs = [c.get("false"), c.get("true")] |
| slot = None if label is None else int(bool(label)) |
| return QuestionSpec(qid, "noul", q.instructions, list(NOUL_OPTIONS), descs, [0, 1], slot) |
|
|
| raise TypeError(type(q)) |
|
|
|
|
| def spec_to_text(spec: QuestionSpec) -> str: |
| head = {"choice": TOK_CHOICE, "score": TOK_SCORE, "noul": TOK_NOUL}[spec.qtype] |
| lines = [f"{TOK_Q}{head} {sanitize(spec.instructions).strip()}"] |
| for i, (name, desc) in enumerate(zip(spec.option_names, spec.option_descs)): |
| name = sanitize(str(name)).strip() |
| if desc: |
| lines.append(f"{TOK_OPT[i]} {name}: {sanitize(str(desc)).strip()}") |
| else: |
| lines.append(f"{TOK_OPT[i]} {name}") |
| lines.append(TOK_ANSWER) |
| return "\n".join(lines) + "\n" |
|
|
|
|
| @dataclass |
| class Encoded: |
| input_ids: List[int] |
| answer_positions: List[int] |
| option_counts: List[int] |
| specs: List[QuestionSpec] |
| labels: List[int] = field(default_factory=list) |
| truncated_state: bool = False |
|
|
| @property |
| def n_tokens(self) -> int: |
| return len(self.input_ids) |
|
|
|
|
| class Formatter: |
| """Tokenizer-aware encoder shared by training and inference.""" |
|
|
| def __init__( |
| self, |
| tokenizer, |
| *, |
| max_total_tokens: int = DEFAULT_MAX_TOTAL_TOKENS, |
| max_state_tokens: int = DEFAULT_MAX_STATE_TOKENS, |
| ): |
| self.tok = tokenizer |
| add_special_tokens(self.tok) |
| self.max_total_tokens = max_total_tokens |
| self.max_state_tokens = max_state_tokens |
| self.answer_id = self.tok.convert_tokens_to_ids(TOK_ANSWER) |
| self.state_id = self.tok.convert_tokens_to_ids(TOK_STATE) |
| self.opt_ids = self.tok.convert_tokens_to_ids(TOK_OPT) |
| assert self.answer_id is not None and self.answer_id != self.tok.unk_token_id |
|
|
| def _ids(self, text: str) -> List[int]: |
| return self.tok(text, add_special_tokens=False)["input_ids"] |
|
|
| def encode( |
| self, |
| state: Union[str, Dict[str, Any], List[Any]], |
| questions: Dict[str, Question], |
| *, |
| labels: Optional[Dict[str, Union[str, int, bool]]] = None, |
| shuffle_options: bool = False, |
| shuffle_questions: bool = False, |
| drop_label_for: Optional[Sequence[str]] = None, |
| rng: Optional[random.Random] = None, |
| state_indent: Optional[int] = None, |
| option_orders: Optional[Dict[str, Sequence[int]]] = None, |
| ) -> Encoded: |
| rng = rng or random.Random() |
| labels = labels or {} |
| drop = set(drop_label_for or []) |
| option_orders = option_orders or {} |
| qids = list(questions.keys()) |
| if shuffle_questions: |
| rng.shuffle(qids) |
|
|
| specs = [ |
| question_to_spec( |
| qid, |
| questions[qid], |
| label=labels.get(qid), |
| shuffle=shuffle_options, |
| rng=rng, |
| drop_label=qid in drop, |
| perm=option_orders.get(qid), |
| ) |
| for qid in qids |
| ] |
| q_texts = [spec_to_text(s) for s in specs] |
| q_ids = [self._ids(t) for t in q_texts] |
| q_total = sum(len(x) for x in q_ids) |
|
|
| state_text = sanitize(state_to_text(state, indent=state_indent)) |
| state_ids = self._ids(TOK_STATE + " " + state_text.strip() + "\n") |
| budget = min(self.max_state_tokens, self.max_total_tokens - q_total) |
| truncated = False |
| if len(state_ids) > budget: |
| |
| keep_tail = max(budget - 1, 0) |
| state_ids = state_ids[:1] + state_ids[len(state_ids) - keep_tail :] |
| truncated = True |
|
|
| ids: List[int] = list(state_ids) |
| answer_positions: List[int] = [] |
| for qi in q_ids: |
| ids.extend(qi) |
| |
| pos = len(ids) - 1 |
| while ids[pos] != self.answer_id: |
| pos -= 1 |
| answer_positions.append(pos) |
|
|
| return Encoded( |
| input_ids=ids, |
| answer_positions=answer_positions, |
| option_counts=[len(s.option_names) for s in specs], |
| specs=specs, |
| labels=[(-100 if s.label_slot is None else s.label_slot) for s in specs], |
| truncated_state=truncated, |
| ) |
|
|
|
|
| def slot_mask(option_counts: Sequence[int], *, include_abstain: bool = True, n_slots: int = N_SLOTS): |
| """Boolean mask (Q, n_slots): True where a slot is valid for that question.""" |
| import torch |
|
|
| k = torch.as_tensor(list(option_counts), dtype=torch.long) |
| ar = torch.arange(n_slots) |
| mask = ar[None, :] < k[:, None] |
| if include_abstain: |
| mask[:, ABSTAIN_SLOT] = True |
| return mask |
|
|
|
|
| def collate(encoded: Sequence[Encoded], pad_id: int, *, max_questions: Optional[int] = None): |
| """Right-pad a batch. Returns dict of tensors for OpenThaiSystemOneForDecision.forward.""" |
| import torch |
|
|
| B = len(encoded) |
| T = max(e.n_tokens for e in encoded) |
| Q = max_questions or max(len(e.answer_positions) for e in encoded) |
| input_ids = torch.full((B, T), pad_id, dtype=torch.long) |
| attention_mask = torch.zeros((B, T), dtype=torch.long) |
| answer_positions = torch.zeros((B, Q), dtype=torch.long) |
| option_counts = torch.zeros((B, Q), dtype=torch.long) |
| labels = torch.full((B, Q), -100, dtype=torch.long) |
| for b, e in enumerate(encoded): |
| n = e.n_tokens |
| input_ids[b, :n] = torch.tensor(e.input_ids) |
| attention_mask[b, :n] = 1 |
| q = len(e.answer_positions) |
| answer_positions[b, :q] = torch.tensor(e.answer_positions) |
| option_counts[b, :q] = torch.tensor(e.option_counts) |
| if e.labels: |
| labels[b, :q] = torch.tensor(e.labels) |
| return { |
| "input_ids": input_ids, |
| "attention_mask": attention_mask, |
| "answer_positions": answer_positions, |
| "option_counts": option_counts, |
| "labels": labels, |
| } |
|
|