from __future__ import annotations import contextlib from dataclasses import dataclass import torch import torch.nn.functional as F from loguru import logger _CLOSE_MARKER: str = "" @dataclass class GuidedResult: user_prompt: str think_text: str answer_text: str raw_full_text: str pmass_format: float logratio_ab: float rep_ratio_think: float think_tokens: int emitted_close: bool emitted_prefill: bool p_true: float _REP_MIN_TOKENS: int = 32 def _ngram_rep_ratio(text: str, n: int = 4) -> float: tokens = text.split() if len(tokens) < _REP_MIN_TOKENS: return float("nan") ngrams = [tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1)] return len(set(ngrams)) / len(ngrams) _DEFAULT_SCHEMA_HINT: str = ( "Think briefly, then answer immediately and only with: " '{"choice": true} or {"choice": false}.' ) @torch.no_grad() def guided_rollout( model, tok, user_prompt: str, choice_token_ids: list, max_think_tokens: int = 128, answer_tokens: int = 4, schema_hint: str = _DEFAULT_SCHEMA_HINT, prefill: str = '\n{"choice": ', verbose: bool = False, ) -> GuidedResult: device = next(model.parameters()).device full_user = f"{user_prompt}\n\n{schema_hint}" if schema_hint else user_prompt messages = [{"role": "user", "content": full_user}] try: prompt = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) except TypeError: prompt = tok.apply_chat_template(messages, tokenize=False) prompt = prompt + "\n" enc = tok(prompt, return_tensors="pt").to(device) prompt_len = enc.input_ids.shape[1] think_end_id = tok.convert_tokens_to_ids("") if think_end_id in (None, getattr(tok, "unk_token_id", None)): think_end_id = tok.eos_token_id pad_id = tok.pad_token_id if tok.pad_token_id is not None else tok.eos_token_id phase1 = model.generate( **enc, max_new_tokens=max_think_tokens, do_sample=False, eos_token_id=think_end_id, pad_token_id=pad_id, ) gen_ids = phase1[0, prompt_len:] keep = gen_ids != pad_id gen_ids = gen_ids[keep] if keep.any() else gen_ids[:0] gen_text = tok.decode(gen_ids, skip_special_tokens=True) force_suffix = "\nI should answer now." + _CLOSE_MARKER + prefill emitted_close = _CLOSE_MARKER in gen_text if emitted_close: think_text, after = gen_text.split(_CLOSE_MARKER, 1) if prefill.lstrip() in after: emitted_prefill = True before_value = after.split(prefill.lstrip(), 1)[0] scoring_text = prompt + think_text + _CLOSE_MARKER + before_value + prefill.lstrip() else: emitted_prefill = False scoring_text = prompt + think_text + _CLOSE_MARKER + prefill else: think_text = gen_text emitted_prefill = False scoring_text = prompt + gen_text + force_suffix score_ids = tok(scoring_text, return_tensors="pt", add_special_tokens=False).input_ids.to(device) logits = model(score_ids).logits[0, -1].float() logp = F.log_softmax(logits, dim=-1) if (len(choice_token_ids) == 2 and all(isinstance(x, (list, tuple)) for x in choice_token_ids)): a_ids, b_ids = list(choice_token_ids[0]), list(choice_token_ids[1]) else: a_ids, b_ids = list(choice_token_ids), [] all_ids = torch.tensor(a_ids + b_ids, device=device, dtype=torch.long) pmass_format = float(logp[all_ids].exp().sum().item()) # SHOULD: pmass≈1 (model picks one of the JSON-bool tokens). pmass<0.9 # means the model is leaking probability to other tokens -> the schema # is being ignored or the steering vector has pushed the model OOD. if pmass_format < 0.9: topk = torch.topk(logp.exp(), k=5) toks = [tok.decode([i]) for i in topk.indices.tolist()] probs = topk.values.tolist() top5 = ", ".join(f"{repr(t)}={p:.3f}" for t, p in zip(toks, probs)) logger.warning(f"pmass={pmass_format:.3f}<0.9 — top-5: {top5}") if a_ids and b_ids: a_t = torch.tensor(a_ids, device=device, dtype=torch.long) b_t = torch.tensor(b_ids, device=device, dtype=torch.long) logratio = float(torch.logsumexp(logp[a_t], dim=0).item() - torch.logsumexp(logp[b_t], dim=0).item()) p_true = float(torch.softmax(torch.stack([torch.logsumexp(logp[a_t], dim=0), torch.logsumexp(logp[b_t], dim=0)]), dim=0)[0].item()) else: logratio = float("nan") p_true = float("nan") cont = model.generate( score_ids, max_new_tokens=answer_tokens, do_sample=False, pad_token_id=pad_id, ) answer_ids = cont[0, score_ids.shape[1]:] answer_text = tok.decode(answer_ids, skip_special_tokens=True) raw_full_text = tok.decode(cont[0], skip_special_tokens=False) return GuidedResult( user_prompt=user_prompt, think_text=think_text, answer_text=answer_text, raw_full_text=raw_full_text, pmass_format=pmass_format, logratio_ab=logratio, rep_ratio_think=_ngram_rep_ratio(think_text, n=4), think_tokens=int(score_ids.shape[1] - prompt_len), emitted_close=emitted_close, emitted_prefill=emitted_prefill, p_true=p_true, ) @torch.no_grad() def guided_rollout_batch( model, tok, user_prompts: list[str], choice_token_ids: list, max_think_tokens: int = 128, schema_hint: str = _DEFAULT_SCHEMA_HINT, prefill: str = '\n{"choice": ', ) -> list[GuidedResult]: """Batched guided rollout. Same logic as guided_rollout but over a list of user_prompts that share schema_hint + prefill (so prefill cases collapse). Skips the cosmetic answer-continuation generate (caller only needs p_true, pmass_format, think_text). Two model calls per batch instead of 3 per row: one phase1 generate (think) + one scoring forward. Tokenizer must already have padding_side='left' and pad_token set.""" if tok.padding_side != "left": raise ValueError("tok.padding_side must be 'left' for batched rollout") device = next(model.parameters()).device prompts = [] for up in user_prompts: full_user = f"{up}\n\n{schema_hint}" if schema_hint else up msgs = [{"role": "user", "content": full_user}] try: p = tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True) except TypeError: p = tok.apply_chat_template(msgs, tokenize=False) prompts.append(p + "\n") think_end_id = tok.convert_tokens_to_ids("") if think_end_id in (None, getattr(tok, "unk_token_id", None)): think_end_id = tok.eos_token_id pad_id = tok.pad_token_id if tok.pad_token_id is not None else tok.eos_token_id enc = tok(prompts, return_tensors="pt", padding=True).to(device) prompt_len = enc.input_ids.shape[1] phase1 = model.generate( **enc, max_new_tokens=max_think_tokens, do_sample=False, eos_token_id=think_end_id, pad_token_id=pad_id, ) scoring_texts = [] per_row = [] # (think_text, emitted_close, emitted_prefill, n_think_tokens) for i, p in enumerate(prompts): gen_ids = phase1[i, prompt_len:] keep = gen_ids != pad_id gen_ids = gen_ids[keep] if keep.any() else gen_ids[:0] gen_text = tok.decode(gen_ids, skip_special_tokens=True) n_think = int(gen_ids.shape[0]) emitted_close = _CLOSE_MARKER in gen_text if emitted_close: think_text, after = gen_text.split(_CLOSE_MARKER, 1) if prefill.lstrip() in after: emitted_prefill = True before_value = after.split(prefill.lstrip(), 1)[0] scoring_text = p + think_text + _CLOSE_MARKER + before_value + prefill.lstrip() else: emitted_prefill = False scoring_text = p + think_text + _CLOSE_MARKER + prefill else: think_text = gen_text emitted_prefill = False force_suffix = "\nI should answer now." + _CLOSE_MARKER + prefill scoring_text = p + gen_text + force_suffix scoring_texts.append(scoring_text) per_row.append((think_text, emitted_close, emitted_prefill, n_think)) score_enc = tok(scoring_texts, return_tensors="pt", padding=True, add_special_tokens=False).to(device) score_logits = model(**score_enc).logits[:, -1].float() score_logp = F.log_softmax(score_logits, dim=-1) if (len(choice_token_ids) == 2 and all(isinstance(x, (list, tuple)) for x in choice_token_ids)): a_ids, b_ids = list(choice_token_ids[0]), list(choice_token_ids[1]) else: a_ids, b_ids = list(choice_token_ids), [] all_ids = torch.tensor(a_ids + b_ids, device=device, dtype=torch.long) a_t = torch.tensor(a_ids, device=device, dtype=torch.long) if a_ids else None b_t = torch.tensor(b_ids, device=device, dtype=torch.long) if b_ids else None results = [] low_pmass = [] # (idx, pmass) for rows with pmass<0.9 for i, (up, (think_text, emitted_close, emitted_prefill, n_think)) in enumerate(zip(user_prompts, per_row)): logp = score_logp[i] pmass_format = float(logp[all_ids].exp().sum().item()) if pmass_format < 0.9: low_pmass.append((i, pmass_format)) if a_t is not None and b_t is not None: la = torch.logsumexp(logp[a_t], dim=0) lb = torch.logsumexp(logp[b_t], dim=0) logratio = float((la - lb).item()) p_true = float(torch.softmax(torch.stack([la, lb]), dim=0)[0].item()) else: logratio = float("nan") p_true = float("nan") results.append(GuidedResult( user_prompt=up, think_text=think_text, answer_text="", raw_full_text="", pmass_format=pmass_format, logratio_ab=logratio, rep_ratio_think=_ngram_rep_ratio(think_text, n=4), think_tokens=n_think, emitted_close=emitted_close, emitted_prefill=emitted_prefill, p_true=p_true, )) # Aggregate-once warning: one line per batch with worst-case top-5 instead # of N spammy per-row lines (heavy steering pushes many rows OOD at once). if low_pmass: worst_i, worst_pm = min(low_pmass, key=lambda x: x[1]) topk = torch.topk(score_logp[worst_i].exp(), k=5) toks = [tok.decode([j]) for j in topk.indices.tolist()] probs = topk.values.tolist() top5 = ", ".join(f"{repr(t)}={pp:.3f}" for t, pp in zip(toks, probs)) logger.warning( f"pmass<0.9 on {len(low_pmass)}/{len(results)} rows in this batch; " f"worst={worst_pm:.3f} top-5: {top5}" ) return results def choice_token_ids_tf(tok) -> list[list[int]]: def _variants(words): seen = [] for s in words: tid = tok.encode(s, add_special_tokens=False)[-1] if tid not in seen: seen.append(tid) return seen return [_variants(["true", " true", "\ntrue", "True", " True", "\nTrue", "1"]), _variants(["false", " false", "\nfalse", "False", " False", "\nFalse", "0"])]