Skip to content

Commit 3a06d12

Browse files
committed
feat: add IHEval (Instruction Hierarchy Evaluation) benchmark
IHEval is a benchmark group with 9 sub-benchmarks across 4 categories (rule-following, task-execution, safety, tool-use), each in aligned / conflict / reference settings. - dataset/iheval: benchmark group + HF-based prepare.py (downloads per-variant input_data.json from the zhihz0535/IHEval mirror; test.jsonl is gitignored), iheval_score.py group composite, and 9 sub-benchmark configs. - Scoring lives in the standalone pip package bzantium/iheval (Apache-2.0); evaluator/iheval.py is a thin lazy-import wrapper (bfcl-style). Pinned in the Dockerfile. - IHEvalMetrics uses the base pass@k machinery so iheval:N yields pass@1[avg-of-N], plus per-setting / per-variant breakdowns. - Registered iheval_* eval types and the iheval metrics key; documented under instruction-following. Signed-off-by: bzantium <ryumin93@gmail.com>
1 parent 98a3cf3 commit 3a06d12

20 files changed

Lines changed: 943 additions & 0 deletions

File tree

dockerfiles/Dockerfile.nemo-skills

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ RUN git clone https://github.com/ShishirPatil/gorilla.git /opt/gorilla
4545
RUN cd /opt/gorilla && git checkout 86d0374d0db52623c5092a73f82c22b87b7e9a25
4646
RUN cd /opt/gorilla/berkeley-function-call-leaderboard && pip install --no-cache-dir -e . --extra-index-url https://download.pytorch.org/whl/cpu
4747

48+
# iheval (Instruction Hierarchy Evaluation) scoring package
49+
ARG IHEVAL_COMMIT=4572a71b46abdd5ec3a61553ed2fa91af31827b5
50+
RUN pip install --no-cache-dir "iheval @ git+https://github.com/bzantium/iheval.git@${IHEVAL_COMMIT}"
51+
4852
RUN apt remove -y python3-blinker
4953

5054
# ifbench

docs/evaluation/instruction-following.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,15 @@ More details are coming soon!
1313

1414
- Benchmark is defined in [`nemo_skills/dataset/ifeval/__init__.py`](https://github.com/NVIDIA-NeMo/Skills/blob/main/nemo_skills/dataset/ifeval/__init__.py)
1515
- Original benchmark source is [here](https://github.com/google-research/google-research/tree/master/instruction_following_eval).
16+
17+
### iheval
18+
19+
IHEval (Instruction Hierarchy Evaluation) measures whether a model respects the
20+
system > user > tool instruction hierarchy. It is a **benchmark group** with 9
21+
sub-benchmarks across 4 categories (rule-following, task-execution, safety,
22+
tool-use), each in `aligned` / `conflict` / `reference` settings.
23+
24+
- Benchmark group is defined in [`nemo_skills/dataset/iheval/__init__.py`](https://github.com/NVIDIA-NeMo/Skills/blob/main/nemo_skills/dataset/iheval/__init__.py); run all sub-benchmarks with `--benchmarks iheval` (or a single one, e.g. `iheval.safety_hijack`).
25+
- Data is downloaded at prepare time from the [`zhihz0535/IHEval`](https://huggingface.co/datasets/zhihz0535/IHEval) HuggingFace mirror (not committed).
26+
- Rule-based scoring lives in the standalone [`bzantium/iheval`](https://github.com/bzantium/iheval) package — install with `pip install git+https://github.com/bzantium/iheval.git` (already baked into the nemo-skills Docker image).
27+
- Original benchmark source is [here](https://github.com/ytyz1307zzh/IHEval).
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# IHEval benchmark group: 9 sub-benchmarks across rule-following, task-execution, safety, tool-use.
16+
17+
SPLITS = [
18+
"rule_following_single",
19+
"rule_following_multi",
20+
"task_execution_verb_extract",
21+
"task_execution_translation",
22+
"task_execution_lang_detect",
23+
"safety_hijack",
24+
"safety_extract",
25+
"tool_use_webpage",
26+
"tool_use_slack_user",
27+
]
28+
29+
IS_BENCHMARK_GROUP = True
30+
31+
BENCHMARKS = {f"iheval.{split}": {} for split in SPLITS}
32+
33+
SCORE_MODULE = "nemo_skills.dataset.iheval.iheval_score"
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""IHEval group-level composite score (overall + per-category + per-setting + conflict gap)."""
16+
17+
_CATEGORIES: dict[str, list[str]] = {
18+
"rule_following": ["rule_following_single", "rule_following_multi"],
19+
"task_execution": [
20+
"task_execution_verb_extract",
21+
"task_execution_translation",
22+
"task_execution_lang_detect",
23+
],
24+
"safety": ["safety_hijack", "safety_extract"],
25+
"tool_use": ["tool_use_webpage", "tool_use_slack_user"],
26+
}
27+
28+
_ALL_SUBS: list[str] = [sub for subs in _CATEGORIES.values() for sub in subs]
29+
30+
_SETTINGS: tuple[str, ...] = ("aligned", "conflict", "reference")
31+
32+
33+
def _mean(values):
34+
valid = [v for v in values if v is not None]
35+
return sum(valid) / len(valid) if valid else 0.0
36+
37+
38+
def _composite(per_sub: dict) -> dict:
39+
"""Build the group composite for one aggregation mode from ``{sub: mode_dict}``."""
40+
per_category = {
41+
category: _mean([per_sub.get(sub, {}).get("symbolic_correct") for sub in subs])
42+
for category, subs in _CATEGORIES.items()
43+
}
44+
per_setting = {
45+
setting: _mean([p.get(f"setting_{setting}") for p in per_sub.values() if f"setting_{setting}" in p])
46+
for setting in _SETTINGS
47+
}
48+
return {
49+
"num_entries": sum(p.get("num_entries", 0) for p in per_sub.values()),
50+
"symbolic_correct": _mean([p.get("symbolic_correct") for p in per_sub.values()]),
51+
"aligned_avg": per_setting["aligned"],
52+
"conflict_avg": per_setting["conflict"],
53+
"reference_avg": per_setting["reference"],
54+
"conflict_gap": per_setting["reference"] - per_setting["conflict"],
55+
"rule_following_avg": per_category["rule_following"],
56+
"task_execution_avg": per_category["task_execution"],
57+
"safety_avg": per_category["safety"],
58+
"tool_use_avg": per_category["tool_use"],
59+
}
60+
61+
62+
def compute_score(metrics: dict) -> dict:
63+
"""Combine per-sub metrics into one ``iheval`` group result, per aggregation mode.
64+
65+
Handles ``iheval:N`` runs: a composite is produced for every mode present in the
66+
sub-benchmarks (``pass@1``, ``pass@1[avg-of-N]``, ``pass@N``, ...), so they all
67+
surface in the group ``metrics.json``.
68+
"""
69+
present = {sub: metrics[f"iheval.{sub}"] for sub in _ALL_SUBS if isinstance(metrics.get(f"iheval.{sub}"), dict)}
70+
71+
# Aggregation modes are the per-sub top-level keys (pass@1, pass@1[avg-of-k], pass@k, ...).
72+
modes = list(dict.fromkeys(mode for sub_metrics in present.values() for mode in sub_metrics)) or ["pass@1"]
73+
74+
result = {}
75+
for mode in modes:
76+
per_sub = {sub: sm[mode] for sub, sm in present.items() if isinstance(sm.get(mode), dict)}
77+
result[mode] = _composite(per_sub)
78+
return {"iheval": result}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
# Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Prepare IHEval splits from the zhihz0535/IHEval HF mirror.
16+
17+
Writes one test.jsonl per sub-benchmark; variants are discovered from the repo
18+
file listing. Data is downloaded at prepare time and is not committed.
19+
"""
20+
21+
import argparse
22+
import json
23+
from pathlib import Path
24+
25+
HF_REPO = "zhihz0535/IHEval"
26+
HF_ROOT = "iheval" # top-level dir inside the HF dataset repo
27+
28+
# split_name -> (category, task, task_id, category_id)
29+
# `category` and `task` are the path segments in the HF layout
30+
# iheval/<category>/<task>/<setting>/<variant>/; `category_id` and `task_id` are
31+
# the normalized labels written to each row's "category" / "task" fields.
32+
SUB_BENCHMARKS = {
33+
"rule_following_single": ("rule-following", "single-turn", "single", "rule_following"),
34+
"rule_following_multi": ("rule-following", "multi-turn", "multi", "rule_following"),
35+
"task_execution_verb_extract": ("task-execution", "verb-extract", "verb_extract", "task_execution"),
36+
"task_execution_translation": ("task-execution", "translation", "translation", "task_execution"),
37+
"task_execution_lang_detect": ("task-execution", "lang-detect", "lang_detect", "task_execution"),
38+
"safety_hijack": ("safety", "user-prompt-hijack", "hijack", "safety"),
39+
"safety_extract": ("safety", "system-prompt-extract", "extract", "safety"),
40+
"tool_use_webpage": ("tool-use", "get-webpage", "webpage", "tool_use"),
41+
"tool_use_slack_user": ("tool-use", "slack-user", "slack_user", "tool_use"),
42+
}
43+
44+
SETTINGS = ("aligned", "conflict", "reference")
45+
46+
47+
def list_variants(repo_files, category, task, setting):
48+
"""Return variant names that have an ``input_data.json`` for this (category, task, setting)."""
49+
prefix = f"{HF_ROOT}/{category}/{task}/{setting}/"
50+
variants = set()
51+
for f in repo_files:
52+
if f.startswith(prefix) and f.endswith("/input_data.json"):
53+
variant = f[len(prefix) : -len("/input_data.json")]
54+
if "/" not in variant: # exactly one level (the variant dir)
55+
variants.add(variant)
56+
return sorted(variants)
57+
58+
59+
def load_input_data(category, task, setting, variant):
60+
"""Download + parse one ``input_data.json`` from the HF dataset repo."""
61+
from huggingface_hub import hf_hub_download
62+
63+
rel = f"{HF_ROOT}/{category}/{task}/{setting}/{variant}/input_data.json"
64+
local = hf_hub_download(repo_id=HF_REPO, filename=rel, repo_type="dataset")
65+
with open(local, "rt", encoding="utf-8") as fin:
66+
return json.load(fin)
67+
68+
69+
def build_messages(row, category_id, task_id, setting):
70+
"""Build an OpenAI-style ``messages`` list from one upstream row."""
71+
system = row.get("system")
72+
instruction = row.get("instruction", "")
73+
74+
# Multi-turn rule-following with a real dialog history.
75+
conv_hist = row.get("conversation_history")
76+
if category_id == "rule_following" and task_id == "multi" and isinstance(conv_hist, list) and len(conv_hist) >= 2:
77+
messages = []
78+
if system:
79+
messages.append({"role": "system", "content": system})
80+
# conversation_history is [user_turn_1, assistant_turn_1] (possibly more pairs).
81+
pairs = list(zip(conv_hist[0::2], conv_hist[1::2], strict=True))
82+
for user_msg, assistant_msg in pairs:
83+
messages.append({"role": "user", "content": user_msg})
84+
messages.append({"role": "assistant", "content": assistant_msg})
85+
messages.append({"role": "user", "content": instruction})
86+
return messages
87+
88+
# Tool-use with an upstream-supplied tool call/return.
89+
tool = row.get("tool")
90+
if category_id == "tool_use" and isinstance(tool, dict) and tool:
91+
messages = []
92+
if system:
93+
messages.append({"role": "system", "content": system})
94+
messages.append({"role": "user", "content": instruction})
95+
96+
call = tool.get("call") or {}
97+
ret = tool.get("return") or {}
98+
call_id = call.get("id") or ret.get("id") or "call_0"
99+
call_name = call.get("name") or (tool.get("definition") or {}).get("name") or ""
100+
call_args = call.get("arguments", {})
101+
102+
messages.append(
103+
{
104+
"role": "assistant",
105+
"content": None,
106+
"tool_calls": [
107+
{
108+
"id": call_id,
109+
"type": "function",
110+
"function": {"name": call_name, "arguments": json.dumps(call_args, ensure_ascii=False)},
111+
}
112+
],
113+
}
114+
)
115+
messages.append(
116+
{"role": "tool", "tool_call_id": call_id, "name": call_name, "content": ret.get("content", "")}
117+
)
118+
return messages
119+
120+
# Reference setting with no system prompt — single user message.
121+
if setting == "reference" or system in (None, ""):
122+
return [{"role": "user", "content": instruction}]
123+
124+
return [
125+
{"role": "system", "content": system},
126+
{"role": "user", "content": instruction},
127+
]
128+
129+
130+
def _last_user_text(messages):
131+
for msg in reversed(messages):
132+
if msg.get("role") == "user":
133+
content = msg.get("content")
134+
if isinstance(content, str):
135+
return content
136+
return ""
137+
138+
139+
def process_sub_benchmark(out_root, repo_files, split_name, spec):
140+
category, task, task_id, category_id = spec
141+
out_dir = out_root / split_name
142+
out_dir.mkdir(parents=True, exist_ok=True)
143+
output_file = out_dir / "test.jsonl"
144+
145+
rows_written = 0
146+
with output_file.open("w", encoding="utf-8") as fout:
147+
for setting in SETTINGS:
148+
for variant in list_variants(repo_files, category, task, setting):
149+
data = load_input_data(category, task, setting, variant)
150+
for i, row in enumerate(data):
151+
messages = build_messages(row, category_id, task_id, setting)
152+
answer = row.get("answer")
153+
upstream_id = row.get("id", i)
154+
record = {
155+
"id": f"iheval-{task_id}-{setting}-{variant}-{upstream_id}",
156+
"messages": messages,
157+
"question": _last_user_text(messages),
158+
"setting": setting,
159+
"variant": variant,
160+
"category": category_id,
161+
"task": task_id,
162+
"answer": answer,
163+
"expected_answer": answer,
164+
}
165+
fout.write(json.dumps(record, ensure_ascii=False) + "\n")
166+
rows_written += 1
167+
168+
return output_file, rows_written
169+
170+
171+
def main(args):
172+
from huggingface_hub import HfApi
173+
174+
out_root = Path(__file__).absolute().parent
175+
repo_files = HfApi().list_repo_files(HF_REPO, repo_type="dataset")
176+
177+
selected = dict(SUB_BENCHMARKS)
178+
if args.only:
179+
wanted = set(args.only)
180+
missing = wanted.difference(SUB_BENCHMARKS)
181+
if missing:
182+
raise SystemExit(f"Unknown sub-benchmarks: {sorted(missing)}")
183+
selected = {name: SUB_BENCHMARKS[name] for name in wanted}
184+
185+
for split_name, spec in selected.items():
186+
output_file, n = process_sub_benchmark(out_root, repo_files, split_name, spec)
187+
print(f"[{split_name}] wrote {n} rows -> {output_file}")
188+
189+
190+
if __name__ == "__main__":
191+
parser = argparse.ArgumentParser(description="Prepare IHEval test splits from the HuggingFace mirror.")
192+
parser.add_argument("--split", default="test", choices=("test",), help="Local split name.")
193+
parser.add_argument(
194+
"--only", nargs="*", default=None, help="Restrict to these sub-benchmark output dirs (e.g. safety_hijack)."
195+
)
196+
args = parser.parse_args()
197+
main(args)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# settings that define how evaluation should be done by default (all can be changed from cmdline)
16+
17+
METRICS_TYPE = "iheval"
18+
GENERATION_ARGS = "++prompt_format=openai"
19+
EVAL_ARGS = "++eval_type=iheval_rule_following"
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# settings that define how evaluation should be done by default (all can be changed from cmdline)
16+
17+
METRICS_TYPE = "iheval"
18+
GENERATION_ARGS = "++prompt_format=openai"
19+
EVAL_ARGS = "++eval_type=iheval_rule_following"
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# settings that define how evaluation should be done by default (all can be changed from cmdline)
16+
17+
METRICS_TYPE = "iheval"
18+
GENERATION_ARGS = "++prompt_format=openai"
19+
EVAL_ARGS = "++eval_type=iheval_safety"

0 commit comments

Comments
 (0)