Skip to content

feat(tasks): add BFCL v3 — single-turn function calling, both protocols - #143

Merged
ethan-scitix merged 28 commits into
mainfrom
worktree-bfcl-v3
Sep 11, 2026
Merged

feat(tasks): add BFCL v3 — single-turn function calling, both protocols#143
ethan-scitix merged 28 commits into
mainfrom
worktree-bfcl-v3

Conversation

@ethan-scitix

@ethan-scitix ethan-scitix commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Type

  • feature — new benchmark, task, or capability

Summary

Adds Berkeley Function Calling Leaderboard (BFCL) v3, single-turn — 4 tasks over 2 datasets, covering both of upstream's protocols.

  • Datasets: bfcl_v3_non_live (1,390 rows / 7 categories) and bfcl_v3_live (2,251 rows / 6 categories), both pinned to hf:gorilla-llm/Berkeley-Function-Calling-Leaderboard@61fc0608 (Apache-2.0). Per-category counts verified against the snapshot, all 13 exact.
  • Tasks: bfcl_v3_non_live_0shot_gen, bfcl_v3_live_0shot_gen (upstream's Prompt protocol — the model emits text that a vendored decoder parses), and the _fc pair (upstream's native function-calling protocol). _fc marks a capability axis — which model interface is exercised — deliberately different from the _parse prompt/extractor axis.
  • The unqualified name goes to the Prompt protocol, so bfcl_v3_non_live_0shot_gen means what an upstream reader expects; _fc is the marked variant.
  • All four ship stable: replaying upstream's own released rollouts reproduces every published cell exactly on 7282/7282 rows (see Manual).
  • Core gained TaskRequirements.function_tools so a task can declare the capability rather than have it inferred; the declaration was unsatisfiable by construction until tool_calls was forwarded through the legacy model bridge.
  • Upstream refs: gorilla repo · leaderboard

Related Issues

Refs #47 — core now has one declared capability constraint (TaskRequirements.function_tools) enforced at readiness, rather than the silent cross-group failure that RFC describes. This PR does not implement the RFC.

Test Plan

Automated

  • Lint/format clean (ruff check && ruff format --check)
  • Type check clean (ty check)
  • Unit tests pass (pytest tests/unit — 7570 passed)
  • python scripts/check_preflight.py — all 25 checks PASS

Manual

Score comparison: exact, on every published cell. BFCL publishes an evaluation archive beside its leaderboard (HuanzhiMao/BFCL-Result, 2025-06-14 snapshot) carrying, per model and per protocol, both the model's recorded replies and upstream's own per-row verdicts. Replaying gpt-4.1-2025-04-14's rollouts through this port reproduces all 26 published category accuracies to the digit and agrees with upstream's verdict on every row — no model spend, and a tighter check than a live run, which would carry sampling noise.

category Prompt: upstream / ours FC: upstream / ours
simple 95.50 / 95.50 93.50 / 93.50
multiple 94.00 / 94.00 90.50 / 90.50
parallel 93.00 / 93.00 91.00 / 91.00
parallel_multiple 87.50 / 87.50 86.00 / 86.00
java 64.00 / 64.00 61.00 / 61.00
javascript 82.00 / 82.00 68.00 / 68.00
irrelevance 88.75 / 88.75 89.58 / 89.58
Non_Live Overall Acc 88.75 86.25
live_simple 85.66 / 85.66 80.23 / 80.23
live_multiple 76.54 / 76.54 78.35 / 78.35
live_parallel 93.75 / 93.75 68.75 / 68.75
live_parallel_multiple 75.00 / 75.00 66.67 / 66.67
live_irrelevance 77.89 / 77.89 82.31 / 82.31
live_relevance 88.89 / 88.89 77.78 / 77.78
Live Overall Acc 78.32 79.92

Row-for-row agreement: Prompt 1390/1390 non-live + 2251/2251 live; FC 1390/1390 + 2251/2251. 7282/7282, zero disagreements. Pinned in tests/acceptance/bfcl_v3/test_upstream_rollout_replay.py.

This anchor is strictly stronger than the gold replay below, which feeds gold back and so only ever exercises the correct path — it never sees a wrong answer, a decode failure, or either protocol's decoder. The new test drives the production _decode of both mixins over output that is 9% wrong; java at 64%/61% is the point, since a third of those rows are wrong and we have to agree about which third. It also pins both rollups against their own documented claims: live's weighted mean reconstructs the pooled rate over all 2251 rows, while non-live's mean-of-means is asserted not to equal the pooled rate, so a "fix" toward pooling fails loudly. Reverse-mutation checked: flipping FC's UNDERSCORE_TO_DOT and forcing the Prompt decoder to language="Python" each fail it.

What the anchor does not measure, stated here and in the shipped notes: the replies are upstream's, so nothing here proves our prompt is upstream's prompt. That rests on system_prompt_pre_processing_chat_model and func_doc_language_specific_pre_processing being byte-identical to upstream (pinned by test_identity.py) and running on a row-for-row verified snapshot — a construction argument, not a measurement.

The archive is ~6MB, so it is staged on demand rather than vendored; the test skips with a reason naming the layout.

  • Gold replay (tests/acceptance/bfcl_v3/test_gold_replay.py): upstream's own gold answers are fed back as the reply and must grade correct. This anchors the harness on upstream's code run now rather than on a published number, and catches grader wiring breakage without model spend. Verified on the pinned snapshot: 2,351 gradeable Python rows walked, and the only failures are the documented upstream gold defects — any new failure fails the test as our defect, and any defect that starts passing fails it as an upstream fix that needs the set updated. Note this anchor is opt-in on a downloaded snapshot, so it does not run in CI.
  • RCE path closed on the Prompt decoder. Upstream eval-uates model output when resolving arithmetic in an argument. The vendored parser.py now walks the AST instead. Two measurements on the pinned snapshot bound the deviation: replaying all 2,351 gradeable Python gold answers calls safe_eval zero times (measured with a patched safe_eval; a control run on f(x=1+2) proves the spy fires when the branch is taken, so the zero is a real negative, not a silent no-op), and of the gold literals that would reach the branch, the walk agrees with eval on 49/49 — 0 differing values, 0 refusals. The deviation is therefore score-neutral on this data and changes behaviour only where eval would execute something.
  • Unbounded-expression refusal. Decoding runs inline on the event loop with no timeout, so f(x=9**9**9) would stall the whole run rather than one sample; the guard refuses instead.
  • The ast.Lambda branch was left byte-identical to upstream, with a TypeError-pinning test. ast.Lambda.body is a single expr, so upstream's second eval there is unreachable — value.body[0] raises first. Rewriting it would have been a change with no reachable effect.

Checklist

Required (all PRs)

  • PR title follows conventional format (type(scope): description)
  • No internal paths, credentials, or personal info in committed files
  • AI-generated code has AI-Generated Code - <model> (<provider>) in module docstring
  • No new upper-layer dependencies added to core/
  • Deleted code verified — no remaining call sites depend on it

If: New or Modified Benchmark

  • Reference paper/repo linked in Summary
  • Score comparison table included — exact on all 26 published cells, see Manual
  • Dataset loading tested (sieval dataset download <name> succeeds)
  • Task registered in package-level __init__.py

If: community/ Changes

  • Upstream diff documented (what differs and why)
  • License attribution preserved (Apache-2.0, recorded in each vendored module's docstring)

Upstream deviation, all of it recorded in the vendored docstrings:

  1. bfcl_eval.* imports rewritten to sieval.community.bfcl_v3.*.
  2. ast.BinOp arithmetic resolved by AST walk instead of eval (the RCE path above).
  3. An unbounded-exponent refusal in the same path.

Two further notes: the safety guard lives in _safe_eval.py, outside parser.py, so the deviation is auditable in one place; and the vendored ast.NameConstant usage emits a DeprecationWarning on every test run and will break on Python 3.14 — tracked in docs/TODO.md, not fixed here, because replacing it is a divergence from the pinned upstream file with no behavioural effect today.

If: Breaking Change

  • Not a breaking change.

If: New Dependency

  • Added to correct PDM dependency group
  • Justified in Summary

New extra bfcl-v3: tree_sitter==0.21.3, tree-sitter-java==0.21.0, tree-sitter-javascript==0.21.4. Reached only by the Prompt protocol on the java and javascript categories — upstream's ast_parse dispatches those two to tree-sitter rather than Python's ast. The FC protocol never parses source text, and every Live category is Python. Versions are upstream's own, and the tree_sitter pin is hard rather than a floor because 0.21.3 predates the 0.22 API break (the vendored parsers call Language(ptr, name) and Parser.set_language(), both removed later); it has no wheel for Python 3.13 and builds from sdist. CI installs the group so that decode path is actually exercised — the cost is one small C extension per run.

🤖 Generated with Claude Code

ethan-scitix and others added 28 commits September 11, 2026 23:44
`function_tools` is already a capability key, and passing `tools` projects
an intent for it, but `TaskRequirements` had no slot to demand it -- so a
task whose whole premise is the tools API could only fail at its first
request, after a result directory existed. Add the fourth member of the
existing capability-declaration set; both validation paths pick it up
unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Apache-2.0 upstream, so the grader is copied rather than reimplemented.
The only edit is rewriting `bfcl_eval.*` imports to relative ones; each
module's docstring records upstream's blob SHA and that deviation, and a
sha256 test pins the files as committed so a later edit cannot widen it
quietly.

`ast_checker` reads a `MODEL_CONFIG_MAPPING` global to answer one question
-- may a function name contain a dot on this transport. That is a property
of the protocol, not of a model, so sieval supplies the global instead of
carrying upstream's 2044-line model registry.

Co-Authored-By: Claude Opus 5 <[email protected]>
Review found that ast_checker.py's import-rewrite boilerplate had been
copy-pasted into type_mappings.py, java_type_converter.py, and
js_type_converter.py, where it doesn't hold: type_mappings.py has no
imports at all (and now says so without self-contradiction), and each
type_convertor file rewrites exactly one import at one level of relative
nesting (`..type_mappings`), not the three-import set ast_checker.py
actually has. The audit design in this task depends on these docstrings
being followed literally to re-derive the copy, so a wrong one is a real
risk, not a wording nit.

Also drops _tables.py's dangling reference to a nonexistent `_base.py`,
stating directly that LANGUAGE_BY_CATEGORY feeds ast_checker's type
conversion rather than the AST parser's language.

No vendored code changed -- only the three docstrings and one comment.
sha256 hashes for type_mappings.py, java_type_converter.py, and
js_type_converter.py are updated in the same commit since the docstring
is part of the pinned file.

Co-Authored-By: Claude Opus 5 <[email protected]>
`ast_parse` dispatches the java and javascript categories to tree-sitter
parsers rather than Python's `ast`, and the first vendoring pass extracted
`ast_parse` without the two imports that reach them -- leaving 150 of the
1390 non-live samples raising NameError at decode time on the prompt
protocol. Both files are copied verbatim; neither needed an import rewrite.

The three tree-sitter pins are upstream's own. 0.21.3 predates the 0.22 API
break, so it is pinned rather than floored, and CI installs the group so the
path cannot rot unimported.

Co-Authored-By: Claude Opus 5 <[email protected]>
One pinned HF snapshot, two registered datasets split on the group boundary
upstream scores separately. Three categories ship no gold, because the correct
outcome there is about whether a call is produced, not which one.

Gold joins on the universal index -- the `<index>` of
`<category>_<index>[-<sub>-<sub>]` -- because that is upstream's key: its
runner sorts both files on it, discarding the sub-indices, then pairs them
positionally. One row depends on it. The prompt file's
`live_multiple_1052-79-0` is graded against the gold file's
`live_multiple_1052-279-0`; joining on the whole `id` would drop it and score
1052 of live_multiple's 1053 rows, publishing a column upstream never did.

The key's uniqueness is asserted on every file that is joined, which is what
makes an index join and upstream's positional join the same pairing -- and is
stricter than upstream, which only checks the two files are the same length.
It is not asserted on the goldless three, which are never joined:
`live_relevance` ships one row twice, 18 rows over 17 distinct ids, and
upstream scores all 18.

Per-category row counts are asserted on load. The revision pin already stops
a silent re-upload -- the assertion is what turns bumping the pin into a
visible failure instead of a quietly rescored column.

Co-Authored-By: Claude Opus 5 <[email protected]>
`TaskRequirements.function_tools` lets a task declare it needs the tools API,
but the reply's structured calls never reached the task: the IR `Response`
carries `tool_calls` and `response_to_model_output` rebuilt `ModelOutput`
field-by-field without them, so the declaration was unsatisfiable by
construction. No task read them, so nothing caught it.

Forward the tuple and default the field to None. Additive: every existing
construction site omits it and keeps its current behaviour.

Co-Authored-By: Claude Opus 5 <[email protected]>
…istry

`sieval/community/bfcl_v3/__init__.py` eagerly imported `.parser`, the one
module in that package reaching tree-sitter, which lives in the optional
`bfcl-v3` extra. Both BFCL v3 dataset modules import the package for two
pure-data tables, and `import_all_datasets()` imports every dataset submodule in
a bare loop with no per-module `except` -- so on any install without the extra,
`sieval dataset list` and `sieval dataset download <anything>` died with
`ModuleNotFoundError: tree_sitter`, for every dataset in the repo.

`ast_parse` now resolves through a module `__getattr__`; of the eight modules in
the package only `parser.py` reaches tree-sitter, so deferring it unblocks the
rest. Nothing at the call sites changes.

CI cannot see this: `INSTALL_GROUPS` carries `-G bfcl-v3` on purpose, to gate the
Java/JavaScript decode path. The regression test therefore runs a fresh
interpreter with `tree_sitter*` blocked at `sys.meta_path` -- an in-process check
would pass with the eager import restored.

Co-Authored-By: Claude Opus 5 <[email protected]>
Every test loaded exactly one category, so nothing in the file could see a bug
that only shows up across categories. Three reverse mutations of the loader left
all of them green: hoisting the per-category gold table above the loop (which
hands `irrelevance_0` the gold of `simple_0`, since both key on universal index
0), resetting the row accumulator inside the loop (1390 rows become 240), and
storing `function` raw instead of as the json string the sample TypedDict
declares. One test loading two categories at once, one of them goldless, now
kills all three.

The uniqueness guards were covered only jointly: the fixture repeated the index
in both files, so either `_index_rows` call satisfied the `pytest.raises` and
deleting either guard alone stayed green. Split into two tests, each repeating
the index in one file, each matching on the message's own file label.

The file also had no module docstring, so it could not carry the AI attribution
line the repo requires.

Co-Authored-By: Claude Opus 5 <[email protected]>
…mport

A module `__getattr__` types *every* attribute of the package as its own return
type, so after ba18ad51 `ast_parse` resolved to `object` and its four call sites
in `tests/unit/community/bfcl_v3/test_source_parsers.py` each read as
`call-non-callable`. An explicitly declared symbol takes precedence over the
fallback, so a `TYPE_CHECKING` import restores the real signature.

`TYPE_CHECKING` is False at runtime, so the deferral is untouched: the
out-of-process regression test, which imports the whole dataset registry with
`tree_sitter*` blocked at `sys.meta_path`, still passes. This is
`typing.TYPE_CHECKING`, not `from __future__ import annotations`.

Co-Authored-By: Claude Opus 5 <[email protected]>
…ollide

The loader claimed to be strictly stricter than upstream, which asserts
`len(prompt) == len(possible_answer)`. It was not: the prompt count was checked
against the category table and the gold count was not checked at all, so a
re-upload that grew only the `possible_answer/` file passed every guard -- the
join is by index, so the extra rows are simply never looked up. The gold count
is now asserted against the same table, for the joined categories only (the
goldless three have no gold file).

`_index_rows` reported `len(rows) - len(by_index)` -- excess rows, not colliding
keys, so three rows on one index read as "2 rows sharing an index" -- and named
neither the index nor a single id, while the gold-miss guard 35 lines below
prints three example ids. It now reports the colliding indices with the ids that
collide on each.

`_universal_index` raised a bare `ValueError: invalid literal for int()` on an id
like `simple_0a`, with no category, row or file in it, on exactly the scenario
the rest of this module's messages are written for: a pin bump that moves the id
shape while the counts hold. It raises the same named shape as its neighbours
now.

Also marks the ignored `**kwargs` on both `load` overrides deliberate, as
`spider.py` does.

Co-Authored-By: Claude Opus 5 <[email protected]>
One generic base carries preprocess/infer/postprocess/feedback; two axis
classes differ only in how schemas reach the model and how calls are read
back, which is exactly what upstream's (FC) and (Prompt) columns differ in.

The `underscore_to_dot` flag travels as a call argument rather than a module
global, because grading runs in a worker process -- a global set in the
parent never arrives, and getting it wrong grades an FC run under the Prompt
convention and yields a plausible score instead of an error.

Co-Authored-By: Claude Opus 5 <[email protected]>
The comment said the extra is reached "only by the Prompt protocol on the
`java` and `javascript` categories". The first half is right; the second is
not. `parser.py` imports both source parsers at module scope and each imports
`tree_sitter` at its own module scope, with no guard, so touching `ast_parse`
at all needs the extra -- a Python row included.

Caught by blocking `tree_sitter` with a `find_spec` meta-path finder: importing
the task package still succeeds, but calling `_decode` on a Python row raises
`ModuleNotFoundError` exactly as a JavaScript row does.

Comment only. No dependency changed.

Co-Authored-By: Claude Opus 5 <[email protected]>
…g it

A reverse mutation replacing the `gold_json is None` raise with `return False`
left all 18 tests passing, and no preflight check covers it either -- the two
adjacent ones, `check_record_key_access` and `check_reference_kind`, both pass
under that mutation. The guarantee is a rule of its own (a value-reference task
that finds no gold raises), so it is worth a test of its own: scored `False`, a
dataset fault is charged to the model, and under `DENOMINATOR_REQUESTED` the
headline cannot tell the two apart.

Co-Authored-By: Claude Opus 5 <[email protected]>
Non-live is an unweighted mean of five, with `simple_ast` nested inside it;
live is a sample-count-weighted mean of six. Both go through the vendored
aggregation functions rather than being re-derived here.

The split also decides the intervals. A weighted mean is algebraically the
pooled rate over its rows, so live's two rollups are genuine per-sample
rates and carry one. An unweighted mean of rates is not -- javascript's 50
rows weigh the same as simple's 400 -- so non-live's three publish none
rather than bracketing a statistic other than the one printed beside them.

Per-sample values are 0/1, not 0/100: the estimators read
`sum(values) / denominator` as a probability, so percentage-point values
make every rate above one point read as a saturated set and publish an
interval bracketing 100. The rate is scaled to percentage points once, in
`cell`, so the published number and its bounds share their units.

Co-Authored-By: Claude Opus 5 <[email protected]>
Two groups upstream scores separately, each under both published protocols.
The unqualified name is the prompting protocol: every model on the
leaderboard has a Prompt row, while FC exists only where the provider
exposes a tools API -- and FC gates on a capability, so giving it the
default name would make the default the narrow one.

Every stage lives in the already-shared `_base.py`, so each leaf is its
metadata plus a base pair: a protocol mixin and a group base. The family
test asserts both halves separately, because they fail differently -- a
leaf on the wrong group base still binds the right dataset and still
demands the right capability, and would publish an unweighted mean of
category rates over live rows under a live-sounding score key.

The FC leaves' capability refusal is `Task.__init__`'s, reached at
prelaunch reconciliation before a request is made; static schema
validation does not import tasks and so does not see it. Asserted rather
than described, since no implemented chat dialect denies the capability
today and the claim would otherwise be unfalsifiable.

All four ship experimental: the licence does not force it, but nothing has
been aligned against a published BFCL number yet.

Co-Authored-By: Claude Opus 5 <[email protected]>
…eaves

The Prompt protocol decodes replies with `ast_parse`, whose module imports
both tree-sitter source parsers at module scope. The import therefore runs
before the per-language dispatch does, so the extra is needed for every
category rather than just `java` and `javascript` -- including the live
group, whose categories are all Python. The FC leaves parse no source text
and need nothing, so they keep `deps_group=None`.

Without this, the readiness probe never inspects the task-deps axis
(`extras_unsatisfied` is only consulted when a `deps_group` is declared) and
reports `yes` on a base install. Because the import is deferred into
`_decode`, the run then bills for inference before every sample dies at
postprocess.

`deps_group` is a frozen field on `TaskMeta`, so declaring it after these
tasks ship would be a schema-contract change rather than an edit.

The test asserts the mapping in both directions, since both faults are
silent in production: dropping it from a Prompt leaf buys the wasted run
above, and adding it to an FC leaf leaves that leaf reporting NOT ready --
unrunnable over a dependency it never loads.

Co-Authored-By: Claude Opus 5 <[email protected]>
Replaying each row's own possible_answer must grade correct, which separates
"is the port wired up right" from "is the model good" -- a question no model
run can answer. 2344 of 2351 Python rows do.

The replay reads the function schema, not just gold: a `""` among a
parameter's accepted values licenses omitting it, but only where the schema
does not mark it required, and gold sometimes offers a parameter the schema
never declares. Gold also nests alternatives through both dicts and lists.

The 7 residuals are all upstream gold contradicting upstream's own schema and
are asserted BY NAME, so a snapshot that fixes one fails here rather than
passing quietly:

  simple_363             gold calls it find_closest, the schema calls it
                         restaurant_search.find_closest
  live_simple_106-63-0   gold offers "" (omit) for a required parameter
  live_simple_112-68-0   same
  live_multiple_507-149-4  same
  live_multiple_964-207-0  same
  live_multiple_862-181-3  gold names a parameter the schema never declares
  live_multiple_1038-265-0 an optional parameter with no default, which the
                         checker then demands

Java and JavaScript are excluded: the checker requires their arguments as raw
source text for the type converters to parse, while gold holds native values,
so a replay would have to serialize Java and JS inside a test.

Co-Authored-By: Claude Opus 5 <[email protected]>
_fc earns a vocabulary row because it is a different axis from _parse: it
changes which model capability is exercised, not how output is extracted,
so it changes which models can run the task at all.

Co-Authored-By: Claude Opus 5 <[email protected]>
…not lines

The relative-import rule reached into `sieval/community/`, which pre-commit
has always excluded. That was inert while every vendored relative import was
a bare `from . import x`; a drop using `from ..x import y` made it bite, and
the only fix the checker offered was to edit files kept byte-identical to
upstream.

The exemption is scoped to that one rule, inside `_check_relative_scope` --
not to the file, and not to the wrapper's file list. Both of those also
disable the private-access and layer rules over all of community/, which
pre-commit does not check at all and which we want to keep.

Separately, the preflight wrapper reported `len(stderr lines)` as the
violation count. Vendored files emit SyntaxWarning text on every run, so two
real violations were reported as twenty-six.

Co-Authored-By: Claude Opus 5 <[email protected]>
Both comments added with the exemption claimed it preserves "the layer,
sub-package and private-module rules" over `sieval/community/`. Only the
third of those binds there: `FORBIDDEN` and `FORBIDDEN_SUBPACKAGE` have no
`community` entry, so a vendored file may import across layers freely and
always could.

The decision the comments defend is unchanged and still right -- hoisting
the exemption into `_check_file` or the wrapper would drop private-module
protection over a tree pre-commit does not check at all. But a reader
weighing a wider exemption against three rules would be weighing it against
two that cost nothing, and would reasonably conclude the guard is
over-engineered. Name the one rule instead.

Co-Authored-By: Claude Opus 5 <[email protected]>
…protocols

Upstream runs every function schema through
`func_doc_language_specific_pre_processing` before it reaches the model, on
both the prompting and the native-function-calling path, and merges the schema
block into the row's own system turn via
`system_prompt_pre_processing_chat_model`. The port did neither, so what the
model saw diverged from what upstream sends on 3637 of 3641 rows -- the four
exceptions being live_irrelevance rows carrying an empty schema list, where
both renderings are the string `[]`.

Two concrete divergences:

* the 150 Java and JavaScript rows reached the model with their real parameter
  types (`array`, `integer`, `object`, ...) and no language hint, where
  upstream restates every one as `string` and appends "Note that the provided
  function is in Java 8 SDK syntax." The model was being asked for Java source
  against a schema that never said so.
* the 92 live rows that open with their own system turn received a second
  prepended one. Two system turns is a shape every provider accepts and
  answers, so the run completed and only the score was wrong.

Grading is deliberately untouched. Upstream's `ast_file_runner` reads
`function` back out of the dataset and hands it to `ast_checker` unprocessed,
so `preprocess` still stores the raw schema for `feedback` -- verified
byte-identical on all 3641 rows under both protocols, and the gold-replay
anchor still grades 2351/2351.

The two vendored helpers rewrite their arguments in place, so the schema list
is re-parsed per call and the question turns are copied; without the copy all
92 system-turn rows corrupt the stored sample on the second pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
…output

`resolve_ast_by_type`'s `ast.BinOp` branch resolved a decoded call's keyword
argument with `eval(ast.unparse(value))`. That node is the model's reply, so a
reply carrying `f(x=__import__('os').system('...') + 0)` parses to a `BinOp`,
the decoder runs the payload, and it returns an ordinary-looking number. The
sample grades, the score is plausible, and nothing in the run looks unusual
afterwards. Established by side effect rather than by inspection: a probe
payload wrote a marker file, and the file appeared.

`_safe_eval.safe_eval` walks the node instead of unparsing it, so there is no
string and no namespace to escape from, and it admits only literals, their
containers, and the arithmetic/bitwise operators. Every expression upstream's
`eval` computes *without* executing something is computed here to the same
value: 19/19 benign expressions agree, and the 18 fidelity cases in the suite
pass under upstream's own `eval` too, so they encode upstream's values rather
than the port's.

It also refuses two shapes that do not execute. An f-string, where
`ast.literal_eval` draws the same line. And an expression whose cost is
unbounded -- decoding runs inline on the session's event loop with no timeout
around it (the grade timeout is in the `feedback` worker, a stage later), so
`f(x=9**9**9)` would not return a wrong answer, it would stall every other
sample in the run. Restoring upstream's `eval` makes that case run past a 40 s
kill; the guarded version returns in under 0.1 s.

Evidence no bound binds on the pinned data: all 2351 gradeable Python rows
decode, 0 `BinOp` nodes reach the branch at all, and the largest integer
literal across the gold answers is 13 digits against a 2**20 cap. Reverse
mutation confirms the tests discriminate -- restoring upstream's `eval` fails
exactly the 10 safety tests and leaves every fidelity test passing.

The `ast.Lambda` branch keeps upstream's second `eval` byte-identical, because
it cannot reach it: `Lambda.body` is a single expression node, so
`value.body[0]` raises `TypeError` while the argument is still being built.
Hardening a line that cannot execute would be a deviation bought for nothing.
A test pins the `TypeError` and so fails if someone later "repairs" the
subscript, which would install a live `eval` over model output.

The guard is held outside `parser.py` because that file is vendored: inlining
it would enlarge a diff that exists to be compared against upstream, and would
put a security boundary inside a file no linter runs over. `community/`'s
CLAUDE.md records the hand-lint command, since the package-wide exclusions
cover it.

Separately, `preprocess.py` had been vendored without being added to the
identity map, so a vendored file went unprotected by the drift test for a
commit. Added, along with a test that each file's recorded provenance is
resolvable, and a note that `Upstream path:` is relative to
`berkeley-function-call-leaderboard/` rather than the repo root. All 12 blob
SHAs re-verified against gorilla@ea13468e, 0 mismatches.

Co-Authored-By: Claude Opus 5 <[email protected]>
…onment

`_decode` defers its import of the vendored parser, because tree-sitter lives
behind the optional `bfcl-v3` extra and importing it eagerly makes that extra a
hard requirement. The deferred import therefore raises from inside the `try`
whose `except Exception` exists to turn an undecodable reply into a score. An
environment without the extra was being graded, not reported: every row of the
run scores wrong, `fails` stays 0, and the result is indistinguishable from a
model that cannot call a function. `ImportError` now propagates, which costs
the run and names the cause.

Two tests pin the split -- one that an undecodable reply is still scored rather
than raised, and one that a missing parser dependency propagates.

Separately, two docstrings justified `underscore_to_dot` being a call argument
with "grading runs in a worker process, so a global set in the parent never
arrives." That is false: `run_cpu_bound` falls back to running inline on the
event loop whenever no worker pool is available, so the global would arrive
fine. The argument-passing is still right, but for a different reason --
`grade_single_turn` writes the global the vendored checker reads and consumes
it with no `await` in between, which is what makes the pair atomic. A caller
that set the global and then awaited would let another sample's grade land
between the set and the use. A reader who checked the stated reason would find
it wrong and could reasonably conclude the constraint was imaginary, so the
justification is corrected to the one that holds.

Co-Authored-By: Claude Opus 5 <[email protected]>
…grader

`BFCL_V3_SHARED_NOTES` said the vendored parsers were "vendored verbatim ...
only `bfcl_eval.*` imports were rewritten." That stopped being true one commit
ago, when `parser.py`'s `ast.BinOp` branch was changed to walk the argument's
AST instead of `eval`-ing it. `parser.py`'s own docstring records the deviation;
the notes did not, and the notes are the copy that ships -- they reach all four
tasks' `reference_impl` and are embedded verbatim in `sieval/meta/index.json`,
so an archived run's `meta.json` asserted an unmodified grading path.

That claim is exactly the one a reader consults before arguing a divergence, so
a false "verbatim" is worse than no note at all: it answers the question wrongly
instead of prompting a look. The text now names the deviation, what it computes
instead, and that the FC protocol never reaches it -- FC parses no source text,
so two of the four tasks carry the note for a path they do not execute.

`meta/index.json` regenerated; the delta is the four BFCL entries and nothing
else.

Co-Authored-By: Claude Opus 5 <[email protected]>
…ng as a path

The shared notes named the four skipped groups as `exec_*/rest/sql/chatable`,
which carries a `/rest/sql` substring. `scripts/sanitize.sh`'s config scan flags
any absolute-looking path whose first segment is not a system root, and the `*`
before the first slash clears its lookbehind, so the regex matched -- on all
four BFCL entries of `sieval/meta/index.json`, which embeds these notes
verbatim. CI's sanitize job failed on those four lines.

Reworded to a comma list instead of adding an ALLOWLIST entry: the pattern is a
security gate, an exemption for it would have to be scoped by file, and the
four categories were never a path to begin with -- they are BFCL test-file
group names. `sieval/meta/index.json` regenerated; the delta is those four
`notes` strings and nothing else.

Co-Authored-By: Claude Opus 5 <[email protected]>
…bound string `%`

Two review findings, plus two doc nits.

`..type_mappings` in the two vendored type converters was the only thing that
needed `sieval/community/` exempted from the cross-package relative-import
rule. Upstream's own import was absolute (`bfcl_eval.constants.type_mappings`)
and a vendored copy cannot keep it, so that line was already rewritten --
spelling the replacement absolutely costs the same one line and satisfies the
rule as it stands. The exemption is reverted: preflight is the only surface
that checks `community/` at all, since pre-commit's global `exclude` skips it,
so a carve-out there would have taken private-module protection with it for
every future vendored drop. The mirror tests now pin the opposite decision.

`_safe_eval` screened `Pow`, `LShift` and `Mult` but not `Mod`, so `%`
formatting on a string was caught only by the post-hoc size check -- after the
allocation. `"%.400000000f" % 1.0` asks for a 400MB string from two tiny
operands, which is the class the guard exists to close; a precision field sets
the result size independently of both operands, so there is nothing to measure
and the shape is refused outright. Integer `%` is bounded by its right operand
and stays admissible. Not a regression -- upstream's `eval` has the same
exposure -- and still score-neutral, since no gold row reaches `safe_eval`.

Also: the dataset docstrings pinned the harness to the `v1.3` tag while
`_base.py` pinned the commit; both now carry the SHA. And `tree_sitter` is
spelled `tree-sitter` to match its two siblings, re-locked with
`--update-reuse` (content_hash only, no version drift).

Verified: 7575 unit tests pass, 25/25 preflight checks PASS, ruff and ty clean,
and the gold replay still walks 2351 rows failing only the 7 documented
upstream gold defects. The new `%` test is reverse-mutation checked -- its
22-char case, which a post-hoc size check admits, returns a value with the
guard deleted.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ed rollouts

BFCL publishes an evaluation archive beside its leaderboard
(HuanzhiMao/BFCL-Result): per model, per protocol, per category, both the
model's recorded replies AND upstream's own per-row verdicts. Replaying
gpt-4.1-2025-04-14's rollouts through our grader reproduces every published
cell exactly and agrees with upstream's verdict on every row:

  protocol  group      rows        agreement
  Prompt    non-live   1390        1390/1390
  Prompt    live       2251        2251/2251
  FC        non-live   1390        1390/1390
  FC        live       2251        2251/2251

All 26 per-category accuracies match to the digit, so the four leaves now
record their own published numbers the way the other reproduction tasks do,
and ship `stable`. The sentence they carried -- "experimental until an
alignment run against a published BFCL v3 number lands" -- was the only thing
blocking it, and it is now false.

This is a strictly stronger anchor than the gold replay, which feeds gold back
and so only ever exercises the correct path: it never sees a wrong answer, a
decode failure, or either protocol's decoder. The new acceptance test drives
the production `_decode` of both mixins over output that is 9% wrong. `java` at
64% Prompt / 61% FC is the point -- a third of those rows are wrong and we have
to agree about which third. It also pins the two rollups against their own
documented claims: live's weighted mean reconstructs the pooled rate over all
2251 rows, and non-live's mean-of-means is asserted NOT to equal the pooled
rate, so a "fix" toward pooling fails loudly.

Both mutations it is meant to catch were checked by reverse-mutation: flipping
FC's `UNDERSCORE_TO_DOT` (the silent cross-wiring `_base` warns about) and
forcing the Prompt decoder to language="Python" (so Java/JavaScript stop
reaching tree-sitter) each fail the test.

What the anchor does NOT measure is stated in both the test and the shipped
notes: the replies are upstream's, so nothing here proves our prompt is
upstream's. That rests on the two model-facing helpers being byte-identical to
upstream and running on a row-for-row verified snapshot -- a construction
argument, not a measurement.

The archive is ~6MB, so it is staged on demand rather than vendored, and the
test skips with a reason naming the layout. No file hashes are pinned: every
score file's header is asserted against the pinned counts, so a swapped archive
fails, and a doctored result file fails the comparison it feeds.

Verified: 7575 unit tests pass, 9 acceptance tests pass with the archive staged
and skip cleanly without it, 25/25 preflight checks PASS (meta/index.json
regenerated), ruff and ty clean.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…eady say

The port shipped 1751 lines of comment and docstring against 3689 of code, and
the bulk of the excess was one fact stated in several places rather than any
single essay. Net -140 lines over 18 files, with no fact dropped.

Give each fact one owner:

- The four leaves each said the same thing twice -- a module docstring and a
  `reference_impl.notes` restating the headline formula and the interval policy
  in different words. `notes` keeps them, because it is what reaches
  `meta/index.json` and so the only copy a reader without the source ever sees;
  the docstrings now say what the task is and stop.
- `underscore_to_dot` was explained four times (`_base` module docstring,
  `grade_single_turn`, both `_fc` leaves). `_base` owns the mechanism since the
  flag lives there; `grade_single_turn` owns the atomicity argument since it is
  that function's shape being justified, and it absorbs the 734-row number that
  quantifies the risk.
- Why the unqualified name goes to Prompt was argued in every leaf and again in
  `tasks/CLAUDE.md` §Variants, which is the naming vocabulary's actual home.
- The `source_parser/` rename was explained three times, at length.

Also drops a private reference: `tool_convert.py` cited "Step 3's whole-file
modules", a step number from an internal implementation plan that means nothing
in a public checkout.

Kept deliberately: every non-derivable fact, including the two that read as
padding and are not -- `_safe_eval`'s calibration evidence (13-digit largest
gold literal across 2351 rows) and the ImportError/`.get("prediction")`
rationales, both of which describe silent failures. Test docstrings are left
alone after sampling them: at 8-19 lines they are dense rather than padded, and
the longest states two production failure modes a reader cannot infer.

Prose-only, and checked rather than asserted: with docstrings stripped and string
constants normalized, every touched file's AST is identical to its previous
version. The only literals that move are one `notes` per leaf, the shared notes
block, and the six vendored digests whose docstrings were trimmed.

Verified: 7575 unit tests pass, 9 acceptance tests pass, 25/25 preflight checks
PASS (meta/index.json regenerated), ruff and ty clean.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@ethan-scitix
ethan-scitix merged commit d6c025c into main Sep 11, 2026
9 checks passed
@ethan-scitix
ethan-scitix deleted the worktree-bfcl-v3 branch September 11, 2026 19:18
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