mirror of
https://github.com/wassname/persona-steering-template-library.git
synced 2026-08-11 11:23:11 +08:00
201 lines
7.7 KiB
Python
201 lines
7.7 KiB
Python
"""Bounded-thinking plus force-answer judging through Inspect AI.
|
|
|
|
Port of https://gist.github.com/wassname/72eed3a1ddfc286c5e12a118dfa30161
|
|
adapted to Inspect's model interface.
|
|
|
|
Problem. A small reasoning model (e.g. qwen3.5-9b) used as a JUDGE deliberates to its
|
|
max-token budget on an ambiguous item and emits NO verdict. A parser that defaults to
|
|
0 / "tie" then silently launders that NON-conclusion into a score indistinguishable from
|
|
a real "SCORE: 0", and downstream a sign-test drops the item. Two more traps:
|
|
temperature=0 is OUT OF DISTRIBUTION for a thinking model (it can loop), and OpenRouter
|
|
reasoning-BUDGET knobs (effort=low/medium, reasoning_tokens=N) are IGNORED by some
|
|
providers for some models; only effort="none" (disable) is honored. Measure before
|
|
trusting any of them.
|
|
|
|
Fix. Make the judge a bounded THINKING call that ALWAYS commits:
|
|
phase 1: think at the model's native thinking params, but cap total output with
|
|
max_tokens=BUDGET. If it emits a valid verdict within budget, use it.
|
|
phase 2: if it hit the budget without answering, CONTINUE the same conversation: feed
|
|
the (truncated) reasoning back as an assistant turn, add a user turn
|
|
"you are out of time, answer NOW", and generate again with thinking OFF
|
|
(reasoning_effort="none") so it commits directly instead of re-entering the
|
|
thinking channel and eating the budget again.
|
|
N-sample: average the whole thing N times. Reproducibility comes from N, not from an
|
|
OOD greedy temperature.
|
|
|
|
The `found` flag returned throughout distinguishes a REAL "SCORE: 0" (found=True) from a
|
|
non-verdict (found=False). A non-verdict must NEVER be silently laundered into a tie: the
|
|
caller treats found=False as an excluded item, not a score of 0.
|
|
|
|
This is custom judge behavior on top of Inspect's model interface. "Interrupt" is not a
|
|
mid-stream cut: phase 1's max_tokens truncates the provider generation, we detect the
|
|
missing verdict, and phase 2 is a fresh continuation call.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import re
|
|
from typing import Any
|
|
|
|
from inspect_ai.model import (
|
|
CachePolicy,
|
|
ChatMessageAssistant,
|
|
ChatMessageUser,
|
|
ContentReasoning,
|
|
GenerateConfig,
|
|
Model,
|
|
)
|
|
from loguru import logger
|
|
|
|
# phase-1 thinking params (Qwen3 thinking card): temp0 is OOD and loops.
|
|
THINK: dict[str, Any] = dict(temperature=1.0, top_p=0.95, top_k=20, presence_penalty=1.5)
|
|
# phase-2 instruct (non-thinking) params + reasoning OFF, for the forced continuation.
|
|
FORCE: dict[str, Any] = dict(temperature=0.7, top_p=0.8, top_k=20, presence_penalty=1.5)
|
|
|
|
DEFAULT_BUDGET = 4096 # phase-1 thinking cap; measure truncation-rate to tune it
|
|
DEFAULT_N = 2 # samples averaged per call
|
|
|
|
|
|
def parse_score(text: str) -> tuple[int, bool]:
|
|
"""Return (score, found). `found` distinguishes a REAL 'SCORE: 0' from no verdict:
|
|
never silently launder a non-answer into 0."""
|
|
for line in text.splitlines():
|
|
m = re.match(r"\s*SCORE:\s*([+-]?\d+)", line, re.I)
|
|
if m:
|
|
return max(-5, min(5, int(m.group(1)))), True
|
|
return 0, False
|
|
|
|
|
|
def _reasoning_tail(message: ChatMessageAssistant, n: int = 2000) -> str:
|
|
"""The model's (possibly truncated) thinking text, to seed the forced continuation."""
|
|
content = message.content
|
|
parts = (
|
|
[part.reasoning for part in content if isinstance(part, ContentReasoning)]
|
|
if isinstance(content, list)
|
|
else []
|
|
)
|
|
reasoning = "\n".join(parts)
|
|
return reasoning[-n:] if reasoning else ""
|
|
|
|
|
|
async def judge_once(
|
|
*,
|
|
model: Model,
|
|
force_model: Model,
|
|
prompt: str,
|
|
budget: int = DEFAULT_BUDGET,
|
|
seed: int | None = None,
|
|
max_connections: int = 16,
|
|
cache: CachePolicy | bool = True,
|
|
) -> tuple[int, bool, bool]:
|
|
"""One bounded-thinking judgment that ALWAYS commits.
|
|
|
|
Returns (score, found, forced). `forced` is True iff phase 2 was entered.
|
|
`found` is True iff a real SCORE line was emitted (phase 1 or 2); found=False means
|
|
the judge never committed and the caller must exclude the item, not vote 0.
|
|
"""
|
|
phase1_config = GenerateConfig(
|
|
max_tokens=budget,
|
|
temperature=THINK["temperature"],
|
|
top_p=THINK["top_p"],
|
|
presence_penalty=THINK["presence_penalty"],
|
|
seed=seed,
|
|
max_connections=max_connections,
|
|
timeout=120,
|
|
extra_body={"top_k": THINK["top_k"]},
|
|
)
|
|
output1 = await model.generate(prompt, config=phase1_config, cache=cache)
|
|
content1 = output1.completion
|
|
score, found = parse_score(content1)
|
|
if found:
|
|
return score, True, False
|
|
|
|
# phase 2: out of budget -> continue the conversation and FORCE a direct answer
|
|
msgs = [
|
|
ChatMessageUser(content=prompt),
|
|
ChatMessageAssistant(
|
|
content=_reasoning_tail(output1.message) or "(thinking truncated)"
|
|
),
|
|
ChatMessageUser(
|
|
content="You are out of thinking time. Answer NOW: first line "
|
|
"exactly `SCORE: <int -5..+5>`."
|
|
),
|
|
]
|
|
phase2_config = GenerateConfig(
|
|
max_tokens=256,
|
|
temperature=FORCE["temperature"],
|
|
top_p=FORCE["top_p"],
|
|
presence_penalty=FORCE["presence_penalty"],
|
|
seed=seed,
|
|
max_connections=max_connections,
|
|
timeout=90,
|
|
reasoning_effort="none",
|
|
extra_body={"top_k": FORCE["top_k"], "include_reasoning": False},
|
|
)
|
|
output2 = await force_model.generate(msgs, config=phase2_config, cache=cache)
|
|
content2 = output2.completion
|
|
score, found = parse_score(content2)
|
|
if not found:
|
|
logger.warning("bounded judge: forced phase-2 answer still had no SCORE "
|
|
f"(model={model.name}); excluding item, NOT voting 0")
|
|
return score, found, True
|
|
|
|
|
|
async def judge(
|
|
*,
|
|
model: Model,
|
|
force_model: Model,
|
|
prompt: str,
|
|
n: int = DEFAULT_N,
|
|
budget: int = DEFAULT_BUDGET,
|
|
seed: int | None = None,
|
|
max_connections: int = 16,
|
|
cache: CachePolicy | bool = True,
|
|
) -> dict:
|
|
"""N bounded-thinking samples, averaged under Inspect's connection limit.
|
|
|
|
Returns a dict: {score, found_rate, forced_rate, n, budget, samples}.
|
|
`score` is the rounded mean over samples that committed (found=True); if no sample
|
|
committed, score is 0 with found_rate=0.0 and the caller must exclude the item.
|
|
"""
|
|
sample_caches = [
|
|
cache.model_copy(update={
|
|
"scopes": {**cache.scopes, "bounded_sample": str(i)},
|
|
})
|
|
if isinstance(cache, CachePolicy)
|
|
else cache
|
|
for i in range(n)
|
|
]
|
|
samples = await asyncio.gather(
|
|
*[
|
|
judge_once(
|
|
model=model,
|
|
force_model=force_model,
|
|
prompt=prompt,
|
|
budget=budget,
|
|
seed=seed,
|
|
max_connections=max_connections,
|
|
cache=sample_caches[i],
|
|
)
|
|
for i in range(n)
|
|
]
|
|
)
|
|
committed = [(s, forced) for (s, found, forced) in samples if found]
|
|
found_rate = len(committed) / len(samples) if samples else 0.0
|
|
forced_rate = sum(forced for _, forced in committed) / len(committed) if committed else 0.0
|
|
avg = round(sum(s for s, _ in committed) / len(committed)) if committed else 0
|
|
return {
|
|
"score": avg,
|
|
"found_rate": round(found_rate, 4),
|
|
"forced_rate": round(forced_rate, 4),
|
|
"n": len(samples),
|
|
"budget": budget,
|
|
"samples": [{"score": s, "found": f, "forced": forced} for (s, f, forced) in samples],
|
|
}
|
|
|
|
|
|
def score_to_a_more_target_than_b(score: int) -> float:
|
|
"""Map a bounded-judge SCORE in [-5,+5] to the validator's 1..5
|
|
A_more_target_than_B scale (3.0 = tied)."""
|
|
return max(1.0, min(5.0, round((3.0 + 0.4 * score) * 10.0) / 10.0))
|