feat: LLM-guided spec search building blocks (split-aware sampling, external corpora, agent-trace adapter) (#332)

This commit is contained in:
Jon Saad-Falcon
2026-05-08 20:18:40 -07:00
committed by GitHub
parent 37f4942b07
commit 57151327b3
20 changed files with 1296 additions and 14 deletions
+208
View File
@@ -0,0 +1,208 @@
# LLM-Guided Spec Search
LLM-guided spec search is a localcloud 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:
+40
View File
@@ -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"]
+207
View File
@@ -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"]
+11 -2
View File
@@ -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"]
+11 -2
View File
@@ -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]
+5 -1
View File
@@ -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]
+18 -3
View File
@@ -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)
+8 -2
View File
@@ -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"]
View File
@@ -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"
+72
View File
@@ -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")