4646# request needs `max_tokens=4`, a 5-token numeric one needs `max_tokens=8`,
4747# or the completion is truncated before any visible content is emitted.
4848# Extend as further families exhibit similar caps.
49- _OPENAI_MODEL_FAMILY_QUIRKS : dict [str , dict ] = {
50- "gpt-5" : {
51- "top_logprobs_max" : 5 ,
52- "max_tokens_overhead" : 3 ,
53- "force_reasoning_none" : True ,
54- # Under `reasoning_effort='none'`, gpt-5 collapses the standard
55- # `Answer (between 0 and 1): 0.` prefill to `'0.0'` regardless of
56- # input (AUC ~chance on ACSIncome). Reframing as an integer
57- # percentage (0-100, no prefill) unblocks the full digit range and
58- # restores discrimination (probe → HIGH-income '92', LOW '12').
59- "numeric_percentage_mode" : True ,
60- },
61- }
62- _DEFAULT_FAMILY_QUIRKS : dict = {
63- "top_logprobs_max" : 20 ,
64- "max_tokens_overhead" : 0 ,
65- "force_reasoning_none" : False ,
66- "numeric_percentage_mode" : False ,
49+ @dataclasses .dataclass (frozen = True )
50+ class _WebAPIQuirks :
51+ """Per-family value-level API constraints for the WebAPI backend."""
52+ top_logprobs_max : int = 20
53+ max_tokens_overhead : int = 0
54+ force_reasoning_none : bool = False
55+ # When True, `WebAPILLMClassifier.__init__` swaps a `DirectNumericQA`
56+ # for its `percentage=True` variant: gpt-5 under `reasoning_effort='none'`
57+ # collapses the standard `Answer (between 0 and 1): 0.` prefill to
58+ # `'0.0'` regardless of input (AUC ~chance on ACSIncome). Reframing as
59+ # an integer percentage (0-100, no prefill) unblocks the full digit
60+ # range and restores discrimination (probe → HIGH-income '92', LOW '12').
61+ numeric_percentage_mode : bool = False
62+
63+
64+ _OPENAI_MODEL_FAMILY_QUIRKS : dict [str , _WebAPIQuirks ] = {
65+ "gpt-5" : _WebAPIQuirks (
66+ top_logprobs_max = 5 ,
67+ max_tokens_overhead = 3 ,
68+ force_reasoning_none = True ,
69+ numeric_percentage_mode = True ,
70+ ),
6771}
72+ _DEFAULT_FAMILY_QUIRKS = _WebAPIQuirks ()
6873
6974
70- def _resolve_family_quirks (model_name : str ) -> dict :
75+ def _resolve_family_quirks (model_name : str ) -> _WebAPIQuirks :
7176 for family , quirks in _OPENAI_MODEL_FAMILY_QUIRKS .items ():
7277 if family in model_name :
7378 return quirks
@@ -171,7 +176,7 @@ def __init__(
171176 # prompt config's question if it is a `DirectNumericQA` and the
172177 # user did not already set `percentage=True`.
173178 if (
174- self ._family_quirks .get ( " numeric_percentage_mode" )
179+ self ._family_quirks .numeric_percentage_mode
175180 and isinstance (self ._prompt_config .suffix .question , DirectNumericQA )
176181 and not self ._prompt_config .suffix .question .percentage
177182 ):
@@ -209,42 +214,34 @@ def _enable_numeric_percentage_mode(self) -> None:
209214 new_q = dataclasses .replace (
210215 original_q , percentage = True , num_forward_passes = 15
211216 )
212- new_suffix = dataclasses .replace (self ._prompt_config .suffix , question = new_q )
213-
214- # Replace the numeric system prompt ONLY if the current one is the
215- # QA subclass default (i.e. the caller didn't set --system-prompt).
217+ replace_kwargs = {
218+ "suffix" : dataclasses .replace (self ._prompt_config .suffix , question = new_q ),
219+ }
220+ # Replace the numeric system prompt ONLY if the current one is the QA
221+ # subclass default (i.e. the caller didn't set --system-prompt): callers
222+ # who deliberately override it are never silently clobbered.
216223 current_sys = self ._prompt_config .system_prompt
217224 is_default_sys = (
218225 current_sys is not None
219226 and current_sys .system_prompt == type (original_q ).default_system_prompt
220227 )
221228 if is_default_sys :
222- new_system_prompt = VarySystemPrompt (
229+ replace_kwargs [ "system_prompt" ] = VarySystemPrompt (
223230 system_prompt = NUMERIC_PERCENTAGE_SYSTEM_PROMPT
224231 )
225- self ._prompt_config = dataclasses .replace (
226- self ._prompt_config ,
227- suffix = new_suffix ,
228- system_prompt = new_system_prompt ,
229- )
230- else :
231- self ._prompt_config = dataclasses .replace (
232- self ._prompt_config , suffix = new_suffix
233- )
232+ self ._prompt_config = dataclasses .replace (self ._prompt_config , ** replace_kwargs )
234233 # `_encode_row` was built via `partial(...)` capturing the OLD
235234 # prompt_config; rebuild it against the swapped one.
236235 self ._encode_row = partial (
237236 default_encode_row_prompt ,
238237 task = self .task ,
239238 prompt_config = self ._prompt_config ,
240239 )
240+ sys_note = "system prompt updated" if is_default_sys else "user system prompt preserved"
241241 logging .info (
242- f"Enabled numeric-percentage mode for model '{ self .model_name } ': "
243- f"prompt now asks for an integer percentage (0-100) instead of "
244- f"the decimal `0.<digits>` prefill (workaround for "
245- f"reasoning_effort='none' degeneration); "
246- f"system prompt { 'updated to' if is_default_sys else 'left as user override — ' } "
247- f"instruct answer format."
242+ f"Enabled numeric-percentage mode for '{ self .model_name } ': "
243+ f"prompt reframed as integer percentage (0-100) to work around "
244+ f"reasoning_effort='none' decimal-prefill degeneration; { sys_note } ."
248245 )
249246
250247 @staticmethod
@@ -304,8 +301,10 @@ def _query_webapi_batch(
304301 responses_batch : list[dict]
305302 The returned JSON responses for each prompt in the batch.
306303 """
307- # Handle ChainOfThoughtQA with longer text generation
308- if isinstance (question , ChainOfThoughtQA ):
304+ is_cot = isinstance (question , ChainOfThoughtQA )
305+
306+ if is_cot :
307+ # CoT: free-form text generation, no logprobs.
309308 api_call_params = dict (
310309 temperature = self ._resolve_temperature (question ),
311310 max_tokens = question .max_new_tokens ,
@@ -322,96 +321,77 @@ def _query_webapi_batch(
322321 "and provide your final probability estimate. Your response MUST end "
323322 "with 'Probability: X%' where X is a number between 0 and 100."
324323 )
325- # Adapt number of forward passes for token-probability based methods.
326- # Temperature is always 0 here: MC/numeric decode from the returned
327- # top_logprobs (which OpenAI-style APIs report untempered), so sampling
328- # would only add noise to the multi-pass token trajectory.
329- elif question .num_forward_passes == 1 :
330- # Single token answers should require only one forward pass
331- num_forward_passes = 1
332- api_call_params = dict (
333- temperature = 0 ,
334- max_tokens = num_forward_passes + self ._family_quirks ["max_tokens_overhead" ],
335- stream = False ,
336- seed = self .seed ,
337- logprobs = True ,
338- top_logprobs = self ._family_quirks ["top_logprobs_max" ],
339- )
340324 else :
341- # NOTE: Models often generate "0." instead of directly outputting the fractional part
342- # > Therefore: for multi-token answers, extra forward passes may be required
343- # Add extra tokens for textual prefix, e.g., "The probability is: ..."
344- num_forward_passes = question .num_forward_passes + 2
325+ # MCQ / DirectNumericQA: token-probability decoding.
326+ # Temperature is always 0: MC/numeric decode from the returned
327+ # top_logprobs (which OpenAI-style APIs report untempered), so
328+ # sampling would only add noise to the multi-pass trajectory.
329+ # For multi-token numeric answers, add 2 extra passes to cover
330+ # the "0." (or paraphrase) prefix the model may emit before digits.
331+ num_forward_passes = (
332+ 1 if question .num_forward_passes == 1
333+ else question .num_forward_passes + 2
334+ )
345335 api_call_params = dict (
346336 temperature = 0 ,
347- max_tokens = num_forward_passes + self ._family_quirks [ " max_tokens_overhead" ] ,
337+ max_tokens = num_forward_passes + self ._family_quirks . max_tokens_overhead ,
348338 stream = False ,
349339 seed = self .seed ,
350340 logprobs = True ,
351- top_logprobs = self ._family_quirks [ " top_logprobs_max" ] ,
341+ top_logprobs = self ._family_quirks . top_logprobs_max ,
352342 )
353343
354- # Warn once when MCQ/Numeric is run against a reasoning-capable model
355- # whose WebAPI quirks haven't been explicitly validated. Reasoning
356- # models tend to preamble/wrap the answer, exhausting the tight
357- # `max_tokens` budget used by token-probability decoding — even with
358- # `logprobs` support the numbers can silently collapse to chance.
359- # Only fires for MCQ/Numeric (CoT is robust) and when the model was
360- # NOT matched by an explicit entry in `_OPENAI_MODEL_FAMILY_QUIRKS`.
361- if (
362- not isinstance (question , ChainOfThoughtQA )
363- and "reasoning_effort" in self .supported_params
364- and self ._family_quirks is _DEFAULT_FAMILY_QUIRKS
365- and not self ._warned_unvalidated_reasoning
366- ):
367- self ._warned_unvalidated_reasoning = True
368- logging .warning (
369- f"Model '{ self .model_name } ' advertises `reasoning_effort` "
370- f"support (likely a reasoning/thinking model) but has no "
371- f"validated entry in `_OPENAI_MODEL_FAMILY_QUIRKS`. "
372- f"Multiple-choice / numeric prompting on reasoning models "
373- f"can degrade to chance-level AUC (models preamble or wrap "
374- f"the answer in markdown, exhausting the small `max_tokens` "
375- f"budget). If results look wrong, use chain-of-thought "
376- f"prompting (`--cot-prompting`) instead, or add an entry to "
377- f"`_OPENAI_MODEL_FAMILY_QUIRKS` (see the gpt-5 entry for "
378- f"reference: `numeric_percentage_mode`, `force_reasoning_none`, "
379- f"`max_tokens_overhead`, `top_logprobs_max`)."
380- )
344+ # Warn once when running against a reasoning-capable model whose
345+ # WebAPI quirks haven't been explicitly validated. Reasoning
346+ # models preamble/wrap the answer, exhausting the tight
347+ # `max_tokens` budget — token-probability decoding can silently
348+ # collapse to chance-level AUC. (CoT skips this branch entirely.)
349+ if (
350+ "reasoning_effort" in self .supported_params
351+ and self ._family_quirks is _DEFAULT_FAMILY_QUIRKS
352+ and not self ._warned_unvalidated_reasoning
353+ ):
354+ self ._warned_unvalidated_reasoning = True
355+ logging .warning (
356+ f"Model '{ self .model_name } ' advertises `reasoning_effort` "
357+ f"support (likely a reasoning/thinking model) but has no "
358+ f"validated entry in `_OPENAI_MODEL_FAMILY_QUIRKS`. "
359+ f"Multiple-choice / numeric prompting on reasoning models "
360+ f"can degrade to chance-level AUC (models preamble or wrap "
361+ f"the answer in markdown, exhausting the small `max_tokens` "
362+ f"budget). If results look wrong, use chain-of-thought "
363+ f"prompting (`--cot-prompting`) instead, or add an entry "
364+ f"to `_OPENAI_MODEL_FAMILY_QUIRKS` (see the gpt-5 entry "
365+ f"for reference)."
366+ )
381367
382- # gpt-5.x refuses `logprobs` requests unless reasoning_effort='none'
383- # is set — pass it only for MCQ/numeric (CoT reads free-form text and
384- # benefits from the model's default reasoning budget).
385- if (
386- not isinstance (question , ChainOfThoughtQA )
387- and self ._family_quirks ["force_reasoning_none" ]
388- ):
389- api_call_params ["reasoning_effort" ] = "none"
368+ # gpt-5.x refuses `logprobs` requests unless reasoning_effort='none'
369+ # is set. CoT keeps the model's default reasoning budget.
370+ if self ._family_quirks .force_reasoning_none :
371+ api_call_params ["reasoning_effort" ] = "none"
372+
373+ # System prompt for MCQ/Numeric: use the one carried by
374+ # PromptConfig (the QA subclass default unless explicitly
375+ # cleared). `None` disables the system role entirely.
376+ system_prompt = (
377+ self .prompt_config .system_prompt ()
378+ if self .prompt_config .system_prompt is not None
379+ else None
380+ )
381+ logging .debug (f"System prompt: { system_prompt } " )
390382
391383 api_call_params = self ._filter_supported_params (api_call_params )
392384
393385 # `logprobs` are load-bearing for token-probability decoding: dropping
394386 # them would only fail later, deep inside response decoding. Fail fast
395387 # instead (e.g. OpenAI o1/o3 don't support logprobs).
396- if not isinstance ( question , ChainOfThoughtQA ) and "logprobs" not in api_call_params :
388+ if not is_cot and "logprobs" not in api_call_params :
397389 raise RuntimeError (
398390 f"Model '{ self .model_name } ' does not support `logprobs`, which "
399391 f"are required to decode multiple-choice/numeric risk estimates. "
400392 f"Use chain-of-thought prompting (--cot-prompting) instead."
401393 )
402394
403- # Get system prompt depending on Q&A type (if not already set for ChainOfThoughtQA)
404- if not isinstance (question , ChainOfThoughtQA ):
405- # Use the system prompt carried by PromptConfig (always the QA subclass
406- # default unless the caller explicitly cleared it). `None` disables the
407- # system role entirely. Bind unconditionally so it is always defined.
408- system_prompt = (
409- self .prompt_config .system_prompt ()
410- if self .prompt_config .system_prompt is not None
411- else None
412- )
413- logging .debug (f"System prompt: { system_prompt } " )
414-
415395 # Query model for each prompt in the batch
416396 responses_batch = []
417397 for prompt in prompts_batch :
0 commit comments