Skip to content

feat(tasks): ifeval/multi_if _fixed — three repaired constraint checkers - #104

Merged
ethan-scitix merged 3 commits into
mainfrom
feat/ifeval-multi-if-fixed
Aug 15, 2026
Merged

feat(tasks): ifeval/multi_if _fixed — three repaired constraint checkers#104
ethan-scitix merged 3 commits into
mainfrom
feat/ifeval-multi-if-fixed

Conversation

@ethan-scitix

Copy link
Copy Markdown
Collaborator

Three of IFEval's 25 constraint 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
ifeval_0shot_gen and multi_if_0shot_gen 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.

The three defects

length_constraints:nth_paragraph_first_word — the list counted is not the list
indexed.
Upstream splits on \n\n, decrements a count for each blank chunk, then
indexes the unfiltered list:

paragraphs = re.split(r"\n\n", value)
num_paragraphs = len(paragraphs)
for paragraph in paragraphs:
    if not paragraph.strip():
        num_paragraphs -= 1
...
paragraph = paragraphs[self._nth_paragraph - 1]

A blank chunk at or before index nth - 1 therefore checks the wrong paragraph — or
hands 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\n outright. A second stored full-541
run 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_word spanning more than
one 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 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 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 by
random.choice(string.ascii_letters), drawn freshly on every 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. Values that are genuinely
unusable (None, more than one character after stripping) still fall back to
upstream'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's
profiles 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 still
short-circuits, so the set of responses that reach the detector is exactly the set
upstream sends there.

The sibling change_case:english_lowercase is deliberately not repaired — it calls
the 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 stored
responses — 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:

set upstream → fixed
Intern-S2-Preview, full 541 92.79 → 95.38 (+2.59)
a second stored full-541 run 94.82 → 95.19 (+0.37)
Qwen3-30B-A3B thinking-on, 100 prompts 65.00 → 67.00 (+2.00)

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_capital 2
slots 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):

turn overall strict instruction-level
1 75.45 → 77.51 (+2.06) 78.14 → 80.97 (+2.83)
2 69.09 → 70.74 (+1.65) 79.46 → 81.44 (+1.98)
3 62.67 → 63.30 (+0.63) 78.75 → 79.64 (+0.89)

score moves 69.07 → 70.52 (+1.45). Coverage: 1,121 slots across 515 of 4,501
conversations (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 langdetect seeds, strict
prompt-level spans:

set upstream fixed
Intern-S2-Preview 541 92.61–92.79 (0.18) 95.38–95.38 (0.00)
second full-541 run 94.45–94.82 (0.37) 95.19–95.19 (0.00)
Qwen3 100-prompt 63.00–65.00 (2.00) 65.00–67.00 (2.00)

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 one english_capital
whose 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 score spread after repair — the same 0.18 upstream has, because
that residue is language routing on genuinely multilingual text, which no repair here
touches and none should.

Reproduction note. langdetect.detect is randomized and sieval never sets
DetectorFactory.seed, so an unseeded A/B flips checkers these tasks do not touch:
change_case:english_lowercase flipped between arms during this measurement before its
factory 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
_fixed task overrides exactly _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 all inherited, so the two tasks cannot
diverge anywhere except at the registry. A test checks the override set is literally
{"_instruction_dict"} and that preprocess/infer/postprocess/feedback/report
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 all stay upstream's
by 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. _build also refuses to mount a fix under an id not
already in the base registry: after an upstream rename the overlay would otherwise land
on a key nothing looks up, and _fixed would quietly grade identically to the
unqualified task while still claiming a delta.

Why the repair module lives in community/ as original code, which that directory's
CLAUDE.md asks to be argued for: the same three checkers are vendored twice — under
instruction_following_eval/ (google-research) and under multi_if/ (Meta's fork of
the 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_dict
parameter 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 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 above and in
reference_impl.notes; a live run is what promotion owes.

Tests

95 new tests.

  • tests/unit/community/test_instruction_following_eval_fixed.py (68) asserts every
    repair twice: once that it reduces to upstream on the inputs upstream already
    handled — an 8-response × 3-nth matrix for the paragraph checker, description-string
    equality 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.
  • 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 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) pin
    registration, the fresh-interpreter import discipline (evaluation_lib, the repair
    module, langdetect and nltk all absent after importing the task), the one-method
    claim, byte-identical judgements on unrepaired-only samples, and that upstream cannot
    pass a multi-token first_word with any response at all.
  • 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 — no prompt or kwarg text from that dataset
appears anywhere in this PR.

Verification

ruff check + ruff format --check clean repo-wide; ty check reports zero
diagnostics in sieval/ and none in any file this PR adds or touches;
scripts/check_preflight.py passes every check with both tasks registered (including
check_report_declarations, check_reference_kind and check_meta_index_sync);
sync_meta_index.py and sync_package_stubs.py re-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, an
optional dependency absent from the measuring environment and untouched by this PR.

🤖 Generated with Claude Code

ethan-scitix and others added 3 commits August 15, 2026 16:20
…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
ethan-scitix force-pushed the feat/ifeval-multi-if-fixed branch from 2953cf3 to 4a4f0ad Compare August 15, 2026 09:12
@ethan-scitix
ethan-scitix merged commit c9e9f7c into main Aug 15, 2026
9 checks passed
@ethan-scitix
ethan-scitix deleted the feat/ifeval-multi-if-fixed branch August 15, 2026 09:22
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant