Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions benchmarks/hf_qa/hf_qa_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ def build_trace_problem(
num_test: Optional[int] = None,
initial_instructions: str = _DEFAULT_INSTRUCTIONS,
framework: str = "trace",
num_eval_repeats: int = 1,
**_kwargs: Any,
) -> Dict[str, Any]:
"""Build a Trace-Bench task bundle for an HF QA task.
Expand Down Expand Up @@ -271,14 +272,30 @@ def build_trace_problem(
train_split = cfg.get("train_split", fallback)
test_split = cfg.get("test_split", train_split)

train_val_tasks = _load_hf_data(cfg, train_split, num_train + num_validate)
train_tasks = train_val_tasks[:num_train]
val_tasks = train_val_tasks[num_train:]
test_tasks = _load_hf_data(cfg, test_split, num_test) if num_test > 0 else []
if train_split == test_split:
# Single-split mode (matches the reference hotpotqa_eval.py): load
# max(num_train, num_validate, num_test) examples once and let all
# three datasets take the *first N* — overlapping by design so val
# and test scores are directly comparable on identical samples.
n_load = max(num_train, num_validate, num_test)
all_tasks = _load_hf_data(cfg, train_split, n_load)
train_tasks = all_tasks[:num_train]
val_tasks = all_tasks[:num_validate]
test_tasks = all_tasks[:num_test] if num_test > 0 else []

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, num_train, num_validate, num_test all overlap each other? I see.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest this generic fix:

diff --git a/benchmarks/hf_qa/hf_qa_loader.py b/benchmarks/hf_qa/hf_qa_loader.py
index be2f57e..d8f6f0a 100644
--- a/benchmarks/hf_qa/hf_qa_loader.py
+++ b/benchmarks/hf_qa/hf_qa_loader.py
@@ -41,7 +41,7 @@ from __future__ import annotations
 
 from dataclasses import dataclass
 from pathlib import Path
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, List, Optional, Tuple
 
 import yaml
 
@@ -186,6 +186,52 @@ def _load_hf_data(cfg: Dict[str, Any], split: str, n: int) -> List[HFQATask]:
     return tasks
 
 
+def _resolve_count(
+    name: str,
+    override: Optional[int],
+    cfg: Dict[str, Any],
+    default: int,
+) -> int:
+    """Resolve a dataset partition size and validate it."""
+    value = override if override is not None else cfg.get(name, default)
+    if not isinstance(value, int) or isinstance(value, bool):
+        raise TypeError(f"{name} must be a non-negative integer, got {value!r}")
+    if value < 0:
+        raise ValueError(f"{name} must be non-negative, got {value}")
+    return value
+
+
+def _load_hf_partitions(
+    cfg: Dict[str, Any],
+    specs: List[Tuple[str, str, int]],
+) -> Dict[str, List[HFQATask]]:
+    """Load named HF dataset partitions without index overlap.
+
+    ``specs`` is a list of ``(partition_name, hf_split, count)`` tuples.
+
+    Partitions that use the same HF split are allocated contiguous, disjoint
+    slices in the order they appear in ``specs``.  Partitions that use different
+    HF splits each start from the beginning of their own split.
+
+    Example:
+        [
+            ("train", "train", 100),
+            ("validate", "train", 100),
+            ("test", "train", 100),
+        ]
+
+    loads 300 examples from HF split ``train`` once, then slices them as
+    ``[0:100]``, ``[100:200]``, and ``[200:300]``.
+    """
+    partitions: Dict[str, List[HFQATask]] = {name: [] for name, _, _ in specs}
+    by_split: Dict[str, List[Tuple[str, int]]] = {}
+
+    for name, split, count in specs:
+        if not isinstance(count, int) or isinstance(count, bool):
+            raise TypeError(f"Partition {name!r} count must be an integer, got {count!r}")
+        if count < 0:
+            raise ValueError(f"Partition {name!r} count must be non-negative, got {count}")
+        by_split.setdefault(split, []).append((name, count))
+
+    for split, split_specs in by_split.items():
+        total = sum(count for _, count in split_specs)
+        loaded = _load_hf_data(cfg, split, total) if total > 0 else []
+        start = 0
+        for name, count in split_specs:
+            partitions[name] = loaded[start:start + count]
+            start += count
+
+    return partitions
+
+
 # ---------------------------------------------------------------------------
 # Internal helpers
 # ---------------------------------------------------------------------------
@@ -249,29 +295,43 @@ def build_trace_problem(
     cfg = _load_task_config(task_id)
 
     # Resolve dataset sizes: eval_kwargs > hf_tasks.yaml > hardcoded fallback.
-    num_train    = num_train    if num_train    is not None else cfg.get("num_train",    10)
-    num_validate = num_validate if num_validate is not None else cfg.get("num_validate",  0)
-    num_test     = num_test     if num_test     is not None else cfg.get("num_test",      0)
+    num_train = _resolve_count("num_train", num_train, cfg, 10)
+    num_validate = _resolve_count("num_validate", num_validate, cfg, 0)
+    num_test = _resolve_count("num_test", num_test, cfg, 0)
 
     if subtask:
         # BBEH-style multi-split tasks: every subset (train/val/test) comes from
         # the same split, which is the subtask name (e.g. "boolean_expressions").
         split = subtask
-        total = num_train + num_validate + num_test
-        all_tasks = _load_hf_data(cfg, split, total)
-        train_tasks = all_tasks[:num_train]
-        val_tasks   = all_tasks[num_train:num_train + num_validate]
-        test_tasks  = all_tasks[num_train + num_validate:]
+        train_split_name = split
+        validate_split_name = split
+        test_split_name = split
+        partitions = _load_hf_partitions(
+            cfg,
+            [
+                ("train", split, num_train),
+                ("validate", split, num_validate),
+                ("test", split, num_test),
+            ],
+        )
     else:
         # Tasks with explicit train/test HF splits (e.g. HotpotQA):
         #   train + val  → loaded from `train_split`  (default: "train")
         #   test         → loaded from `test_split`   (default: same as train_split)
+        # If train/val/test share the same HF split, they are disjoint
+        # contiguous slices, not independent "first n examples" loads.
         # Falls back to the legacy `split` field if neither is set.
         fallback = cfg.get("split", "train")
         train_split = cfg.get("train_split", fallback)
         test_split  = cfg.get("test_split",  train_split)
+        train_split_name = train_split
+        validate_split_name = train_split
+        test_split_name = test_split
+        partitions = _load_hf_partitions(
+            cfg,
+            [
+                ("train", train_split, num_train),
+                ("validate", train_split, num_validate),
+                ("test", test_split, num_test),
+            ],
+        )
 
-        train_val_tasks = _load_hf_data(cfg, train_split, num_train + num_validate)
-        train_tasks = train_val_tasks[:num_train]
-        val_tasks   = train_val_tasks[num_train:]
-        test_tasks  = _load_hf_data(cfg, test_split, num_test) if num_test > 0 else []
+    train_tasks = partitions["train"]
+    val_tasks = partitions["validate"]
+    test_tasks = partitions["test"]
 
     objective = cfg.get("objective", "Optimize the agent's instructions for this QA task.")
     agent_class = cfg.get("agent_class", "hfqa")
@@ -307,6 +367,9 @@ def build_trace_problem(
             num_train=num_train,
             num_validate=num_validate,
             num_test=num_test,
+            train_split=train_split_name,
+            validate_split=validate_split_name,
+            test_split=test_split_name,
             framework=framework,
         ),
     )
diff --git a/benchmarks/hf_qa/hf_tasks.yaml b/benchmarks/hf_qa/hf_tasks.yaml
index e8c549e..2d5fae1 100644
--- a/benchmarks/hf_qa/hf_tasks.yaml
+++ b/benchmarks/hf_qa/hf_tasks.yaml
@@ -22,8 +22,9 @@
 # Optional fields:
 #   dataset_config  : HuggingFace config name (e.g. "distractor", "plain_text")
 #   train_split     : HF split used for train + val examples (default: "train")
-#   test_split      : HF split used for test examples (default: same as train_split)
-#   split           : fallback split when train_split/test_split are not set
+#   test_split      : HF split used for test examples (default: same as train_split;
+#                     when the same split is reused, partitions are disjoint slices)
+#   split           : fallback split when train_split/test_split are not set
 #   description     : short human-readable description
 #   objective       : optimizer objective injected into optimizer_kwargs

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@allenanie @xuanfeiren it would be cool if we can fix this then merge => once merged, I would like to import some more HF QA datasets

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait what exactly is this fixing?

For POLCA setup, we actually need to have train/val/test being all the same with each other!

So I guess the hf_tasks need to have the flexibility to do both (separate train/valid/test and overlapping train/valid/test).

@doxav doxav Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OMG :-), looks strange, I should look how POLCA works.

I checked latest main, and the current default HF loader does not make train/val/test identical: train and val are sequential slices, and test is loaded from test_split; for HF QA, main uses train_split: train and test_split: validation.

So I think the right fix is to support both modes explicitly:

default / benchmark mode: keep the current main behavior, with disjoint train/val and separate test split;
POLCA mode: opt into same_examples / shared_split, where train/val/test are intentionally the same examples.

We should add a config flag for this instead of hardcoding the POLCA behavior globally ? That should satisfy POLCA while avoiding surprising data leakage for normal HF QA benchmark runs.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah -- I think that's probably the best solution!

else:
# Disjoint train/val + separate test split (legacy behavior).
train_val_tasks = _load_hf_data(cfg, train_split, num_train + num_validate)
train_tasks = train_val_tasks[:num_train]
val_tasks = train_val_tasks[num_train:]
test_tasks = _load_hf_data(cfg, test_split, num_test) if num_test > 0 else []

objective = cfg.get("objective", "Optimize the agent's instructions for this QA task.")
agent_class = cfg.get("agent_class", "hfqa")

# Allow hf_tasks.yaml to override the default initial instructions per-task.
if initial_instructions == _DEFAULT_INSTRUCTIONS:
initial_instructions = cfg.get("initial_instructions", _DEFAULT_INSTRUCTIONS)

agent_kwargs: Dict[str, Any] = dict(
initial_instructions=initial_instructions,
node_description=cfg.get(
Expand Down Expand Up @@ -311,4 +328,6 @@ def build_trace_problem(
bundle["validate_dataset"] = _make_dataset(val_tasks, agent_class)
if test_tasks:
bundle["test_dataset"] = _make_dataset(test_tasks, agent_class)
# Number of times to evaluate each example during dataset scoring (final eval only).
bundle["num_eval_repeats"] = max(1, int(num_eval_repeats))
return bundle
6 changes: 3 additions & 3 deletions benchmarks/hf_qa/hf_tasks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@
# objective : optimizer objective injected into optimizer_kwargs

- id: hotpot_qa
dataset_id: hotpot_qa
dataset_id: hotpotqa/hotpot_qa
dataset_config: distractor
train_split: train # train + validation examples come from the HF train split
test_split: validation # test examples come from the HF validation split
train_split: validation # train/val/test all from HF validation split (matches local hotpotqa_eval.py)
test_split: validation
num_train: 100
num_validate: 100
num_test: 100
Expand Down
18 changes: 17 additions & 1 deletion benchmarks/hf_qa/hotpot_qa.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,23 @@ class HotpotQAGuide(Guide):
"""

def get_feedback(self, task: Any, response: Any, info: Any, **kwargs: Any):
response_str = str(getattr(response, "data", response))
# Extract clean text. Trace LLM backends return AssistantTurn whose
# str() falls through to repr() ("AssistantTurn(content=..., model=...)")
# which pollutes substring matching. Prefer get_text() / .content if
# available, then .data, then plain str().
payload = getattr(response, "data", response)
if hasattr(payload, "get_text"):
response_str = str(payload.get_text())
elif hasattr(payload, "content"):
# AssistantTurn-like: content is ContentBlockList
response_str = str(payload.content)
else:
response_str = str(payload)
if not response_str.strip():
return None, (
f"skipped: LLM returned empty response (likely safety filter or recitation).\n"
f"Question: {info.question}"
)
if check_answer(response_str, info.answer):
return 1.0, "Correct."
return 0.0, (
Expand Down
41 changes: 41 additions & 0 deletions configs/smallexp_hf_hotpotqa_gemini.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
runs_dir: runs
mode: real
seeds: [42, 123, 456]

job_timeout: 108000

llm:
model: gemini-3.1-flash-lite-preview

tasks:
- id: hf:hotpot_qa
eval_kwargs:
num_train: 100
num_validate: 100
num_test: 100
num_eval_repeats: 5

trainers:
- id: POLCA
params_variants:
- ps_steps: 60
ps_batches: 1
ps_candidates: 5
ps_proposals: 1
batch_size: 2
num_threads: 10
test_frequency: null
log_frequency: 1
validate_exploration_candidates: false
use_best_candidate_to_explore: false
num_pool: 10 # post-training: re-evaluate top 10 candidates on full val, apply best
# POLCA-specific knobs
epsilon: 0.1
use_summarizer: true
embedding_model: gemini/gemini-embedding-001

- id: DSPyTrainer
params_variants:
- dspy_optimizer: gepa
auto: medium
reflection_minibatch_size: 10
2 changes: 1 addition & 1 deletion configs/smoke_hf_hotpotqa_gemini.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ llm:

tasks:
- id: hf:hotpot_qa
eval_kwargs: {num_train: 20, num_validate: 10, num_test: 10}
eval_kwargs: {num_train: 1, num_validate: 1, num_test: 1}

trainers:
- id: PrioritySearch
Expand Down
20 changes: 20 additions & 0 deletions trace_bench/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,30 @@ def _task_in_bench(task_key: str, bench: str | None) -> bool:
"ps_candidates",
"ps_proposals",
"ps_mem_update",
"batch_size",
# POLCA-specific knobs (popped inside POLCA.train)
"epsilon",
"use_summarizer",
"embedding_model",
"num_pool",
# PrioritySearch fine-grained knobs (defaults are in PrioritySearch.train signature)
"validate_exploration_candidates",
"use_best_candidate_to_explore",
"test_frequency",
"num_test_samples",
"log_frequency",
"save_frequency",
"gepa_iters",
"gepa_train_bs",
"gepa_merge_every",
"gepa_pareto_subset",
# DSPyTrainer pass-through knobs
"dspy_optimizer",
"auto",
"max_steps",
"bsize",
"max_demos",
"reflection_minibatch_size",
# LLM4AD pass-through knobs (merged into params_variants by config parser)
"optimizer_kwargs",
"eval_kwargs",
Expand Down
2 changes: 1 addition & 1 deletion trace_bench/resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@


def _default_trainer_kwargs(algo_name: str) -> Dict[str, Any]:
if algo_name == "PrioritySearch":
if algo_name in ("PrioritySearch", "POLCA"):
return dict(num_epochs=1, num_steps=1, num_batches=1, num_candidates=2, num_proposals=2)
if algo_name == "GEPA-Base":
return dict(num_iters=1, train_batch_size=2, merge_every=2, pareto_subset_size=2)
Expand Down
41 changes: 29 additions & 12 deletions trace_bench/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,25 +384,42 @@ def _score_dataset(bundle: Dict[str, Any], dataset: Dict[str, Any], dataset_name
if n <= 0:
return {"score": None, "feedback": f"{dataset_name}: empty_dataset"}

scores: List[float] = []
repeats = max(1, int(bundle.get("num_eval_repeats", 1) or 1))

scores: List[float] = [] # one per (example, repeat) pair
feedbacks: List[str] = []
for i in range(n):
task_input = inputs[i]
task_info = infos[i]
response = _extract_response(bundle["param"], task_input)
try:
score, feedback = guide(task_input, response, task_info)
except Exception as exc:
return {"score": None, "feedback": f"{dataset_name}: eval_error[{i}]: {exc}"}
try:
scores.append(float(score))
except Exception:
return {"score": score, "feedback": f"{dataset_name}: non_numeric_score[{i}]: {feedback}"}
feedbacks.append(str(feedback))
per_example_scores: List[float] = []
last_feedback = ""
for r in range(repeats):
response = _extract_response(bundle["param"], task_input)
try:
score, feedback = guide(task_input, response, task_info)
except Exception as exc:
return {"score": None, "feedback": f"{dataset_name}: eval_error[{i},rep{r}]: {exc}"}
# Score=None means "unscoreable" (e.g. SAFETY filter); skip this rep.
if score is None:
last_feedback = str(feedback)
continue
try:
s = float(score)
except Exception:
return {"score": score, "feedback": f"{dataset_name}: non_numeric_score[{i},rep{r}]: {feedback}"}
per_example_scores.append(s)
scores.append(s)
last_feedback = str(feedback)
feedbacks.append(last_feedback)

if not scores:
return {
"score": None,
"feedback": f"{dataset_name}: all {n} examples x {repeats} repeats produced unscoreable responses.",
}
return {
"score": sum(scores) / len(scores),
"feedback": f"{dataset_name}: mean over {len(scores)} examples. " + " | ".join(feedbacks[:3]),
"feedback": f"{dataset_name}: mean over {len(scores)} valid evals (out of {n} examples x {repeats} repeats). " + " | ".join(feedbacks[:3]),
}


Expand Down
Loading