feat(tasks): ifeval/multi_if _fixed — three repaired constraint checkers - #104
Merged
Conversation
…heckers
Three of IFEval's 25 checkers grade something other than what their own
instruction says. This adds `ifeval_0shot_gen_fixed` and `multi_if_0shot_gen_fixed`,
which substitute repaired checkers and quantify the delta. Neither unqualified task
changes behaviour: both keep grading through the vendored registries, defects
included, because that is what reproduces a published number. Nothing here changes
what an item asks -- every repair is to the code that decides whether an answer
complied.
`length_constraints:nth_paragraph_first_word` splits on `\n\n`, decrements a *count*
for each blank chunk, then indexes the UNFILTERED list. A blank chunk at or before
index `nth - 1` therefore checks the wrong paragraph -- or a blank one -- against a
total computed the other way. Whole runs open with a blank chunk uniformly depending
on the chat template: 540 of 541 responses in one stored Intern-S2-Preview run open
with a space then a blank line, all 100 in a Qwen3-30B-A3B thinking-on run open with
`\n\n` outright, and a second full-541 run does it in 0.2% -- whose flips at this id
are correspondingly zero. Multi-IF-only second half: 32 of its 749 slots store a
`first_word` spanning more than one token, and upstream compares against
`paragraph.split()[0]`, so such a slot returns FAIL for EVERY possible response. A
check that cannot pass measures nothing. The repair compares as many tokens as the
constraint's own value spans, each normalised by upstream's own routine; it does not
relax the comparison, and for a single-token value -- every IFEval slot and 714 of
Multi-IF's -- it reduces to upstream's expression token for token.
`keywords:letter_frequency` accepts a letter only in `[a-z]`; anything else is
silently replaced by `random.choice(string.ascii_letters)`, drawn freshly per call,
so an item naming `#` or `!` is graded against a different letter each time it is
scored. The repair keeps the character the item names and consumes no draw, so the
global RNG stream other checkers default arguments from is unperturbed. Genuinely
unusable values still fall back to upstream's behaviour.
`change_case:english_capital` is `value.isupper() and langdetect.detect(value) ==
"en"`. langdetect's profiles are built from lowercase text, and the checker's own
precondition is that the response IS all capitals, so every response it ever sees is
in exactly the state the detector handles worst. Only the argument changes: detection
runs on a case-folded copy. `isupper()` is still upstream's, still first, and still
short-circuits. The sibling `change_case:english_lowercase` is deliberately not
repaired -- it detects on already-lowercase text.
SCORE IMPACT, measured by running each task's own feedback/report over the same
stored responses -- the numbers a run reports, not a grader-level approximation --
with langdetect seeded identically per arm. IFEval strict prompt-level 92.79->95.38
(+2.59) on a full-541 Intern-S2-Preview run, 94.82->95.19 (+0.37) on a second
full-541 run, 65.00->67.00 (+2.00) on a 100-prompt Qwen3-30B-A3B thinking-on subset;
loose +0.55/+0.55/+2.00; coverage 70 of 834 slots over 68 of 541 prompts (12.6%).
Flips are bidirectional -- the second run's loose reading moves english_capital 2
FAIL->PASS and 1 PASS->FAIL. Multi-IF `score` 69.07->70.52 (+1.45) over 160 stored
conversations (1,218 slots), per-turn overall +2.06/+1.65/+0.63; coverage 1,121 slots
across 515 of 4,501 conversations (11.4%).
It also removes a source of run-to-run nondeterminism. Over five langdetect seeds the
repaired IFEval task is seed-invariant on both full-541 sets (spread 0.00, not one of
834 slots changing verdict) where upstream spans 0.18 and 0.37. The 100-prompt subset
keeps a 2.00 spread at two slots that are not this task's to fix: one
change_case:english_lowercase and one english_capital whose response is a
94k-character repetition loop reading as Welsh on one seed in five. Multi-IF keeps
0.18 after repair -- the same 0.18 upstream has, since that residue is language
routing on genuinely multilingual text.
Design. Each `_fixed` task overrides exactly one method, `_instruction_dict()` -- a
new seam on the base tasks returning None, meaning "the vendored registry".
Prompting, both graders, the per-sample record and the pooled report are inherited,
so the two tasks cannot diverge anywhere else; a test asserts the override set is
literally {"_instruction_dict"} and that the five pipeline methods are the same
function objects. The repairs are mixins over the upstream classes, so
build_description's defaults and validation, get_instruction_args and the description
pattern stay upstream's by construction. Registries are built fresh per call and the
vendored globals are never mutated -- samples grade concurrently -- and `_build`
refuses to mount a fix under an id not already in the base registry, so an upstream
rename fails loudly instead of silently un-applying a fix.
The repair module is original code in `community/`, which that directory's CLAUDE.md
asks to be argued for: these three checkers are vendored twice (google-research's
copy and Meta's fork), differing only in one import path and one logging call, so
holding the fix outside both serves upstream alignment -- the alternative is two
copies of every repair inside files whose value is being diffable against upstream.
The one change inside the vendored files is a keyword-only `instruction_dict`
parameter on their four grader functions, defaulting to the vendored registry;
omitting it reproduces upstream exactly. It was preferred over re-implementing the
~25-line grading loops here, which would drift silently at the next re-sync.
Both tasks are `experimental` deliberately: by construction neither can reproduce a
published anchor -- they grade differently on purpose -- and neither has been run end
to end under its own name. `_fixed` owes a quantified divergence, which is in the
module docstrings and reference_impl.notes; a live run is what promotion owes.
95 new tests. Every repair is asserted twice: that it REDUCES TO UPSTREAM on the
inputs upstream already handled (an 8-response x 3-nth matrix, description-string
equality, a held-constant stubbed detector), which is what makes the divergence
provably the defect rather than a rewrite riding along, and that it changes the
verdict on the input that exposed the defect. Scope is bounded from both sides: a
blank chunk after the target index is asserted harmless, a wrong word and a wrong
paragraph count still fail, a blank first_word stays ungradeable in both arms, and
the repaired letter-frequency checker consumes no random draw. `change_case:*`
fixtures stub the detector rather than calling it -- a test that flipped with the
process RNG would be no evidence at all. Multi-IF fixtures are synthetic throughout.
Verified: ruff check + format clean repo-wide; `ty check` reports zero diagnostics in
sieval/ and none in any file this touches; check_preflight passes every check with
both tasks registered; full unit suite shows no new failures against a clean
base-commit worktree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`multi_if_0shot_gen_fixed` grades through the shared repair module in `sieval.community.instruction_following_eval_fixed`, which imports google-research's `instructions` at module scope. absl-py and immutabledict are that module's imports, and both were declared only in the `ifeval` extra, so on a clean `sieval[multi-if]` install `_instruction_dict()` raised `ModuleNotFoundError: No module named 'absl'`. It is called from `feedback()`, which puts the failure after inference has already been paid for; the task module itself imports fine, so registration gives no early warning. `check_dep_coverage` cannot see this: it scans only `sieval/tasks/` and `sieval/datasets/` top-level imports, against the union of every declared group, so neither a per-group gap nor a transitive import through `community/` is in its range. Worth closing separately. Verified by building each `_fixed` task's registry with only its own extra's modules importable: `sieval[multi-if]` now yields 25 checkers with 3 repaired, and blocking absl/immutabledict again reproduces the original failure. `sieval[ifeval]` was already fine in the other direction -- Meta's fork guards its emoji/langdetect imports and defers pythainlp -- and is now covered by a check rather than by accident. Three further points from the same review: * Both `_fixed` tasks ship `status="stable"`. The divergence is carried by the name, so `status` is not gated on reproducing a published number; what a `_fixed` variant owes is a quantified delta, which both carry. `experimental` stays reserved for a faithful port whose published anchor is not reachable, which is why `multi_if_0shot_gen` keeps it and its `_fixed` sibling does not inherit it. * The four vendored graders now default `instruction_dict` on `is None` rather than truthiness. An override returning an empty registry would otherwise grade silently through the vendored one -- the exact un-apply that `_build`'s unknown-id refusal exists to prevent. * `_EnglishCapitalFix` documents that overriding the whole method also drops upstream's log-on-exception (`logging.error` in google-research's copy, `logger.info` in Meta's), which is the only other difference from upstream and changes no verdict. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two task docstrings had grown to carry the full measurement record -- score-impact and seed-spread tables, per-set per-id flip counts -- all of which `reference_impl.notes` already ships, and notes is the copy that reaches `meta/index.json` and an archived run. So the docstrings now keep only what notes cannot say in a metadata string: what fires the paragraph defect and why the per-set deltas span an order of magnitude, why coverage makes the multi-token repair inert on IFEval, and the langdetect-seeding caveat. Everything quantified stays in notes, unchanged -- the divergence enumeration and the score impact the `_fixed` rule requires are untouched, as are the three ids the tests assert on. Also tightened: the two `status="stable"` justifications, the lazy-import comments, the community module's defect writeups, and the `multi-if` extra's comment block. No behaviour change, no vendored file touched. net -113 lines; 5363 tests pass, preflight all-PASS, ruff/ty clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ethan-scitix
force-pushed
the
feat/ifeval-multi-if-fixed
branch
from
August 15, 2026 09:12
2953cf3 to
4a4f0ad
Compare
ethan-scitix
added a commit
that referenced
this pull request
Aug 15, 2026
…ers (#105) * feat(tasks): ifbench_0shot_gen_fixed — four repaired constraint checkers Four checkers vendored from AllenAI IFBench grade something other than what their own instruction text asks for. Per sieval/tasks/CLAUDE.md the unqualified name keeps tracking upstream, bugs included; the repairs land in a registered `_fixed` variant that carries a measured delta. format:line_indent blank lines removed with `for line in lines: lines.remove(line)`, which mutates the list under its own iterator and skips one element per removal, so the verdict depends on how many blank lines a response happened to contain ratio:sentence_type declaratives counted with endswith('.'), missing quoted ones; and `declarative == 2 * interrogative` passes vacuously at 0 == 0 words:words_position positions indexed over tokens; `words[-3]` is right for exactly one trailing punctuation token and off by one for two, flipping verdicts in both directions words:vowel paragraphs counted with split('\n'), rejecting any soft-wrapped single paragraph on line count No item text is changed — a defect licenses repairing the verifier, not rewriting the question. SCORE IMPACT, over the full official 300-prompt test set at 8 rollouts (Qwen3-30B-A3B, thinking on), replaying stored responses through each task's own feedback()+report(): loose prompt-level (headline) 39.2917 -> 39.3333 (+0.0417) strict prompt-level 30.7917 -> 31.2500 (+0.4583) loose instruction-level 42.2602 -> 42.2965 (+0.0363) strict instruction-level 34.0480 -> 34.4477 (+0.3997) The delta is small, and that is the finding: 12 of 5,504 gradings flip. Only format:line_indent (11) and words:words_position (1) flip anything on these responses; ratio:sentence_type and words:vowel flip nothing and are kept because their defect is unexercised by this model, not absent. The variant overrides exactly one method. `_instruction_dict()` is a new seam on the base task returning None (= the vendored registry); the two graders in the vendored evaluation_lib take it as a keyword-only argument, so omitting it reproduces upstream exactly. Repairs subclass their upstream checker, which keeps build_description and the description pattern upstream's by construction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tasks): a CRLF blank line is a paragraph break too Review follow-ups on ifbench_0shot_gen_fixed. `words:vowel`'s repair replaced upstream's split('\n') with a blank-line pattern, `\n[ \t]*\n`, which does not match `\r\n\r\n`. A CRLF response's two paragraphs stayed joined, counted as one, and passed a checker whose only job is to reject them -- FAIL->PASS, the inflating direction, and the one case the module docstring explicitly claimed to handle. The blank-line test asserted that claim for `\n\n` alone, so nothing caught it. Widened to `\r?\n[^\S\r\n]*\r?\n`: CRLF reads as one break, and "whitespace that is not a line break" covers an NBSP-padded blank line as well as a space-padded one. The test is now parametrized over five spellings; with the old pattern exactly the CRLF and NBSP cases fail. Also: - `ratio:sentence_type` reads the terminal mark past closing quotes on *both* counts, so it recovers quoted interrogatives as well as quoted declaratives. A `_fixed` owes every divergence enumerated, and the notes described only the declarative half. Documented in both places, with a test that isolates the interrogative side -- repairing only the declarative count fails it and nothing else. - status experimental -> stable. The base is experimental about a published-number reproduction this task does not attempt; what a `_fixed` owes for stable is a quantified score impact, and the notes carry one. The divergence is announced by the name. - community/ifbench/__init__.py lists the graders' `instruction_dict` as the third local adaptation; the comment in evaluation_lib.py pointed at the task module instead of the checkers module. - Both graders test the `None` sentinel rather than falsiness, so an empty overlay raises on lookup instead of silently grading through the vendored registry. The measured delta is unaffected, and the run's own numbers are why. The widening changes a verdict only on a response carrying a CRLF or NBSP blank line; such a response would have been a disagreement between the line-count and paragraph-count gates, and the run reports those two agreeing on all 70 non-blank responses. The reported flips came from `format:line_indent` and `words:words_position` either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tasks): registration must not import the vendored IFBench fork `import_all_tasks()` walks every module under `sieval/tasks` with `pkgutil.walk_packages`, private ones included. The underscore on `_ifbench_fixed_checkers` keeps it out of the task *index*; it does not keep it out of the *import scan*. So its module-scope `from sieval.community.ifbench import instructions` ran during registration and chained into `import emoji`, which CI does not install -- preflight's `check_meta_index_sync` died with ModuleNotFoundError. This arrived with c1bb3f3, not with ec2a5ee; a local run could not see it because the `ifbench` extras are installed here. `_math_verify` is the precedent and the rule it keeps is the fix: a private task module owes the same import discipline as a public one, so the fork is imported inside `_fixed_checker_classes()` (cached -- a subclass needs its base at class-definition time) and never at module scope. The four verdict functions stay at module scope, where they are read, typed and tested without the fork present. `core/` is untouched. Two docstring paragraphs and one test comment asserted the false version of this ("the discovery scan skips it"); corrected rather than left to mislead the next reader. CI gains `-G ifbench`. The repaired checkers cannot be tested against a fake -- the whole assertion is repaired-vs-upstream -- so without the group the substance of this PR runs nowhere. It is emoji + syllapy on top of the nltk `ifeval` already pulls, and it is the first group whose vendored code fetches NLTK corpora at import, so `checks` warms them in a named step instead of letting a test hit the network mid-run. Verified: reverse-mutation (re-adding the module-scope import reproduces the exact CI traceback, 1 failed / 2 passed); `sync_meta_index.py --check` under a meta_path hook blocking emoji and syllapy exits 0; 44 ifbench tests, 5266 unit tests, ruff, ty all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ci: drop the NLTK staging step; ifbench already self-downloads The step I added was redundant and its comment was wrong. IFBench is not the first group here whose vendored code fetches NLTK corpora at import, and not the only one in the test job: `sieval/tasks/multi_if_0shot_gen.py` and `sieval/datasets/ruler/_shared.py` both find-or-download on demand already, and multi_if's own docstring records that the IFBench sibling "ensures the same resource from inside its vendored module, because upstream IFBench ships that download helper itself". So on-demand fetch is the repo's convention, not an exception this PR introduces, and `download_nltk_resources()` catches LookupError rather than raising -- the failure mode the step claimed to prevent could not happen. What the step did add was a second copy of the corpus list, drifting against the six the delivery image bakes. The delivery image is where staging belongs and it is already correct: `Dockerfile` pre-downloads a superset. It is not CI's environment (no `container:` anywhere, and it installs a prebuilt wheel from ./dist), so it was never the route to this fix -- `-G ifbench` is, and that stays: without the group the repaired-checker tests fail at collection. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(tasks): match the _fixed conventions #104 landed on main Rebasing onto main brought in `ifeval_0shot_gen_fixed` and `multi_if_0shot_gen_fixed`. Nothing here broke -- preflight, ty, ruff and 5405 unit tests pass on the rebase untouched -- but the two new siblings settled how a `_fixed` task states itself, and this one predates them. - **`**Status.**` paragraph in the module docstring.** All three other `_fixed` tasks carry one (ugmathbench's too, in its own wording); this was the only one arguing its status solely in a decorator comment, and an eleven-line one. The argument moves to the docstring where the other three keep it, and the comment shrinks to the siblings' four-line pointer. Same claim, no content dropped. - **`DIVERGENCES (all four, exhaustive):`** in `notes`. Both siblings mark the enumeration with that keyword, and "exhaustive" is the load-bearing half -- it says the list is complete, which is what the variant rule demands. This said "nothing else differs" in prose ahead of the list; now it says it in the family's words, and the completeness clause moves after the list to name what is inherited (one overridden method). - **`REPRODUCTION NOTE:`** in `notes`, which both siblings end with and this had no equivalent of. The IFBench analogue of their langdetect trap is the IFEval-family random fallback: 16 of IFBench's 58 checkers draw `random.randint`/`random.choice` in `build_description` when a row omits the kwarg, so an unseeded A/B can move checkers this task never touches. Verified by walking `instructions.py` class by class: none of the four repaired here draws, so the repairs consume no draw and leave that shared stream unperturbed -- the property #104 had to engineer for `keywords:letter_frequency`, free here. Also records why these repairs are *not* in `sieval.community.instruction_following_eval_fixed`. Three `_fixed` instruction-following tasks now exist and two share that module, so the odd one out should say why: IFBench is allenai's own checker set, sharing none of the three google-research defects, and it has one caller. `ugmathbench_0shot_gen_fixed` keeps its repairs in the task tree the same way -- the shared module is extraction on coupling, not on call count. Prose and shipped metadata only; no checker, prompt, dataset or verdict path is touched, and the measured delta is unchanged. `notes` is embedded in `sieval/meta/index.json`, so the regenerated index rides along. Verified: preflight ALL PASS (incl. check_meta_index_sync, check_reference_kind over 64 tasks), `ruff check` + `ruff format --check`, `ty check`, `sanitize.sh`, 44 ifbench tests, 5405 unit tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(tasks): tighten the ifbench_0shot_gen_fixed prose Wording only. Every claim the two docstrings, the `notes` and the CI comment made is still made; none is made twice, and none is made at length where a clause does. - The class docstring goes. It restated "overrides exactly one method", which the module docstring's opening sentence, the `notes` completeness clause and `test_exactly_one_method_differs_from_the_ unqualified_task` each already say. Neither `_fixed` sibling on main carries one. - The measured-delta section keeps every number and drops the readings wrapped around them -- "the defect is real and unexercised, not absent" was already the sentence before it, twice. - `notes` loses the placement rationale, which is code organisation and belongs in the module docstring that now carries it, not in shipped metadata a leaderboard reader sees. The four divergences, the exhaustiveness marker, the score table and the reproduction note are untouched -- the three tests asserting on them still pass. - The `_PARA_SPLIT` and registry-guard comments state the failure mode once instead of restating the claim it protects. - The CI comment drops the Dockerfile pointer and the "on-demand route" restatement; the anti-regression half -- that these corpora need no staging step -- stays, since that is the part a future reader would otherwise re-add. No checker, prompt, dataset, verdict path or number changes. The regenerated `sieval/meta/index.json` moves exactly one line, the `notes` string. Verified: preflight ALL PASS, `ruff check` + `ruff format --check`, `ty check`, `sanitize.sh`, 44 ifbench tests, 5405 unit tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three of IFEval's 25 constraint checkers grade something other than what their own
instruction says. This adds
ifeval_0shot_gen_fixedandmulti_if_0shot_gen_fixed,which substitute repaired checkers and quantify the delta. Neither unqualified task
changes behaviour —
ifeval_0shot_genandmulti_if_0shot_genkeep grading throughthe vendored registries, defects included, because that is what reproduces a published
number.
Nothing here changes what an item asks. Every repair is to the code that decides
whether an answer complied.
The three defects
length_constraints:nth_paragraph_first_word— the list counted is not the listindexed. Upstream splits on
\n\n, decrements a count for each blank chunk, thenindexes the unfiltered list:
A blank chunk at or before index
nth - 1therefore checks the wrong paragraph — orhands the check a blank one — while the total it is compared against was computed on
the other reading. The repair filters once and indexes what it counted.
This fires far more often than it looks like it should, because whole runs open with a
blank chunk uniformly depending on the chat template: 540 of 541 responses in one
stored Intern-S2-Preview run open with a space then a blank line, and all 100 responses
in a Qwen3-30B-A3B thinking-on run open with
\n\noutright. A second stored full-541run does it in 0.2%, and its flips at this id are correspondingly zero — so the blast
radius is run-dependent, not universal.
Second, Multi-IF-only half: 32 of its 749 slots store a
first_wordspanning more thanone token (Hindi 12, Spanish 9, French 3, Portuguese 3, Russian 3, Italian 2, English
0), and the prompts carrying them ask for that same phrase. Upstream compares against
paragraph.split()[0], one token, so such a slot returns FAIL for every possibleresponse — a check that cannot pass measures nothing. The repair compares as many
tokens as the constraint's own value spans, each normalised by upstream's own routine.
It does not relax anything: the phrase must still open the paragraph, in order. For a
single-token value — every IFEval slot and 714 of Multi-IF's — it reduces to upstream's
expression token for token.
keywords:letter_frequency— the item is graded against a letter nobody asked for.Upstream accepts a letter only in
[a-z]; anything else is silently replaced byrandom.choice(string.ascii_letters), drawn freshly on every call. So an itemnaming
#or!is graded against a different letter each time it is scored. Therepair keeps the character the item names, and consumes no draw, so the global RNG
stream other checkers default arguments from is unperturbed. Values that are genuinely
unusable (
None, more than one character after stripping) still fall back toupstream's behaviour — there the item states nothing gradable, and inventing a rule
would be worse.
change_case:english_capital— langdetect is called on input it cannot read.Upstream is
value.isupper() and langdetect.detect(value) == "en". langdetect'sprofiles are built from lowercase text, so ALL-CAPS input is off-distribution for every
profile and the verdict wanders between the Latin-script languages. The checker's own
precondition is that the response is all capitals, so every response it ever sees is
in exactly the state the detector handles worst. Only the argument changes: the repair
detects on a case-folded copy.
isupper()is still upstream's, still first, and stillshort-circuits, so the set of responses that reach the detector is exactly the set
upstream sends there.
The sibling
change_case:english_lowercaseis deliberately not repaired — it callsthe detector on text that is already lowercase, which is the condition the profiles
were built for.
Measured score impact
Measured by running each task's own
feedback()/report()over the same storedresponses — these are the numbers a run reports, not a grader-level approximation —
with
langdetect's factory seeded identically per arm.ifeval_0shot_gen_fixed, strict prompt-level:Loose prompt-level +0.55 / +0.55 / +2.00. Coverage: the three ids account for 70 of 834
constraint slots over 68 of 541 prompts (12.6%).
Flips are bidirectional — the second run's loose reading moves
english_capital2slots FAIL→PASS and 1 PASS→FAIL. A well-posed detector removes false passes as well
as false failures.
multi_if_0shot_gen_fixed, over 160 stored Qwen3-30B-A3B thinking-on conversations(1,218 constraint slots):
overallscoremoves 69.07 → 70.52 (+1.45). Coverage: 1,121 slots across 515 of 4,501conversations (11.4%).
The strict/loose asymmetry is mechanical, and there is a test for it rather than a
claim: the loose reading re-tries each response with its first line stripped, which
deletes the very blank chunk that shifts the index. Loose was accidentally masking
the paragraph defect, not immune to it — which is why it is the weaker evidence of the
two.
It also removes a source of run-to-run nondeterminism
Re-grading each IFEval set under five different
langdetectseeds, strictprompt-level spans:
On both full sets the repaired task is seed-invariant: not one of the 834 slots
changes verdict across all five seeds. The 100-prompt subset keeps its 2.00 spread, and
the attribution is exact — its two unstable slots are one
change_case:english_lowercase(deliberately unrepaired) and oneenglish_capitalwhose response is a 94,152-character repetition loop that reads as Welsh on one seed in
five. The repair makes detection well-posed; it does not make langdetect deterministic,
and on a degenerate response nothing would.
Multi-IF keeps a 0.18
scorespread after repair — the same 0.18 upstream has, becausethat residue is language routing on genuinely multilingual text, which no repair here
touches and none should.
Reproduction note.
langdetect.detectis randomized and sieval never setsDetectorFactory.seed, so an unseeded A/B flips checkers these tasks do not touch:change_case:english_lowercaseflipped between arms during this measurement before itsfactory was pinned, and looked for a while like the repair leaking through a shared
RNG. Pin the seed in both arms before attributing any flip to a fix.
Design
One overridden method, and it is asserted, not asserted-in-a-docstring. Each
_fixedtask overrides exactly_instruction_dict()— a new seam on the base tasksreturning
None, meaning "the vendored registry". Prompting, both graders, theper-sample record and the pooled report are all inherited, so the two tasks cannot
diverge anywhere except at the registry. A test checks the override set is literally
{"_instruction_dict"}and thatpreprocess/infer/postprocess/feedback/reportare the same function objects.
The repairs are mixins over the upstream classes, so
build_description's defaultsand validation,
get_instruction_argsand the description pattern all stay upstream'sby construction rather than by review.
Registries are built fresh per call and the vendored globals are never mutated —
samples grade concurrently, and swapping a global would change how the unqualified
task grades in the same session.
_buildalso refuses to mount a fix under an id notalready in the base registry: after an upstream rename the overlay would otherwise land
on a key nothing looks up, and
_fixedwould quietly grade identically to theunqualified task while still claiming a delta.
Why the repair module lives in
community/as original code, which that directory'sCLAUDE.md asks to be argued for: the same three checkers are vendored twice — under
instruction_following_eval/(google-research) and undermulti_if/(Meta's fork ofthe same file). For these three classes the two copies differ only in one import path
and one logging call, neither of which a repair touches. Holding the fix outside both
vendored packages serves upstream alignment rather than working against it: the
alternative is two copies of every repair inside files whose value is being diffable
against upstream, and a fix duplicated into two files is a fix that will eventually
exist in only one of them.
The one change inside the vendored files is a keyword-only
instruction_dictparameter on their four grader functions, defaulting to the vendored registry. Omitting
it reproduces upstream exactly, which is what the unqualified tasks do. It was preferred
over re-implementing the grading loops in the repair module: those loops are ~25 lines
each of upstream logic and a copy would drift silently at the next re-sync, whereas a
parameter cannot.
Both tasks are
experimental, deliberately. By construction neither can reproduce apublished anchor — they grade differently on purpose — and neither has been run end to
end under its own name.
_fixedowes a quantified divergence, which is above and inreference_impl.notes; a live run is what promotion owes.Tests
95 new tests.
tests/unit/community/test_instruction_following_eval_fixed.py(68) asserts everyrepair twice: once that it reduces to upstream on the inputs upstream already
handled — an 8-response × 3-
nthmatrix for the paragraph checker, description-stringequality for letter frequency, a held-constant stubbed detector for
english_capital— and once that it changes the verdict on the input that exposed the defect. The
reduction half is what makes the divergence provably the defect and not a rewrite
riding along.
harmless, a wrong word and a wrong paragraph count still fail, a blank
first_wordstays ungradeable in both arms, and the fixed letter-frequency checker is asserted to
consume no random draw (
random.getstate()unchanged).tests/unit/tasks/test_{ifeval,multi_if}_0shot_gen_fixed.py(13 + 14) pinregistration, the fresh-interpreter import discipline (
evaluation_lib, the repairmodule,
langdetectandnltkall absent after importing the task), the one-methodclaim, byte-identical judgements on unrepaired-only samples, and that upstream cannot
pass a multi-token
first_wordwith any response at all.change_case:*fixtures stub the detector rather than calling it: a test that flippedwith the process RNG would be no evidence at all.
Multi-IF fixtures are synthetic throughout — no prompt or kwarg text from that dataset
appears anywhere in this PR.
Verification
ruff check+ruff format --checkclean repo-wide;ty checkreports zerodiagnostics in
sieval/and none in any file this PR adds or touches;scripts/check_preflight.pypasses every check with both tasks registered (includingcheck_report_declarations,check_reference_kindandcheck_meta_index_sync);sync_meta_index.pyandsync_package_stubs.pyre-run.Full unit suite against a clean worktree at the same base commit: 13 failed / 5262
passed at base, 13 failed / 5357 passed on this branch. The two failure sets are
identical, and the passed count differs by exactly 95 — the tests this PR adds. The 13
are
ModuleNotFoundError: No module named 'rouge_score'in the IHEval modules, anoptional dependency absent from the measuring environment and untouched by this PR.
🤖 Generated with Claude Code