mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
feat: LLM-guided spec search building blocks (split-aware sampling, external corpora, agent-trace adapter) (#332)
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
# LLM-Guided Spec Search
|
||||
|
||||
LLM-guided spec search is a local–cloud collaboration that uses each side
|
||||
for what it does best: a frontier cloud model (the *teacher*) reads traces
|
||||
from a deployed local agent and proposes typed edits across the agent's
|
||||
configuration; the local hardware runs the resulting configuration with
|
||||
zero marginal API cost at inference time. A held-out gate accepts only
|
||||
edits that improve a target failure mode without unacceptable regression
|
||||
on others.
|
||||
|
||||
The configuration the teacher edits is called the **spec**: a typed object
|
||||
with five primitives (Intelligence, Engine, Agents, Tools & Memory,
|
||||
Learning). The Learning primitive is where the search lives — it specifies
|
||||
which edits are considered, how candidate specs are evaluated, and when
|
||||
optimization stops.
|
||||
|
||||
## How it works
|
||||
|
||||
A search session repeats four phases:
|
||||
|
||||
| Phase | What happens |
|
||||
|---|---|
|
||||
| **Diagnose** | The teacher reads eligible traces and groups failures into clusters, each annotated with a natural-language characterization of the skill gap. |
|
||||
| **Plan** | The teacher proposes typed edits across the editable primitives (Intelligence, Engine, Agents, Tools & Memory). A single proposal can edit multiple slots at once. |
|
||||
| **Execute** | The candidate spec is evaluated on a held-out gate: the targeted cluster must improve, and every other cluster must regress by no more than a per-cluster tolerance. |
|
||||
| **Record** | Accepted edits are committed; rejected edits are rolled back. |
|
||||
|
||||
The four-phase loop is implemented by the existing `jarvis learning`
|
||||
command — see [Learning & Distillation](learning-distillation.md) for the
|
||||
end-to-end runnable workflow, edit applier registry, and configuration
|
||||
schema. This document covers the new building blocks added on top.
|
||||
|
||||
### Alignment with the paper
|
||||
|
||||
The paper's Algorithm 1 specifies a **multi-session loop** that runs
|
||||
diagnose → plan → execute → record repeatedly until gate-score stagnation
|
||||
(default *k* = 5 sessions) or budget exhaustion, with default per-cluster
|
||||
tolerance ε = 1 %. The current `DistillationOrchestrator` runs
|
||||
**one session per `jarvis learning run` invocation** (one diagnose, one
|
||||
plan, one round of edits + gate decisions, one record); the per-cluster
|
||||
regression check uses a default `max_regression = 0.05` (5 %).
|
||||
|
||||
To match the paper's defaults today:
|
||||
|
||||
- pass `max_regression=0.01` when constructing the orchestrator or the
|
||||
`BenchmarkGate` directly, and
|
||||
- run `jarvis learning run` from a wrapper that re-invokes it until
|
||||
per-session gate-score deltas stagnate for *k* sessions.
|
||||
|
||||
Wiring the multi-session stagnation loop into the orchestrator natively,
|
||||
plus changing the default `max_regression` to 1 %, are tracked as
|
||||
follow-ups — they're not part of this PR.
|
||||
|
||||
## What this PR adds
|
||||
|
||||
### `splits.py` — deterministic train / test splits
|
||||
|
||||
`src/openjarvis/evals/core/splits.py` adds a small helper that takes a
|
||||
list of records and a benchmark name and returns a deterministic
|
||||
train / test partition. The split is keyed off a stable hash of each
|
||||
record's id, so the same `(records, train_frac, seed)` always yields the
|
||||
same partition. This is the substrate that makes "evaluate on
|
||||
`split=test`" and "search over `split=train`" reproducible.
|
||||
|
||||
```python
|
||||
from openjarvis.evals.core.splits import apply_split
|
||||
|
||||
train = apply_split(records, split="train", train_frac=0.2, seed=42)
|
||||
test = apply_split(records, split="test", train_frac=0.2, seed=42)
|
||||
all_ = apply_split(records, split="all", train_frac=0.2, seed=42) # passthrough
|
||||
```
|
||||
|
||||
The `split` kwarg is now wired through every dataset provider in
|
||||
`openjarvis.evals.datasets` (gaia, livecodebench, liveresearch,
|
||||
liveresearchbench, pinchbench, taubench, toolcall15) so any caller that
|
||||
constructs a `BenchmarkConfig` can request a particular split.
|
||||
|
||||
### External-corpus dataset providers
|
||||
|
||||
When the diagnose phase wants the teacher to reason over a broader
|
||||
agent-trace corpus instead of just the local student's own traces, it
|
||||
can ingest records from a HuggingFace-backed external corpus. Three
|
||||
providers are included:
|
||||
|
||||
| Corpus | HuggingFace dataset | What it surfaces |
|
||||
|---|---|---|
|
||||
| `adp` | `neulab/agent-data-collection` | Multi-turn agent trajectories from AgentTuning, CodeAct, OpenHands, and others |
|
||||
| `toolorchestra` | `nvidia/ToolScale` | Tool-use trajectories (the dataset underlying the ToolOrchestra paper) |
|
||||
| `generalthoughts` | `natolambert/GeneralThought-430K-filtered` | A filtered reasoning-trace pool |
|
||||
|
||||
Each provider implements the standard `DatasetProvider.load(...)`
|
||||
interface so corpus records can be loaded the same way benchmark records
|
||||
are. They're labeled "NOT USED FOR EVALUATION" in their docstrings —
|
||||
they exist purely to feed the proposer's diagnose phase.
|
||||
|
||||
### `external_adapter.py` — corpus records as synthetic traces
|
||||
|
||||
`src/openjarvis/learning/distillation/external_adapter.py` adapts records
|
||||
from any external corpus into rows the proposer's existing trace tools
|
||||
understand. The proposer reads from the SQLite TraceStore via search /
|
||||
get tools; this adapter writes each `EvalRecord` as a synthetic `Trace`
|
||||
with `feedback=0.5` (no ground truth) and a `source_name` tag in
|
||||
metadata so multi-source diagnose runs can filter downstream.
|
||||
|
||||
```python
|
||||
from openjarvis.evals.datasets.adp import ADPDataset
|
||||
from openjarvis.learning.distillation.external_adapter import (
|
||||
write_external_records_as_traces,
|
||||
)
|
||||
from openjarvis.traces.store import TraceStore
|
||||
|
||||
records = ADPDataset().load(max_samples=200, seed=42, split="all")
|
||||
store = TraceStore("~/.openjarvis/traces.db")
|
||||
n = write_external_records_as_traces(store, records, source_name="adp")
|
||||
# proposer's diagnose phase can now search/filter these traces by source="adp"
|
||||
```
|
||||
|
||||
### Bug fix: agent-backend trace toggle
|
||||
|
||||
`src/openjarvis/evals/backends/jarvis_agent.py` previously hardcoded
|
||||
`builder.telemetry(telemetry).traces(True).build()`, ignoring the
|
||||
`telemetry` parameter. This silently caused every agent-backend
|
||||
evaluation to write to `~/.openjarvis/traces.db` regardless of caller
|
||||
intent. A corrupt traces.db then turned every agent eval into "database
|
||||
disk image is malformed" errors that the eval scorer dropped, producing
|
||||
fake high accuracies from a handful of successful samples.
|
||||
|
||||
The fix is one line — match the `JarvisDirectBackend` pattern:
|
||||
|
||||
```python
|
||||
self._system = builder.telemetry(telemetry).traces(telemetry).build()
|
||||
```
|
||||
|
||||
Callers that previously expected traces to always be written should pass
|
||||
`telemetry=True` explicitly.
|
||||
|
||||
## Configuration
|
||||
|
||||
`jarvis learning` reads its configuration from `~/.openjarvis/config.toml`:
|
||||
|
||||
```toml
|
||||
[learning.distillation]
|
||||
enabled = true # gate the entire subsystem
|
||||
autonomy_mode = "tiered" # auto | tiered | manual
|
||||
teacher_model = "claude-opus-4-6" # any CloudEngine-supported model
|
||||
max_cost_per_session_usd = 5.0 # per-session teacher API budget
|
||||
max_tool_calls_per_diagnosis = 30 # max teacher tool calls in diagnosis
|
||||
```
|
||||
|
||||
Gate / acceptance knobs (constructor arguments to `BenchmarkGate` and
|
||||
`DistillationOrchestrator`):
|
||||
|
||||
| Argument | Meaning | Current default | Paper default |
|
||||
|---|---|---|---|
|
||||
| `max_regression` | Maximum per-cluster score drop tolerated before rejecting an edit (the ε in the paper's `GateOK`). | `0.05` (5 %) | `0.01` (1 %) |
|
||||
| `min_improvement` | Minimum overall score improvement required to accept an edit. | `0.0` | (paper does not specify; uses `> 0` per `Gc(S') > Gc(S)`) |
|
||||
| `n_tasks` | Number of tasks scored per gate run. | `50` | n/a (gate is per-cluster) |
|
||||
|
||||
Pass `max_regression=0.01` explicitly to match the paper's default. See
|
||||
the *Alignment with the paper* note above for the gap between the
|
||||
single-session `DistillationOrchestrator` and the paper's multi-session
|
||||
loop with stagnation criterion.
|
||||
|
||||
Each `EditApplier` registers an autonomy tier (`auto`, `review`,
|
||||
`manual`); see the
|
||||
[Learning & Distillation](learning-distillation.md) doc for the per-edit
|
||||
configuration schema and the registered appliers.
|
||||
|
||||
## What runs where
|
||||
|
||||
At inference time, the resulting spec runs entirely on-device — model
|
||||
inference, agent execution, and tool invocation. Teacher API calls are
|
||||
made only at search time, for diagnosis and edit proposal. The local
|
||||
spec makes zero teacher calls at inference time.
|
||||
|
||||
When a frontier teacher is used, only **eligible** scrubbed traces are
|
||||
transmitted (per the trace-eligibility rules in the configuration).
|
||||
Users requiring strict local-only operation can swap a larger local
|
||||
model in as the teacher; this trades some search quality for zero cloud
|
||||
exposure.
|
||||
|
||||
## Why decouple primitives at all
|
||||
|
||||
Existing personal AI frameworks bundle agent prompts, tool descriptions,
|
||||
memory configuration, and runtime settings around a specific cloud
|
||||
model. Naive substitution of a local model for the cloud model collapses
|
||||
accuracy because none of the surrounding configuration was tuned for the
|
||||
new model. Optimizing across primitives jointly — instead of
|
||||
single-primitive optimization (LoRA-only, prompt-only) — is what allows
|
||||
LLM-guided spec search to recover that lost accuracy. The decomposition
|
||||
into Intelligence / Engine / Agents / Tools & Memory / Learning, with
|
||||
the spec as the typed configuration object, is what makes the search
|
||||
space well-defined.
|
||||
|
||||
## Adding a new external corpus
|
||||
|
||||
1. Create `src/openjarvis/evals/datasets/<corpus>.py` implementing
|
||||
`DatasetProvider` (look at `adp.py` for a small reference). The
|
||||
provider's `load(max_samples, seed, split)` should respect the
|
||||
`split` kwarg via `apply_split` from `openjarvis.evals.core.splits`.
|
||||
2. Register the new dataset in your local `DatasetRegistry` (typically
|
||||
via `@DatasetRegistry.register("<corpus>")` decorator on the class).
|
||||
3. Use it the same way as the bundled corpora: load records, then call
|
||||
`write_external_records_as_traces(store, records, source_name="<corpus>")`
|
||||
to make them visible to the proposer's diagnose phase.
|
||||
|
||||
The proposer can then filter on `metadata["source"] == "<corpus>"` if
|
||||
you're feeding multiple corpora into the same search session.
|
||||
@@ -62,7 +62,7 @@ class JarvisAgentBackend(InferenceBackend):
|
||||
builder._config.skills.enabled = skills_enabled
|
||||
if overlay_dir is not None:
|
||||
builder._config.learning.skills.overlay_dir = str(overlay_dir)
|
||||
self._system = builder.telemetry(telemetry).traces(True).build()
|
||||
self._system = builder.telemetry(telemetry).traces(telemetry).build()
|
||||
|
||||
@property
|
||||
def framework_commit_value(self) -> str:
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Deterministic train/test split helper used by dataset providers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import List, Literal, TypeVar
|
||||
|
||||
SplitName = Literal["train", "test", "all"]
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def apply_split(
|
||||
items: List[T],
|
||||
*,
|
||||
split: SplitName,
|
||||
seed: int,
|
||||
train_frac: float,
|
||||
) -> List[T]:
|
||||
"""Return a deterministic slice of ``items`` according to ``split``.
|
||||
|
||||
The underlying permutation is ``random.Random(seed).shuffle(items_copy)``.
|
||||
``train`` is the first ``int(len(items) * train_frac)`` entries of the
|
||||
shuffle, ``test`` is the remainder, ``all`` is the whole shuffle.
|
||||
"""
|
||||
if split not in ("train", "test", "all"):
|
||||
raise ValueError(f"split must be one of train/test/all, got {split!r}")
|
||||
if not 0.0 < train_frac < 1.0:
|
||||
raise ValueError(f"train_frac must be in (0, 1), got {train_frac}")
|
||||
shuffled = list(items)
|
||||
random.Random(seed).shuffle(shuffled)
|
||||
if split == "all":
|
||||
return shuffled
|
||||
cut = int(len(shuffled) * train_frac)
|
||||
if split == "train":
|
||||
return shuffled[:cut]
|
||||
return shuffled[cut:]
|
||||
|
||||
|
||||
__all__ = ["apply_split", "SplitName"]
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Agent Data Collection (neulab) — external agent-trajectory corpus.
|
||||
|
||||
NOT USED FOR EVALUATION. Surfaces multi-turn agent trajectories to the
|
||||
LLM-guided spec search proposer via
|
||||
``openjarvis.learning.distillation.external_adapter`` so the diagnose
|
||||
phase can reason over a broad pool of agent traces without depending on
|
||||
the per-cell student's own trace history.
|
||||
|
||||
HF dataset: neulab/agent-data-collection — a multi-config collection of
|
||||
agent trajectory datasets (AgentTuning subsets, CodeAct, OpenHands, etc.).
|
||||
Each config shares the same ``std`` split schema:
|
||||
- ``id``: trajectory identifier
|
||||
- ``content``: list-of-dicts with keys ``class_``, ``source``, ``content``
|
||||
- ``details``: per-config metadata dict
|
||||
|
||||
Conversion to EvalRecord:
|
||||
- ``problem`` : content of the first turn whose ``source == "user"``
|
||||
- ``reference``: content of the last turn whose ``class_ == "message_action"``
|
||||
(the agent's final response/action), truncated to 2000 chars
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import random
|
||||
from typing import Iterable, List, MutableMapping, Optional
|
||||
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.splits import apply_split
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
|
||||
HF_DATASET_ID = "neulab/agent-data-collection"
|
||||
# Use 'std' split — the normalised, model-agnostic format present in every config.
|
||||
HF_SPLIT = "std"
|
||||
|
||||
# All sub-configs available in the dataset. We concatenate them so the
|
||||
# corpus is as diverse as possible for the proposer's diagnose phase.
|
||||
_CONFIGS = [
|
||||
"agenttuning_alfworld",
|
||||
"agenttuning_db",
|
||||
"agenttuning_kg",
|
||||
"agenttuning_mind2web",
|
||||
"agenttuning_os",
|
||||
"agenttuning_webshop",
|
||||
"code_feedback",
|
||||
"codeactinstruct",
|
||||
"go-browse-wa",
|
||||
"mind2web",
|
||||
"nebius_SWE-agent-trajectories",
|
||||
"nnetnav-live",
|
||||
"nnetnav-wa",
|
||||
"openhands",
|
||||
"orca_agentinstruct",
|
||||
"swe-gym_openhands_sampled_trajectories",
|
||||
"swe-smith",
|
||||
"synatra",
|
||||
]
|
||||
|
||||
|
||||
def _parse_content(raw: object) -> List[MutableMapping[str, object]]:
|
||||
"""Parse the ``content`` field, which may be a list or a string repr of one."""
|
||||
if isinstance(raw, list):
|
||||
return raw # type: ignore[return-value]
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
parsed = ast.literal_eval(raw)
|
||||
if isinstance(parsed, list):
|
||||
return parsed # type: ignore[return-value]
|
||||
except (ValueError, SyntaxError):
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def _extract_problem_reference(
|
||||
turns: List[MutableMapping[str, object]],
|
||||
) -> tuple[Optional[str], str]:
|
||||
"""Return (problem, reference) from a list of trajectory turns.
|
||||
|
||||
problem = content of the first user-sourced turn
|
||||
reference = content of the last message_action turn (agent's final output),
|
||||
truncated to 2000 chars; falls back to the last non-user turn
|
||||
"""
|
||||
problem: Optional[str] = None
|
||||
for turn in turns:
|
||||
if turn.get("source") == "user":
|
||||
text = str(turn.get("content") or "").strip()
|
||||
if text:
|
||||
problem = text
|
||||
break
|
||||
|
||||
reference = ""
|
||||
# Prefer the last message_action (the agent's final response/finish action)
|
||||
for turn in reversed(turns):
|
||||
if turn.get("class_") == "message_action":
|
||||
text = str(turn.get("content") or "").strip()
|
||||
if text:
|
||||
reference = text[:2000]
|
||||
break
|
||||
if not reference:
|
||||
# Fallback: last non-user turn of any class
|
||||
for turn in reversed(turns):
|
||||
if turn.get("source") != "user":
|
||||
text = str(turn.get("content") or "").strip()
|
||||
if text:
|
||||
reference = text[:2000]
|
||||
break
|
||||
|
||||
return problem, reference
|
||||
|
||||
|
||||
class ADPDataset(DatasetProvider):
|
||||
"""Agent Data Collection (neulab) external corpus for LLM-guided spec search."""
|
||||
|
||||
dataset_id = "adp"
|
||||
dataset_name = "Agent Data Collection (neulab)"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._records: List[EvalRecord] = []
|
||||
|
||||
def load(
|
||||
self,
|
||||
*,
|
||||
max_samples: Optional[int] = None,
|
||||
split: Optional[str] = None,
|
||||
seed: Optional[int] = None,
|
||||
) -> None:
|
||||
from datasets import load_dataset
|
||||
|
||||
# We use streaming to avoid pulling entire datasets into Arrow memory
|
||||
# for each config — the GC of large Arrow tables causes intermittent
|
||||
# segfaults in this environment.
|
||||
#
|
||||
# Row collection cap:
|
||||
# - When only max_samples is given (no split, no explicit seed) we stop
|
||||
# after collecting max_samples rows from the configs.
|
||||
# - When a split or shuffle is requested we need enough rows to make
|
||||
# apply_split meaningful: collect at most max(max_samples * 50, 2000)
|
||||
# rows so that even the test-split has sufficient samples, but we
|
||||
# don't load the entire corpus.
|
||||
if split in ("train", "test", "all") or seed is not None:
|
||||
row_cap = max(max_samples * 50, 2000) if max_samples is not None else 5000
|
||||
else:
|
||||
row_cap = max_samples
|
||||
|
||||
rows: List[MutableMapping[str, object]] = []
|
||||
for cfg in _CONFIGS:
|
||||
if row_cap is not None and len(rows) >= row_cap:
|
||||
break
|
||||
try:
|
||||
ds_stream = load_dataset(
|
||||
HF_DATASET_ID, cfg, split=HF_SPLIT, streaming=True
|
||||
)
|
||||
for row in ds_stream:
|
||||
rows.append(dict(row)) # type: ignore[arg-type]
|
||||
if row_cap is not None and len(rows) >= row_cap:
|
||||
break
|
||||
except Exception:
|
||||
# Skip configs that fail to load (gated, missing, etc.)
|
||||
continue
|
||||
|
||||
effective_seed = 42 if seed is None else seed
|
||||
if split in ("train", "test", "all"):
|
||||
rows = apply_split(
|
||||
rows,
|
||||
split=split,
|
||||
seed=effective_seed,
|
||||
train_frac=0.2,
|
||||
)
|
||||
elif seed is not None:
|
||||
rng = random.Random(seed)
|
||||
rng.shuffle(rows)
|
||||
|
||||
if max_samples is not None:
|
||||
rows = rows[:max_samples]
|
||||
|
||||
records: List[EvalRecord] = []
|
||||
for i, r in enumerate(rows):
|
||||
turns = _parse_content(r.get("content"))
|
||||
problem, reference = _extract_problem_reference(turns)
|
||||
if not problem:
|
||||
continue
|
||||
row_id = r.get("id")
|
||||
record_id = str(row_id) if row_id is not None else f"adp-{i}"
|
||||
metadata: dict[str, object] = {"source": "adp"}
|
||||
details = r.get("details")
|
||||
if isinstance(details, dict):
|
||||
metadata["details"] = details
|
||||
records.append(
|
||||
EvalRecord(
|
||||
record_id=record_id,
|
||||
problem=problem,
|
||||
reference=reference,
|
||||
category="",
|
||||
subject="",
|
||||
metadata=metadata,
|
||||
)
|
||||
)
|
||||
self._records = records
|
||||
|
||||
def iter_records(self) -> Iterable[EvalRecord]:
|
||||
return iter(self._records)
|
||||
|
||||
def size(self) -> int:
|
||||
return len(self._records)
|
||||
|
||||
|
||||
__all__ = ["ADPDataset"]
|
||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
||||
from typing import Iterable, List, MutableMapping, Optional, Sequence
|
||||
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.splits import apply_split
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
|
||||
_DEFAULT_CACHE_DIR = Path.home() / ".cache" / "gaia_benchmark"
|
||||
@@ -54,7 +55,11 @@ class GAIADataset(DatasetProvider):
|
||||
from datasets import load_dataset
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
use_split = split or self._default_split
|
||||
use_split = (
|
||||
self._default_split
|
||||
if split in ("train", "test", "all") or split is None
|
||||
else split
|
||||
)
|
||||
|
||||
# Ensure dataset is downloaded
|
||||
dataset_location = self._cache_dir / "GAIA"
|
||||
@@ -82,7 +87,11 @@ class GAIADataset(DatasetProvider):
|
||||
else:
|
||||
rows = list(dataset)
|
||||
|
||||
if seed is not None:
|
||||
effective_seed = 42 if seed is None else seed
|
||||
if split in ("train", "test", "all"):
|
||||
rows = list(rows)
|
||||
rows = apply_split(rows, split=split, seed=effective_seed, train_frac=0.2)
|
||||
elif seed is not None:
|
||||
rng = random.Random(seed)
|
||||
rows = list(rows)
|
||||
rng.shuffle(rows)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""GeneralThought-430K (filtered) — external reasoning corpus.
|
||||
|
||||
NOT USED FOR EVALUATION. Provides the LLM-guided spec search proposer
|
||||
with a large pool of reasoning trajectories to reason over (via
|
||||
`openjarvis.learning.distillation.external_adapter`) when the diagnose
|
||||
phase wants signal from a broad reasoning corpus rather than the
|
||||
per-cell student's own trace history.
|
||||
|
||||
HF dataset: natolambert/GeneralThought-430K-filtered (filtered variant of
|
||||
GeneralReasoning/GeneralThought-430K — community/verifier-score filtered).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Iterable, List, MutableMapping, Optional, Sequence
|
||||
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.splits import apply_split
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
|
||||
HF_DATASET_ID = "natolambert/GeneralThought-430K-filtered"
|
||||
HF_SPLIT = "train"
|
||||
# Schema (observed from load_dataset_builder + sample row):
|
||||
# question_id, question_url, question, reference_answer, prev_messages,
|
||||
# model_name, model_answer, model_reasoning, task, question_license,
|
||||
# question_source, community_answer_score, community_question_score,
|
||||
# verifier_score
|
||||
PROBLEM_COLS = ("question",)
|
||||
SOLUTION_COLS = ("model_answer", "reference_answer")
|
||||
|
||||
|
||||
def _first_present(
|
||||
row: MutableMapping[str, object], keys: Sequence[str]
|
||||
) -> Optional[str]:
|
||||
for k in keys:
|
||||
v = row.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
class GeneralThoughtsDataset(DatasetProvider):
|
||||
"""GeneralThought-430K (filtered) external reasoning corpus for LLM-guided spec search."""
|
||||
|
||||
dataset_id = "generalthoughts"
|
||||
dataset_name = "GeneralThought-430K-filtered (natolambert)"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._records: List[EvalRecord] = []
|
||||
|
||||
def load(
|
||||
self,
|
||||
*,
|
||||
max_samples: Optional[int] = None,
|
||||
split: Optional[str] = None,
|
||||
seed: Optional[int] = None,
|
||||
) -> None:
|
||||
from datasets import load_dataset
|
||||
|
||||
ds = load_dataset(HF_DATASET_ID, split=HF_SPLIT)
|
||||
rows: List[MutableMapping[str, object]]
|
||||
if hasattr(ds, "to_list"):
|
||||
rows = ds.to_list()
|
||||
else:
|
||||
rows = list(ds)
|
||||
|
||||
effective_seed = 42 if seed is None else seed
|
||||
if split in ("train", "test", "all"):
|
||||
rows = apply_split(
|
||||
rows,
|
||||
split=split,
|
||||
seed=effective_seed,
|
||||
train_frac=0.2,
|
||||
)
|
||||
elif seed is not None:
|
||||
rng = random.Random(seed)
|
||||
rng.shuffle(rows)
|
||||
|
||||
if max_samples is not None:
|
||||
rows = rows[:max_samples]
|
||||
|
||||
records: List[EvalRecord] = []
|
||||
for i, r in enumerate(rows):
|
||||
problem = _first_present(r, PROBLEM_COLS)
|
||||
if not problem:
|
||||
continue
|
||||
reference = _first_present(r, SOLUTION_COLS) or ""
|
||||
question_id = r.get("question_id")
|
||||
record_id = (
|
||||
str(question_id) if question_id is not None else f"generalthoughts-{i}"
|
||||
)
|
||||
# Gather lightweight metadata — skip large text fields to keep records lean
|
||||
metadata: dict[str, object] = {"source": "generalthoughts"}
|
||||
for key in ("task", "question_source", "model_name", "question_license"):
|
||||
val = r.get(key)
|
||||
if val is not None:
|
||||
metadata[key] = val
|
||||
records.append(
|
||||
EvalRecord(
|
||||
record_id=record_id,
|
||||
problem=problem,
|
||||
reference=reference,
|
||||
category="reasoning",
|
||||
subject=str(r.get("task") or "General"),
|
||||
metadata=metadata,
|
||||
)
|
||||
)
|
||||
self._records = records
|
||||
|
||||
def iter_records(self) -> Iterable[EvalRecord]:
|
||||
return iter(self._records)
|
||||
|
||||
def size(self) -> int:
|
||||
return len(self._records)
|
||||
|
||||
|
||||
__all__ = ["GeneralThoughtsDataset"]
|
||||
@@ -15,6 +15,7 @@ import random
|
||||
from typing import Any, Dict, Iterable, List, MutableMapping, Optional, Sequence
|
||||
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.splits import apply_split
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
@@ -89,7 +90,11 @@ class LiveCodeBenchDataset(DatasetProvider):
|
||||
) -> None:
|
||||
from datasets import load_dataset
|
||||
|
||||
use_split = split or _DEFAULT_SPLIT
|
||||
use_split = (
|
||||
_DEFAULT_SPLIT
|
||||
if split in ("train", "test", "all") or split is None
|
||||
else split
|
||||
)
|
||||
|
||||
# Try lite version first (smaller, faster download), fall back to full
|
||||
dataset = None
|
||||
@@ -114,7 +119,11 @@ class LiveCodeBenchDataset(DatasetProvider):
|
||||
else:
|
||||
rows = list(dataset)
|
||||
|
||||
if seed is not None:
|
||||
effective_seed = 42 if seed is None else seed
|
||||
if split in ("train", "test", "all"):
|
||||
rows = list(rows)
|
||||
rows = apply_split(rows, split=split, seed=effective_seed, train_frac=0.2)
|
||||
elif seed is not None:
|
||||
rng = random.Random(seed)
|
||||
rows = list(rows)
|
||||
rng.shuffle(rows)
|
||||
|
||||
@@ -18,6 +18,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.splits import apply_split
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
@@ -132,7 +133,12 @@ class LiveResearchBenchDataset(DatasetProvider):
|
||||
if split and split in ("en", "zh"):
|
||||
queries = [q for q in queries if q.get("language") == split]
|
||||
|
||||
if seed is not None:
|
||||
effective_seed = 42 if seed is None else seed
|
||||
if split in ("train", "test", "all"):
|
||||
queries = apply_split(
|
||||
queries, split=split, seed=effective_seed, train_frac=0.2
|
||||
)
|
||||
elif seed is not None:
|
||||
random.Random(seed).shuffle(queries)
|
||||
if max_samples is not None:
|
||||
queries = queries[:max_samples]
|
||||
|
||||
@@ -21,6 +21,7 @@ import random
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.splits import apply_split
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
@@ -88,7 +89,11 @@ class LiveResearchBenchDataset(DatasetProvider):
|
||||
) -> None:
|
||||
from datasets import load_dataset
|
||||
|
||||
hf_split = split or self._hf_split
|
||||
hf_split = (
|
||||
self._hf_split
|
||||
if split in ("train", "test", "all") or split is None
|
||||
else split
|
||||
)
|
||||
LOGGER.info(
|
||||
"Loading %s (config=%s, split=%s) from HuggingFace ...",
|
||||
HF_DATASET_ID,
|
||||
@@ -125,7 +130,12 @@ class LiveResearchBenchDataset(DatasetProvider):
|
||||
f"(config={self._hf_config}, split={hf_split})"
|
||||
)
|
||||
|
||||
if seed is not None:
|
||||
effective_seed = 42 if seed is None else seed
|
||||
if split in ("train", "test", "all"):
|
||||
records = apply_split(
|
||||
records, split=split, seed=effective_seed, train_frac=0.2
|
||||
)
|
||||
elif seed is not None:
|
||||
random.Random(seed).shuffle(records)
|
||||
if max_samples is not None:
|
||||
records = records[:max_samples]
|
||||
|
||||
@@ -19,6 +19,7 @@ from typing import Any, Dict, Iterable, List, Optional
|
||||
import yaml
|
||||
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.splits import apply_split
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
@@ -158,7 +159,10 @@ class PinchBenchDataset(DatasetProvider):
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Skipping %s: %s", tf.name, exc)
|
||||
|
||||
if seed is not None:
|
||||
effective_seed = 42 if seed is None else seed
|
||||
if split in ("train", "test", "all"):
|
||||
tasks = apply_split(tasks, split=split, seed=effective_seed, train_frac=0.2)
|
||||
elif seed is not None:
|
||||
random.Random(seed).shuffle(tasks)
|
||||
if max_samples is not None:
|
||||
tasks = tasks[:max_samples]
|
||||
|
||||
@@ -16,6 +16,7 @@ from pathlib import Path
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.splits import apply_split
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
@@ -51,7 +52,15 @@ def _ensure_tau2() -> None:
|
||||
)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
subprocess.run(
|
||||
["uv", "pip", "install", "--python", sys.executable, "-e", str(CACHE_DIR)],
|
||||
[
|
||||
"uv",
|
||||
"pip",
|
||||
"install",
|
||||
"--python",
|
||||
sys.executable,
|
||||
"-e",
|
||||
str(CACHE_DIR),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
@@ -128,8 +137,9 @@ class TauBenchDataset(DatasetProvider):
|
||||
from tau2.runner import get_tasks
|
||||
|
||||
# split overrides domains if provided (e.g. "airline,retail")
|
||||
# "train", "test", "all" are reserved for the apply_split path below.
|
||||
domains = self._domains
|
||||
if split:
|
||||
if split and split not in ("train", "test", "all"):
|
||||
domains = [d.strip() for d in split.split(",") if d.strip()]
|
||||
|
||||
all_records: List[EvalRecord] = []
|
||||
@@ -219,7 +229,12 @@ class TauBenchDataset(DatasetProvider):
|
||||
)
|
||||
all_records.append(record)
|
||||
|
||||
if seed is not None:
|
||||
effective_seed = 42 if seed is None else seed
|
||||
if split in ("train", "test", "all"):
|
||||
all_records = apply_split(
|
||||
all_records, split=split, seed=effective_seed, train_frac=0.2
|
||||
)
|
||||
elif seed is not None:
|
||||
import random
|
||||
|
||||
random.Random(seed).shuffle(all_records)
|
||||
|
||||
@@ -14,6 +14,7 @@ import random
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.splits import apply_split
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
@@ -653,7 +654,7 @@ class ToolCall15Dataset(DatasetProvider):
|
||||
scenarios = list(SCENARIOS)
|
||||
|
||||
# Optional category filter via split (e.g. "A", "A,B", "D-RestraintRefusal")
|
||||
if split:
|
||||
if split and split not in ("train", "test", "all"):
|
||||
filter_cats = [c.strip().upper() for c in split.split(",")]
|
||||
scenarios = [
|
||||
s
|
||||
@@ -661,7 +662,12 @@ class ToolCall15Dataset(DatasetProvider):
|
||||
if any(s["category"].upper().startswith(fc) for fc in filter_cats)
|
||||
]
|
||||
|
||||
if seed is not None:
|
||||
effective_seed = 42 if seed is None else seed
|
||||
if split in ("train", "test", "all"):
|
||||
scenarios = apply_split(
|
||||
scenarios, split=split, seed=effective_seed, train_frac=0.2
|
||||
)
|
||||
elif seed is not None:
|
||||
random.Random(seed).shuffle(scenarios)
|
||||
if max_samples is not None:
|
||||
scenarios = scenarios[:max_samples]
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""ToolScale (nvidia) — external tool-use corpus.
|
||||
|
||||
NOT USED FOR EVALUATION. Surfaces tool-use trajectories from
|
||||
``nvidia/ToolScale`` (the dataset underlying the ToolOrchestra paper) to
|
||||
the LLM-guided spec search proposer via
|
||||
``openjarvis.learning.distillation.external_adapter`` so the diagnose
|
||||
phase can reason over a broad pool of tool-use traces.
|
||||
|
||||
The ``dataset_id`` is kept as ``"toolorchestra"`` (matching the published
|
||||
paper name) even though the HuggingFace dataset is published as
|
||||
``nvidia/ToolScale``.
|
||||
|
||||
HF dataset: nvidia/ToolScale — single ``train`` split.
|
||||
Schema:
|
||||
- ``id``: record identifier (string)
|
||||
- ``description``: task/policy metadata dict (or string repr)
|
||||
- ``user_scenario``: dict with ``persona`` and ``instructions``
|
||||
- ``instructions.task_instructions``: the user's tool-use request (problem)
|
||||
- ``instructions.reason_for_call``: optional context / motivation
|
||||
- ``initial_state``: environment state at task start (may be None/empty)
|
||||
- ``evaluation_criteria``: dict with ``actions`` list — the expected
|
||||
sequence of tool calls (used as reference)
|
||||
|
||||
Conversion to EvalRecord:
|
||||
- ``problem`` : ``user_scenario.instructions.task_instructions``
|
||||
- ``reference``: string representation of ``evaluation_criteria.actions``,
|
||||
truncated to 2000 chars
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import random
|
||||
from typing import Any, Iterable, List, MutableMapping, Optional
|
||||
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.splits import apply_split
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
|
||||
HF_DATASET_ID = "nvidia/ToolScale"
|
||||
HF_SPLIT = "train"
|
||||
|
||||
|
||||
def _parse_field(raw: object) -> Any:
|
||||
"""Parse a field that may be a dict, a string repr of a dict, or None."""
|
||||
if raw is None or (isinstance(raw, str) and raw.strip().lower() in ("none", "")):
|
||||
return None
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
return ast.literal_eval(raw)
|
||||
except (ValueError, SyntaxError):
|
||||
pass
|
||||
return raw
|
||||
|
||||
|
||||
def _extract_problem_reference(
|
||||
row: MutableMapping[str, object],
|
||||
) -> tuple[Optional[str], str]:
|
||||
"""Return (problem, reference) from a ToolScale row.
|
||||
|
||||
problem = user_scenario.instructions.task_instructions
|
||||
reference = str(evaluation_criteria.actions) truncated to 2000 chars
|
||||
"""
|
||||
problem: Optional[str] = None
|
||||
user_scenario = _parse_field(row.get("user_scenario"))
|
||||
if isinstance(user_scenario, dict):
|
||||
instructions = user_scenario.get("instructions")
|
||||
if isinstance(instructions, dict):
|
||||
task_inst = instructions.get("task_instructions")
|
||||
if isinstance(task_inst, str) and task_inst.strip():
|
||||
problem = task_inst.strip()
|
||||
# Fallback: top-level string in user_scenario
|
||||
if not problem:
|
||||
for key in ("task_instructions", "task", "query", "request"):
|
||||
v = user_scenario.get(key)
|
||||
if isinstance(v, str) and v.strip():
|
||||
problem = v.strip()
|
||||
break
|
||||
|
||||
reference = ""
|
||||
eval_criteria = _parse_field(row.get("evaluation_criteria"))
|
||||
if isinstance(eval_criteria, dict):
|
||||
actions = eval_criteria.get("actions")
|
||||
if actions is not None:
|
||||
reference = str(actions)[:2000]
|
||||
if not reference and eval_criteria is not None:
|
||||
reference = str(eval_criteria)[:2000]
|
||||
|
||||
return problem, reference
|
||||
|
||||
|
||||
class ToolOrchestraDataset(DatasetProvider):
|
||||
"""ToolScale (nvidia/ToolScale) external corpus for LLM-guided spec search.
|
||||
|
||||
Published as part of the ToolOrchestra paper. ``dataset_id`` is kept
|
||||
as ``"toolorchestra"`` (the paper's name).
|
||||
"""
|
||||
|
||||
dataset_id = "toolorchestra"
|
||||
dataset_name = "ToolScale (nvidia) — ToolOrchestra paper"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._records: List[EvalRecord] = []
|
||||
|
||||
def load(
|
||||
self,
|
||||
*,
|
||||
max_samples: Optional[int] = None,
|
||||
split: Optional[str] = None,
|
||||
seed: Optional[int] = None,
|
||||
) -> None:
|
||||
from datasets import load_dataset
|
||||
|
||||
ds = load_dataset(HF_DATASET_ID, split=HF_SPLIT)
|
||||
rows: List[MutableMapping[str, object]]
|
||||
if hasattr(ds, "to_list"):
|
||||
rows = ds.to_list()
|
||||
else:
|
||||
rows = list(ds)
|
||||
|
||||
effective_seed = 42 if seed is None else seed
|
||||
if split in ("train", "test", "all"):
|
||||
rows = apply_split(
|
||||
rows,
|
||||
split=split,
|
||||
seed=effective_seed,
|
||||
train_frac=0.2,
|
||||
)
|
||||
elif seed is not None:
|
||||
rng = random.Random(seed)
|
||||
rng.shuffle(rows)
|
||||
|
||||
if max_samples is not None:
|
||||
rows = rows[:max_samples]
|
||||
|
||||
records: List[EvalRecord] = []
|
||||
for i, r in enumerate(rows):
|
||||
problem, reference = _extract_problem_reference(r)
|
||||
if not problem:
|
||||
continue
|
||||
row_id = r.get("id")
|
||||
record_id = str(row_id) if row_id is not None else f"toolorchestra-{i}"
|
||||
metadata: dict[str, object] = {"source": "toolorchestra"}
|
||||
desc = _parse_field(r.get("description"))
|
||||
if isinstance(desc, dict):
|
||||
for key in ("purpose", "notes"):
|
||||
val = desc.get(key)
|
||||
if val is not None:
|
||||
metadata[key] = val
|
||||
records.append(
|
||||
EvalRecord(
|
||||
record_id=record_id,
|
||||
problem=problem,
|
||||
reference=reference,
|
||||
category="",
|
||||
subject="",
|
||||
metadata=metadata,
|
||||
)
|
||||
)
|
||||
self._records = records
|
||||
|
||||
def iter_records(self) -> Iterable[EvalRecord]:
|
||||
return iter(self._records)
|
||||
|
||||
def size(self) -> int:
|
||||
return len(self._records)
|
||||
|
||||
|
||||
__all__ = ["ToolOrchestraDataset"]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Adapt external agent-dataset records into TraceStore rows.
|
||||
|
||||
LLM-guided spec search reads student traces from a SQLite TraceStore via
|
||||
its search/get tools. This adapter reuses that pipeline for external
|
||||
agent corpora (e.g. ADP, ToolOrchestra, GeneralThoughts): each EvalRecord
|
||||
from a provider becomes a synthetic Trace so the teacher proposer can
|
||||
reason over it without a live student run on the corpus task.
|
||||
|
||||
These synthetic traces carry feedback=0.5 by default (no ground truth).
|
||||
Tool calls are empty. The `source_name` tag in each trace's metadata is
|
||||
how multi-source corpora are filtered downstream.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from openjarvis.core.types import Trace
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
from openjarvis.traces.store import TraceStore
|
||||
|
||||
|
||||
def write_external_records_as_traces(
|
||||
store: TraceStore,
|
||||
records: Iterable[EvalRecord],
|
||||
*,
|
||||
source_name: str,
|
||||
feedback_score: float = 0.5,
|
||||
) -> int:
|
||||
"""Write each EvalRecord to `store` as a synthetic Trace.
|
||||
|
||||
The LLM-guided spec search proposer reads from the TraceStore via
|
||||
its search/get tools; this lets the diagnose phase substitute live
|
||||
student traces with records from pre-existing agent-task corpora
|
||||
(ADP / ToolOrchestra / GeneralThoughts).
|
||||
|
||||
Args:
|
||||
store: Destination TraceStore.
|
||||
records: Iterable of EvalRecord (e.g. from a dataset provider).
|
||||
source_name: Label recorded in each trace's metadata["source"]
|
||||
so multi-source setups can filter later.
|
||||
feedback_score: Value for Trace.feedback (0..1). Defaults to 0.5
|
||||
because external records carry no ground-truth outcome.
|
||||
|
||||
Returns:
|
||||
Number of traces written.
|
||||
"""
|
||||
written = 0
|
||||
for rec in records:
|
||||
meta = {"source": source_name, "record_id": rec.record_id}
|
||||
if rec.metadata:
|
||||
meta.update(rec.metadata)
|
||||
trace = Trace(
|
||||
trace_id=f"ext-{source_name}-{rec.record_id}",
|
||||
query=rec.problem,
|
||||
agent="external",
|
||||
model="external",
|
||||
engine="external",
|
||||
steps=[],
|
||||
result=rec.reference or "",
|
||||
outcome=None,
|
||||
feedback=feedback_score,
|
||||
metadata=meta,
|
||||
)
|
||||
store.save(trace)
|
||||
written += 1
|
||||
return written
|
||||
|
||||
|
||||
__all__ = ["write_external_records_as_traces"]
|
||||
@@ -0,0 +1,70 @@
|
||||
# tests/evals/core/test_config_split_parsing.py
|
||||
"""Regression: the `split` key under [[benchmarks]] is parsed and plumbed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from openjarvis.evals.core.config import load_eval_config
|
||||
|
||||
|
||||
def _write_config(tmp_path: Path, split_value: str | None) -> Path:
|
||||
split_line = f'split = "{split_value}"' if split_value else ""
|
||||
toml = f"""
|
||||
[meta]
|
||||
name = "split-parse-test"
|
||||
|
||||
[run]
|
||||
output_dir = "{tmp_path / "out"}"
|
||||
seed = 42
|
||||
|
||||
[[models]]
|
||||
name = "dummy-model"
|
||||
engine = "noop"
|
||||
|
||||
[[benchmarks]]
|
||||
name = "gaia"
|
||||
backend = "jarvis-agent"
|
||||
{split_line}
|
||||
max_samples = 10
|
||||
"""
|
||||
p = tmp_path / "eval.toml"
|
||||
p.write_text(toml)
|
||||
return p
|
||||
|
||||
|
||||
def test_split_parsed_when_present(tmp_path: Path):
|
||||
p = _write_config(tmp_path, "test")
|
||||
cfg = load_eval_config(p)
|
||||
assert cfg.benchmarks[0].split == "test"
|
||||
|
||||
|
||||
def test_split_absent_becomes_none(tmp_path: Path):
|
||||
p = _write_config(tmp_path, None)
|
||||
cfg = load_eval_config(p)
|
||||
assert cfg.benchmarks[0].split is None
|
||||
|
||||
|
||||
def test_split_train_value(tmp_path: Path):
|
||||
p = _write_config(tmp_path, "train")
|
||||
cfg = load_eval_config(p)
|
||||
assert cfg.benchmarks[0].split == "train"
|
||||
|
||||
|
||||
def test_split_all_value(tmp_path: Path):
|
||||
p = _write_config(tmp_path, "all")
|
||||
cfg = load_eval_config(p)
|
||||
assert cfg.benchmarks[0].split == "all"
|
||||
|
||||
|
||||
def test_split_plumbs_to_agent_eval_config(tmp_path: Path):
|
||||
"""End-to-end: parsed split flows into RunConfig.dataset_split
|
||||
which is what runner.py passes to ds.load(split=...)."""
|
||||
p = _write_config(tmp_path, "test")
|
||||
cfg = load_eval_config(p)
|
||||
# Pull out one expanded RunConfig (model x benchmark product)
|
||||
from openjarvis.evals.core.config import expand_suite
|
||||
|
||||
run_cfgs = list(expand_suite(cfg))
|
||||
assert len(run_cfgs) >= 1
|
||||
assert run_cfgs[0].dataset_split == "test"
|
||||
@@ -0,0 +1,72 @@
|
||||
# tests/evals/core/test_splits.py
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.evals.core.splits import apply_split
|
||||
|
||||
|
||||
def test_train_is_first_20_percent():
|
||||
items = list(range(100))
|
||||
train = apply_split(items, split="train", seed=42, train_frac=0.2)
|
||||
assert len(train) == 20
|
||||
|
||||
|
||||
def test_test_is_remaining_80_percent():
|
||||
items = list(range(100))
|
||||
test = apply_split(items, split="test", seed=42, train_frac=0.2)
|
||||
assert len(test) == 80
|
||||
|
||||
|
||||
def test_train_and_test_are_disjoint():
|
||||
items = list(range(100))
|
||||
train = apply_split(items, split="train", seed=42, train_frac=0.2)
|
||||
test = apply_split(items, split="test", seed=42, train_frac=0.2)
|
||||
assert set(train).isdisjoint(set(test))
|
||||
|
||||
|
||||
def test_train_union_test_equals_all_shuffled():
|
||||
items = list(range(100))
|
||||
train = apply_split(items, split="train", seed=42, train_frac=0.2)
|
||||
test = apply_split(items, split="test", seed=42, train_frac=0.2)
|
||||
allx = apply_split(items, split="all", seed=42, train_frac=0.2)
|
||||
assert sorted(train + test) == sorted(allx)
|
||||
|
||||
|
||||
def test_all_returns_shuffled_copy():
|
||||
items = list(range(100))
|
||||
allx = apply_split(items, split="all", seed=42, train_frac=0.2)
|
||||
assert len(allx) == 100
|
||||
assert set(allx) == set(items)
|
||||
|
||||
|
||||
def test_deterministic_across_calls():
|
||||
items = list(range(100))
|
||||
a = apply_split(items, split="train", seed=42, train_frac=0.2)
|
||||
b = apply_split(items, split="train", seed=42, train_frac=0.2)
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_different_seeds_give_different_order():
|
||||
items = list(range(100))
|
||||
a = apply_split(items, split="train", seed=42, train_frac=0.2)
|
||||
b = apply_split(items, split="train", seed=43, train_frac=0.2)
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_invalid_split_raises():
|
||||
with pytest.raises(ValueError, match=r"split must be one of train/test/all"):
|
||||
apply_split([1, 2, 3], split="foo", seed=42, train_frac=0.2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_frac", [0.0, 1.0, -0.5, 1.5])
|
||||
def test_rejects_train_frac_out_of_range(bad_frac):
|
||||
with pytest.raises(ValueError, match=r"train_frac must be in \(0, 1\)"):
|
||||
apply_split([1, 2, 3], split="train", seed=42, train_frac=bad_frac)
|
||||
|
||||
|
||||
def test_does_not_mutate_input():
|
||||
items = list(range(10))
|
||||
snapshot = items[:]
|
||||
apply_split(items, split="train", seed=42, train_frac=0.8)
|
||||
assert items == snapshot
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Smoke test: each external-corpus provider loads and iterates records.
|
||||
|
||||
All three providers (ADP, ToolOrchestra, GeneralThoughts) are live and
|
||||
point at their confirmed HF ids:
|
||||
- adp → neulab/agent-data-collection
|
||||
- toolorchestra → nvidia/ToolScale
|
||||
- generalthoughts → natolambert/GeneralThought-430K-filtered
|
||||
|
||||
These tests download each dataset once to ~/.cache/huggingface and
|
||||
verify the provider loads, iterates, and honours the split kwarg.
|
||||
The ModuleNotFoundError skip branch is defensive — currently
|
||||
unreachable since all three provider modules exist.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
PROVIDERS = [
|
||||
("openjarvis.evals.datasets.adp", "ADPDataset"),
|
||||
("openjarvis.evals.datasets.toolorchestra", "ToolOrchestraDataset"),
|
||||
("openjarvis.evals.datasets.generalthoughts", "GeneralThoughtsDataset"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.parametrize("mod_name,cls_name", PROVIDERS)
|
||||
def test_external_provider_loads_and_iterates(mod_name, cls_name):
|
||||
"""Download, load 5 records, assert they have record_id + non-empty problem."""
|
||||
try:
|
||||
mod = importlib.import_module(mod_name)
|
||||
except ModuleNotFoundError:
|
||||
pytest.skip(f"{mod_name} not implemented (HF id not found)")
|
||||
ds_cls = getattr(mod, cls_name)
|
||||
ds = ds_cls()
|
||||
ds.load(max_samples=5)
|
||||
records = list(ds.iter_records())
|
||||
assert 1 <= len(records) <= 5
|
||||
for r in records:
|
||||
assert r.record_id
|
||||
assert r.problem
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.parametrize("mod_name,cls_name", PROVIDERS)
|
||||
def test_external_provider_respects_split(mod_name, cls_name):
|
||||
"""Train and test splits are disjoint when seed is held constant."""
|
||||
try:
|
||||
mod = importlib.import_module(mod_name)
|
||||
except ModuleNotFoundError:
|
||||
pytest.skip(f"{mod_name} not implemented (HF id not found)")
|
||||
ds_cls = getattr(mod, cls_name)
|
||||
train = ds_cls()
|
||||
train.load(split="train", seed=42, max_samples=20)
|
||||
test = ds_cls()
|
||||
test.load(split="test", seed=42, max_samples=20)
|
||||
train_ids = {r.record_id for r in train.iter_records()}
|
||||
test_ids = {r.record_id for r in test.iter_records()}
|
||||
if len(train_ids) + len(test_ids) < 10:
|
||||
pytest.skip("sample too small to verify disjointness meaningfully")
|
||||
assert train_ids.isdisjoint(test_ids)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Integration test: each provider's split kwarg produces disjoint train/test slices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
PROVIDERS = [
|
||||
("openjarvis.evals.datasets.pinchbench", "PinchBenchDataset"),
|
||||
("openjarvis.evals.datasets.liveresearch", "LiveResearchBenchDataset"),
|
||||
("openjarvis.evals.datasets.gaia", "GAIADataset"),
|
||||
("openjarvis.evals.datasets.liveresearchbench", "LiveResearchBenchDataset"),
|
||||
("openjarvis.evals.datasets.taubench", "TauBenchDataset"),
|
||||
("openjarvis.evals.datasets.toolcall15", "ToolCall15Dataset"),
|
||||
("openjarvis.evals.datasets.livecodebench", "LiveCodeBenchDataset"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.parametrize("mod_name,cls_name", PROVIDERS)
|
||||
def test_train_and_test_are_disjoint_per_provider(mod_name, cls_name):
|
||||
mod = importlib.import_module(mod_name)
|
||||
ds_cls = getattr(mod, cls_name)
|
||||
|
||||
train_ds = ds_cls()
|
||||
train_ds.load(split="train", seed=42)
|
||||
test_ds = ds_cls()
|
||||
test_ds.load(split="test", seed=42)
|
||||
|
||||
train_ids = {r.record_id for r in train_ds.iter_records()}
|
||||
test_ids = {r.record_id for r in test_ds.iter_records()}
|
||||
total = len(train_ids) + len(test_ids)
|
||||
|
||||
# A dataset with fewer than ~10 items can't produce a meaningful 20/80
|
||||
# split where both sides are non-empty. In that regime we skip the
|
||||
# disjointness check. But always reject a silent zero: if apply_split
|
||||
# or an upstream filter produced an empty slice, fail loudly.
|
||||
if total == 0:
|
||||
pytest.fail(
|
||||
f"{cls_name} returned 0 records for both train and test — "
|
||||
f"likely a gate regression"
|
||||
)
|
||||
if total < 10:
|
||||
pytest.skip("dataset too small for a 20/80 split")
|
||||
|
||||
assert train_ids.isdisjoint(test_ids)
|
||||
assert total == train_ds.size() + test_ds.size()
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_toolcall15_split_is_nonempty():
|
||||
"""Regression: toolcall15 must not silently return 0 records for split=train."""
|
||||
from openjarvis.evals.datasets.toolcall15 import ToolCall15Dataset
|
||||
|
||||
ds = ToolCall15Dataset()
|
||||
ds.load(split="train", seed=42)
|
||||
assert ds.size() > 0, "toolcall15 train split should have >0 records"
|
||||
|
||||
ds_test = ToolCall15Dataset()
|
||||
ds_test.load(split="test", seed=42)
|
||||
assert ds_test.size() > 0, "toolcall15 test split should have >0 records"
|
||||
|
||||
train_ids = {r.record_id for r in ds.iter_records()}
|
||||
test_ids = {r.record_id for r in ds_test.iter_records()}
|
||||
assert train_ids.isdisjoint(test_ids)
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Tests for openjarvis.learning.distillation.external_adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
from openjarvis.learning.distillation.external_adapter import (
|
||||
write_external_records_as_traces,
|
||||
)
|
||||
from openjarvis.traces.store import TraceStore
|
||||
|
||||
|
||||
def _fake_records(n: int) -> list[EvalRecord]:
|
||||
return [
|
||||
EvalRecord(
|
||||
record_id=f"rec-{i}",
|
||||
problem=f"question {i}",
|
||||
reference=f"answer {i}",
|
||||
category="test-category",
|
||||
metadata={"difficulty": "hard"} if i % 2 else None,
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
def test_writes_one_trace_per_record(tmp_path: Path):
|
||||
store = TraceStore(tmp_path / "traces.db")
|
||||
records = _fake_records(5)
|
||||
count = write_external_records_as_traces(
|
||||
store,
|
||||
records,
|
||||
source_name="testcorpus",
|
||||
)
|
||||
assert count == 5
|
||||
|
||||
|
||||
def test_synthetic_trace_fields(tmp_path: Path):
|
||||
store = TraceStore(tmp_path / "traces.db")
|
||||
write_external_records_as_traces(
|
||||
store,
|
||||
_fake_records(1),
|
||||
source_name="test",
|
||||
)
|
||||
# Use store.get() with known trace_id to retrieve the trace directly.
|
||||
t = store.get("ext-test-rec-0")
|
||||
assert t is not None
|
||||
assert t.trace_id == "ext-test-rec-0"
|
||||
assert t.query == "question 0"
|
||||
assert t.result == "answer 0"
|
||||
assert t.agent == "external"
|
||||
assert t.model == "external"
|
||||
assert t.engine == "external"
|
||||
assert t.steps == []
|
||||
assert t.outcome is None
|
||||
assert t.feedback == 0.5
|
||||
assert t.metadata["source"] == "test"
|
||||
assert t.metadata["record_id"] == "rec-0"
|
||||
|
||||
|
||||
def test_custom_feedback_score(tmp_path: Path):
|
||||
store = TraceStore(tmp_path / "traces.db")
|
||||
write_external_records_as_traces(
|
||||
store,
|
||||
_fake_records(1),
|
||||
source_name="src",
|
||||
feedback_score=0.9,
|
||||
)
|
||||
trace = store.get("ext-src-rec-0")
|
||||
assert trace is not None
|
||||
assert trace.feedback == 0.9
|
||||
|
||||
|
||||
def test_metadata_merges_record_metadata(tmp_path: Path):
|
||||
store = TraceStore(tmp_path / "traces.db")
|
||||
records = [
|
||||
EvalRecord(
|
||||
record_id="A",
|
||||
problem="q",
|
||||
reference="r",
|
||||
category="cat",
|
||||
metadata={"k1": "v1", "k2": "v2"},
|
||||
),
|
||||
]
|
||||
write_external_records_as_traces(store, records, source_name="src")
|
||||
trace = store.get("ext-src-A")
|
||||
assert trace is not None
|
||||
assert trace.metadata == {
|
||||
"source": "src",
|
||||
"record_id": "A",
|
||||
"k1": "v1",
|
||||
"k2": "v2",
|
||||
}
|
||||
|
||||
|
||||
def test_written_traces_are_fts_searchable(tmp_path: Path):
|
||||
store = TraceStore(tmp_path / "traces.db")
|
||||
records = [
|
||||
EvalRecord(
|
||||
record_id="alpha",
|
||||
problem="question about databases",
|
||||
reference="answer",
|
||||
category="cat",
|
||||
),
|
||||
EvalRecord(
|
||||
record_id="beta",
|
||||
problem="question about networking",
|
||||
reference="answer",
|
||||
category="cat",
|
||||
),
|
||||
]
|
||||
write_external_records_as_traces(store, records, source_name="src")
|
||||
hits = store.search("databases", limit=10)
|
||||
assert any(h["query"] == "question about databases" for h in hits)
|
||||
|
||||
|
||||
def test_returns_zero_for_empty_input(tmp_path: Path):
|
||||
store = TraceStore(tmp_path / "traces.db")
|
||||
count = write_external_records_as_traces(
|
||||
store,
|
||||
[],
|
||||
source_name="empty",
|
||||
)
|
||||
assert count == 0
|
||||
|
||||
|
||||
def test_duplicate_record_ids_raise(tmp_path: Path):
|
||||
import sqlite3
|
||||
|
||||
store = TraceStore(tmp_path / "traces.db")
|
||||
records = [
|
||||
EvalRecord(record_id="dup", problem="p1", reference="r", category="cat"),
|
||||
EvalRecord(record_id="dup", problem="p2", reference="r", category="cat"),
|
||||
]
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
write_external_records_as_traces(store, records, source_name="src")
|
||||
Reference in New Issue
Block a user