Skip to content

Commit 80ebbc3

Browse files
authored
Merge pull request #319 from rinaldofesta/fix/eval-grader-false-negatives
ds4-eval: fix four answer-grader false negatives + golden self-tests
2 parents cafc134 + b2533d1 commit 80ebbc3

1 file changed

Lines changed: 234 additions & 6 deletions

File tree

ds4_eval.c

Lines changed: 234 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
27002756
static 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+
31353242
static 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+
32763416
static 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

Comments
 (0)