@@ -2697,6 +2697,62 @@ static char *find_last_answer_marker(const char *visible) {
26972697 return last ? last : strcasestr_local (visible , "answer" );
26982698}
26992699
2700+ /* True when the in-range capital at `letter` is the object of an explicit
2701+ * rejection earlier on the same answer line -- "not B", "isn't B",
2702+ * "rules out C", "eliminate E" -- so it is a distractor the model is
2703+ * discarding, not its pick. This rewrites only the lexical "first valid
2704+ * letter wins" default for clear elimination phrasing; the cue set is kept
2705+ * small and high-precision on purpose, and there is no general
2706+ * selection-vs-rejection sentence parsing (issue #321). It never looks before
2707+ * `start` or across a newline, so "D, not B" still grades D: the pick is
2708+ * reached and accepted before the rejected distractor is ever inspected. */
2709+ static bool mc_letter_is_negated (const char * start , const char * letter ) {
2710+ const char * p = letter ;
2711+ /* Step back over the gap to the previous word: spaces and light separating
2712+ * punctuation only, and never across a line break. */
2713+ while (p > start ) {
2714+ char c = p [-1 ];
2715+ if (c == '\n' ) return false;
2716+ if (c == ' ' || c == '\t' || c == ',' || c == ';' ) p -- ;
2717+ else break ;
2718+ }
2719+ /* Read the immediately preceding word (letters/apostrophe), lowercased. */
2720+ const char * wend = p ;
2721+ while (p > start && (isalpha ((unsigned char )p [-1 ]) || p [-1 ] == '\'' )) p -- ;
2722+ size_t wlen = (size_t )(wend - p );
2723+ if (wlen == 0 || wlen >= 16 ) return false;
2724+ char w [16 ];
2725+ for (size_t i = 0 ; i < wlen ; i ++ ) w [i ] = (char )tolower ((unsigned char )p [i ]);
2726+ w [wlen ] = '\0' ;
2727+
2728+ /* Contraction form: isn't / aren't / wasn't / doesn't / won't / can't. */
2729+ if (wlen >= 3 && strcmp (w + wlen - 3 , "n't" ) == 0 ) return true;
2730+
2731+ static const char * cues [] = {
2732+ "not" , "except" , "excluding" , "exclude" , "excludes" ,
2733+ "eliminate" , "eliminates" , "eliminated" ,
2734+ "reject" , "rejects" , "rejected" , "rejecting" , NULL
2735+ };
2736+ for (int i = 0 ; cues [i ]; i ++ ) if (strcmp (w , cues [i ]) == 0 ) return true;
2737+
2738+ /* Two-word cue: "rule out" / "rules out" / "ruled out". */
2739+ if (strcmp (w , "out" ) == 0 ) {
2740+ const char * q = p ;
2741+ while (q > start && (q [-1 ] == ' ' || q [-1 ] == '\t' )) q -- ;
2742+ const char * rend = q ;
2743+ while (q > start && isalpha ((unsigned char )q [-1 ])) q -- ;
2744+ size_t rl = (size_t )(rend - q );
2745+ if (rl && rl < 8 ) {
2746+ char r [8 ];
2747+ for (size_t i = 0 ; i < rl ; i ++ ) r [i ] = (char )tolower ((unsigned char )q [i ]);
2748+ r [rl ] = '\0' ;
2749+ if (!strcmp (r , "rule" ) || !strcmp (r , "rules" ) || !strcmp (r , "ruled" ))
2750+ return true;
2751+ }
2752+ }
2753+ return false;
2754+ }
2755+
27002756static char find_answer_letter (const char * generated , int nchoices ) {
27012757 if (nchoices <= 0 ) return '?' ;
27022758 const char * visible = strstr (generated , "</think>" );
@@ -2712,7 +2768,22 @@ static char find_answer_letter(const char *generated, int nchoices) {
27122768 if (c >= 'A' && c <= max_answer ) {
27132769 char before = p == visible ? ' ' : p [-1 ];
27142770 char after = p [1 ];
2715- if (is_letter_boundary (before , after )) return c ;
2771+ if (!is_letter_boundary (before , after )) continue ;
2772+ /* A standalone capital that begins a same-line English word
2773+ * ("A careful ...") or a contraction ("I'll ...") is prose,
2774+ * not the model's pick: the real letter comes later on the
2775+ * line. Skip it; the reverse scan below still recovers it if
2776+ * it was the only candidate (e.g. "Answer: A is correct"). */
2777+ if (after == '\'' ) continue ;
2778+ if (after == ' ' || after == '\t' ) {
2779+ const char * w = p + 1 ;
2780+ while (* w == ' ' || * w == '\t' ) w ++ ;
2781+ if (islower ((unsigned char )* w )) continue ;
2782+ }
2783+ /* A distractor explicitly rejected before the pick on the same
2784+ * line ("not B, ... D") must not win over the real choice. */
2785+ if (mc_letter_is_negated (answer , p )) continue ;
2786+ return c ;
27162787 }
27172788 }
27182789 }
@@ -2765,7 +2836,17 @@ static void find_integer_answer(const char *generated, char *dst, size_t dstlen)
27652836 if (answer ) {
27662837 const char * end = answer + strlen (answer );
27672838 if (strlen (answer ) > 160 ) end = answer + 160 ;
2768- if (scan_first_integer (answer , end , dst , dstlen )) return ;
2839+ /* Restrict to the final answer line: digits after it (continued
2840+ * reasoning, footnotes, years) must not override the answer. */
2841+ const char * nl = memchr (answer , '\n' , (size_t )(end - answer ));
2842+ if (nl ) end = nl ;
2843+ /* When the line shows arithmetic ("m+n = 256+37 = 293") the stated
2844+ * result is the right-hand side of the LAST '='. Otherwise the first
2845+ * integer on the line is the answer (keeps "Final answer: 082"). */
2846+ const char * eq = NULL ;
2847+ for (const char * r = answer ; r < end ; r ++ ) if (* r == '=' ) eq = r ;
2848+ if (scan_first_integer (eq ? eq + 1 : answer , end , dst , dstlen )) return ;
2849+ if (eq && scan_first_integer (answer , end , dst , dstlen )) return ;
27692850 }
27702851
27712852 const char * last_start = NULL ;
@@ -3132,6 +3213,32 @@ static char *trace_copy_model_output(const char *case_start, const char *case_en
31323213 return out ;
31333214}
31343215
3216+ typedef enum {
3217+ REGRADE_NOT_GRADED ,
3218+ REGRADE_PASSED ,
3219+ REGRADE_FAILED ,
3220+ } regrade_outcome ;
3221+
3222+ /* Decide how one trace case regrades. Only PASSED/FAILED traces were graded by
3223+ * a completed run; STOPPED/SKIPPED/SWITCHED/ERROR cases carry partial or no
3224+ * model output and must be reported as not-graded rather than counted, since
3225+ * grading them inflates the totals and raises spurious "changed" drift. An
3226+ * empty status keeps legacy traces (written before the status line) working. */
3227+ static regrade_outcome regrade_case_outcome (const eval_case * tc ,
3228+ const char * traced_status ,
3229+ const char * model_output ,
3230+ char * got , size_t gotlen ) {
3231+ bool gradeable = traced_status [0 ] == '\0' ||
3232+ !strcmp (traced_status , "PASSED" ) ||
3233+ !strcmp (traced_status , "FAILED" );
3234+ if (!gradeable ) {
3235+ if (gotlen ) got [0 ] = '\0' ;
3236+ return REGRADE_NOT_GRADED ;
3237+ }
3238+ find_case_answer (tc , model_output , got , gotlen );
3239+ return answer_matches (tc , got ) ? REGRADE_PASSED : REGRADE_FAILED ;
3240+ }
3241+
31353242static int regrade_trace_file (const char * path ) {
31363243 size_t len = 0 ;
31373244 char * text = read_text_file (path , & len );
@@ -3145,6 +3252,7 @@ static int regrade_trace_file(const char *path) {
31453252 int changed = 0 ;
31463253 int unknown = 0 ;
31473254 int parse_errors = 0 ;
3255+ int not_graded = 0 ;
31483256
31493257 while (true) {
31503258 const char * case_start = trace_find_next_case (start , end );
@@ -3186,8 +3294,14 @@ static int regrade_trace_file(const char *path) {
31863294 }
31873295
31883296 char got [EVAL_ANSWER_MAX ];
3189- find_case_answer (tc , model_output , got , sizeof (got ));
3190- bool ok = answer_matches (tc , got );
3297+ regrade_outcome outcome =
3298+ regrade_case_outcome (tc , traced_status , model_output , got , sizeof (got ));
3299+ if (outcome == REGRADE_NOT_GRADED ) {
3300+ not_graded ++ ;
3301+ free (model_output );
3302+ continue ;
3303+ }
3304+ bool ok = (outcome == REGRADE_PASSED );
31913305 if (ok ) passed ++ ;
31923306 else failed ++ ;
31933307
@@ -3204,8 +3318,8 @@ static int regrade_trace_file(const char *path) {
32043318 free (model_output );
32053319 }
32063320
3207- printf ("ds4-eval: regraded %d cases from %s: passed=%d failed=%d changed=%d unknown=%d parse_errors=%d\n" ,
3208- total , path , passed , failed , changed , unknown , parse_errors );
3321+ printf ("ds4-eval: regraded %d cases from %s: passed=%d failed=%d changed=%d not_graded=%d unknown=%d parse_errors=%d\n" ,
3322+ total , path , passed , failed , changed , not_graded , unknown , parse_errors );
32093323 free (text );
32103324 return (unknown || parse_errors || total == 0 ) ? 1 : 0 ;
32113325}
@@ -3273,10 +3387,37 @@ static int trace_copy_self_test_case(void) {
32733387 return 0 ;
32743388}
32753389
3390+ static int regrade_status_self_test_case (void ) {
3391+ int failed = 0 ;
3392+ const eval_case integer = {.source = "AIME2025" , .answer = "82" };
3393+ char got [EVAL_ANSWER_MAX ];
3394+
3395+ if (regrade_case_outcome (& integer , "PASSED" , "</think>Answer: 82" ,
3396+ got , sizeof (got )) != REGRADE_PASSED ) {
3397+ fprintf (stderr , "ds4-eval: regrade self-test failed: PASSED trace not regraded\n" );
3398+ failed ++ ;
3399+ }
3400+ /* An interrupted run whose partial output happens to look correct must not
3401+ * be counted or flagged as drift. */
3402+ if (regrade_case_outcome (& integer , "STOPPED" , "</think>Answer: 82" ,
3403+ got , sizeof (got )) != REGRADE_NOT_GRADED ) {
3404+ fprintf (stderr , "ds4-eval: regrade self-test failed: STOPPED trace was graded\n" );
3405+ failed ++ ;
3406+ }
3407+ /* Legacy traces without a status line stay gradeable. */
3408+ if (regrade_case_outcome (& integer , "" , "</think>Answer: 82" ,
3409+ got , sizeof (got )) != REGRADE_PASSED ) {
3410+ fprintf (stderr , "ds4-eval: regrade self-test failed: legacy empty status not graded\n" );
3411+ failed ++ ;
3412+ }
3413+ return failed ;
3414+ }
3415+
32763416static int run_extractor_self_tests (void ) {
32773417 int failed = 0 ;
32783418
32793419 failed += trace_copy_self_test_case ();
3420+ failed += regrade_status_self_test_case ();
32803421
32813422 const eval_case mc = {
32823423 .source = "SuperGPQA" ,
@@ -3337,6 +3478,93 @@ static int run_extractor_self_tests(void) {
33373478 "Answer: 10" ,
33383479 "10" );
33393480
3481+ /* --- Regression cases for answer-extractor false negatives. These guard
3482+ * against the grader under-reporting model accuracy on well-formed
3483+ * final-answer lines. --- */
3484+
3485+ /* Multiple choice: a standalone in-range capital that merely begins an
3486+ * English word ("I think", "A careful") or a contraction ("I'll") must
3487+ * not shadow the choice the model actually states later on the line.
3488+ * Only reachable when the stray letter is itself a valid option, i.e.
3489+ * 10-choice (A-J) cases, of which the embedded set has 24. */
3490+ const eval_case mc_c = {
3491+ .source = "SuperGPQA" ,
3492+ .choice = {"A" , "B" , "C" , "D" , "E" , "F" , "G" , "H" , "I" , "J" },
3493+ .answer = "C" ,
3494+ };
3495+ failed += extractor_self_test_case (
3496+ "MC: leading pronoun 'I' does not shadow the chosen letter" ,
3497+ & mc_c , "</think>Answer: I think it is C" , "C" );
3498+ failed += extractor_self_test_case (
3499+ "MC: contraction I'll does not shadow the chosen letter" ,
3500+ & mc_c , "</think>Answer: I'll go with C." , "C" );
3501+ failed += extractor_self_test_case (
3502+ "MC: leading article 'A' does not shadow the chosen letter" ,
3503+ & mc_c , "</think>Answer: A careful reading shows C." , "C" );
3504+
3505+ const eval_case mc_i = {
3506+ .source = "SuperGPQA" ,
3507+ .choice = {"A" , "B" , "C" , "D" , "E" , "F" , "G" , "H" , "I" , "J" },
3508+ .answer = "I" ,
3509+ };
3510+ failed += extractor_self_test_case (
3511+ "MC: a genuine standalone 'I' answer is still picked" ,
3512+ & mc_i , "</think>Answer: I." , "I" );
3513+
3514+ const eval_case mc4_d = {
3515+ .source = "SuperGPQA" ,
3516+ .choice = {"A" , "B" , "C" , "D" },
3517+ .answer = "D" ,
3518+ };
3519+ failed += extractor_self_test_case (
3520+ "MC: out-of-range pronoun is harmless on 4-choice cases" ,
3521+ & mc4_d , "</think>Answer: I think it is D" , "D" );
3522+
3523+ /* A distractor the model explicitly rejects *before* stating its pick on
3524+ * the same line must not shadow the real choice ("not B, ... D" /
3525+ * "rules out C, leaving D"). The bare first-valid-letter rule grabbed the
3526+ * rejected letter; a small, high-precision negation-cue skip fixes it.
3527+ * "D, not B" is the guard against the naive "take the last letter" fix:
3528+ * the pick is reached and accepted before the rejected distractor. #321 */
3529+ const eval_case mc_d = {
3530+ .source = "SuperGPQA" ,
3531+ .choice = {"A" , "B" , "C" , "D" , "E" , "F" , "G" , "H" , "I" , "J" },
3532+ .answer = "D" ,
3533+ };
3534+ failed += extractor_self_test_case (
3535+ "MC: 'not B' distractor before the pick does not shadow it" ,
3536+ & mc_d , "</think>Answer: It is not B, the answer is D" , "D" );
3537+ failed += extractor_self_test_case (
3538+ "MC: 'rules out C, leaving D' grades the surviving choice" ,
3539+ & mc_d , "</think>Answer: rules out C, leaving D" , "D" );
3540+ failed += extractor_self_test_case (
3541+ "MC: contraction negation (isn't B) before the pick is skipped" ,
3542+ & mc_d , "</think>Answer: It isn't B, so D" , "D" );
3543+ failed += extractor_self_test_case (
3544+ "MC: a leading pick followed by 'not B' is still graded as the pick" ,
3545+ & mc_d , "</think>Answer: D, not B" , "D" );
3546+
3547+ /* Integer: when the answer line shows the arithmetic, the graded value
3548+ * must be the stated result (right of the last '='), not the first
3549+ * summand/factor; digits on later lines must not leak in either. Many
3550+ * embedded AIME2025 cases ask for a derived sum (m+n, a+b+c, ...). */
3551+ const eval_case int_293 = {.source = "AIME2025" , .answer = "293" };
3552+ failed += extractor_self_test_case (
3553+ "integer: sum line grades the total, not the first summand" ,
3554+ & int_293 , "</think>Answer: m+n = 256+37 = 293" , "293" );
3555+ const eval_case int_62 = {.source = "AIME2025" , .answer = "62" };
3556+ failed += extractor_self_test_case (
3557+ "integer: three-term sum line grades the total" ,
3558+ & int_62 , "</think>Answer: a+b+c = 12+25+25 = 62" , "62" );
3559+ const eval_case int_81 = {.source = "AIME2025" , .answer = "81" };
3560+ failed += extractor_self_test_case (
3561+ "integer: product-sum line grades the result, not the first factor" ,
3562+ & int_81 , "</think>Answer: 2*7 + 3*6 + 5*4 + 7*5 = 81" , "81" );
3563+ const eval_case int_82 = {.source = "AIME2025" , .answer = "82" };
3564+ failed += extractor_self_test_case (
3565+ "integer: digits on a later line do not override the answer line" ,
3566+ & int_82 , "</think>Answer: 82\nThe value 2025 is just the year." , "82" );
3567+
33403568 if (failed ) return 1 ;
33413569 printf ("ds4-eval: answer extractor self-tests passed\n" );
33423570 return 0 ;
0 commit comments