feat(optimize): add trial runner and feedback system

Add TrialRunner to bridge TrialConfig to the eval framework (EvalRunner),
TraceJudge for LLM-as-judge scoring of agent traces, and FeedbackCollector
for aggregating explicit, thumbs, and judge-driven feedback signals.
68 tests pass covering all three modules.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jon Saad-Falcon
2026-03-04 23:49:23 +00:00
co-authored by Claude Opus 4.6
parent 8437fccbb3
commit 9ef339f531
7 changed files with 1165 additions and 0 deletions
@@ -0,0 +1,6 @@
"""Feedback subsystem: LLM-as-judge scoring and signal aggregation."""
from openjarvis.optimize.feedback.collector import FeedbackCollector
from openjarvis.optimize.feedback.judge import TraceJudge
__all__ = ["TraceJudge", "FeedbackCollector"]
@@ -0,0 +1,115 @@
"""FeedbackCollector -- aggregates feedback from multiple sources."""
from __future__ import annotations
import time
from typing import Any, Dict, List, Optional
from openjarvis.core.types import Trace
from openjarvis.optimize.feedback.judge import TraceJudge
class FeedbackCollector:
"""Collects feedback signals: explicit user scores + LLM judge evaluations.
Signals are stored in-memory as dicts with at least ``trace_id``,
``score``, ``source``, and ``timestamp`` keys.
"""
def __init__(self) -> None:
self._records: List[Dict[str, Any]] = []
# ------------------------------------------------------------------
# Recording helpers
# ------------------------------------------------------------------
def record_explicit(
self,
trace_id: str,
score: float,
source: str = "api",
) -> None:
"""Record an explicit numeric score (0-1) for a trace."""
self._records.append({
"trace_id": trace_id,
"score": min(max(score, 0.0), 1.0),
"source": source,
"timestamp": time.time(),
})
def record_thumbs(self, trace_id: str, thumbs_up: bool) -> None:
"""Record a thumbs-up / thumbs-down signal (converted to 1.0/0.0)."""
self._records.append({
"trace_id": trace_id,
"score": 1.0 if thumbs_up else 0.0,
"source": "thumbs",
"timestamp": time.time(),
})
# ------------------------------------------------------------------
# Judge-driven evaluation
# ------------------------------------------------------------------
def evaluate_traces(
self,
traces: List[Trace],
judge: TraceJudge,
) -> List[Dict[str, Any]]:
"""Score *traces* via the LLM judge and record the results.
Returns the list of newly created feedback records.
"""
new_records: List[Dict[str, Any]] = []
for trace in traces:
score, feedback = judge.score_trace(trace)
record: Dict[str, Any] = {
"trace_id": trace.trace_id,
"score": score,
"source": "judge",
"feedback": feedback,
"timestamp": time.time(),
}
self._records.append(record)
new_records.append(record)
return new_records
# ------------------------------------------------------------------
# Queries
# ------------------------------------------------------------------
def get_records(
self, trace_id: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Return stored records, optionally filtered by *trace_id*."""
if trace_id is None:
return list(self._records)
return [r for r in self._records if r["trace_id"] == trace_id]
def stats(self) -> Dict[str, Any]:
"""Return aggregate statistics over all recorded feedback.
Returns a dict with ``count``, ``mean_score``, and a simple
``distribution`` bucket (low / medium / high).
"""
count = len(self._records)
if count == 0:
return {
"count": 0,
"mean_score": 0.0,
"distribution": {"low": 0, "medium": 0, "high": 0},
}
scores = [r["score"] for r in self._records]
mean_score = sum(scores) / count
low = sum(1 for s in scores if s < 0.4)
medium = sum(1 for s in scores if 0.4 <= s < 0.7)
high = sum(1 for s in scores if s >= 0.7)
return {
"count": count,
"mean_score": mean_score,
"distribution": {"low": low, "medium": medium, "high": high},
}
__all__ = ["FeedbackCollector"]
+118
View File
@@ -0,0 +1,118 @@
"""TraceJudge -- LLM-as-judge scoring for agent traces."""
from __future__ import annotations
import logging
import re
from typing import List, Tuple
from openjarvis.core.types import Trace
from openjarvis.evals.core.backend import InferenceBackend
LOGGER = logging.getLogger(__name__)
_SYSTEM_PROMPT = (
"You are an expert evaluator of AI assistant traces. "
"You will be shown a user query, the steps the assistant took, "
"and its final result. Rate the overall quality of the response "
"on a scale from 0.0 (completely wrong / unhelpful) to 1.0 "
"(perfect). Provide your score on the first line as a decimal "
"number, then explain your reasoning."
)
_SCORE_RE = re.compile(
r"(?:Score|Rating|Quality)?\s*[:=]?\s*(\d+(?:\.\d+)?)"
r"(?:\s*/\s*(\d+(?:\.\d+)?))?",
re.IGNORECASE,
)
def _format_trace(trace: Trace) -> str:
"""Render a Trace into a textual prompt for the judge."""
lines: List[str] = []
lines.append(f"## Query\n{trace.query}")
if trace.steps:
lines.append("\n## Steps")
for i, step in enumerate(trace.steps, 1):
step_input = step.input.get("content", str(step.input))
step_output = step.output.get("content", str(step.output))
lines.append(
f"{i}. [{step.step_type.value}] "
f"input={step_input!r} output={step_output!r} "
f"({step.duration_seconds:.3f}s)",
)
lines.append(f"\n## Final Result\n{trace.result}")
return "\n".join(lines)
def _parse_score(text: str) -> float:
"""Extract a 0-1 score from the judge response.
Handles formats like ``0.85``, ``Score: 0.85``, ``Rating: 7/10``.
Falls back to 0.5 when parsing fails.
"""
match = _SCORE_RE.search(text)
if match is None:
LOGGER.warning("Could not parse score from judge response; defaulting to 0.5")
return 0.5
numerator = float(match.group(1))
denominator_str = match.group(2)
if denominator_str is not None:
denominator = float(denominator_str)
if denominator > 0:
return min(max(numerator / denominator, 0.0), 1.0)
return 0.5
# If the number is > 1.0 assume it is on a 0-10 scale
if numerator > 1.0:
return min(numerator / 10.0, 1.0)
return min(max(numerator, 0.0), 1.0)
class TraceJudge:
"""LLM-as-judge for scoring traces when no ground truth exists.
Given a :class:`Trace`, the judge constructs a prompt showing the
query, agent steps, and final result, then asks an LLM to rate the
quality on a 0-1 scale.
"""
def __init__(self, backend: InferenceBackend, model: str) -> None:
self._backend = backend
self._model = model
def score_trace(self, trace: Trace) -> Tuple[float, str]:
"""Score a single trace.
Returns:
``(score, feedback)`` where *score* is in [0, 1] and
*feedback* is the judge's textual reasoning.
"""
prompt = _format_trace(trace)
response = self._backend.generate(
prompt,
model=self._model,
system=_SYSTEM_PROMPT,
temperature=0.0,
max_tokens=1024,
)
score = _parse_score(response)
return score, response
def batch_evaluate(
self, traces: List[Trace],
) -> List[Tuple[float, str]]:
"""Evaluate multiple traces sequentially.
Returns a list of ``(score, feedback)`` tuples, one per trace.
"""
results: List[Tuple[float, str]] = []
for trace in traces:
results.append(self.score_trace(trace))
return results
__all__ = ["TraceJudge"]
+138
View File
@@ -0,0 +1,138 @@
"""TrialRunner -- evaluates a proposed config against a benchmark."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, List
from openjarvis.evals.core.types import RunConfig, RunSummary
from openjarvis.optimize.types import TrialConfig, TrialResult
LOGGER = logging.getLogger(__name__)
class TrialRunner:
"""Evaluates a proposed config against a benchmark.
Bridges the optimization types (:class:`TrialConfig`) to the eval
framework (:class:`EvalRunner`) so the optimizer can score candidate
configurations end-to-end.
"""
def __init__(
self,
benchmark: str,
max_samples: int = 50,
judge_model: str = "gpt-5-mini-2025-08-07",
output_dir: str = "results/optimize/",
) -> None:
self.benchmark = benchmark
self.max_samples = max_samples
self.judge_model = judge_model
self.output_dir = output_dir
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def run_trial(self, trial: TrialConfig) -> TrialResult:
"""Run *trial* against the configured benchmark and return a result.
Steps:
1. Convert ``trial`` to a :class:`Recipe` and extract params.
2. Build a :class:`RunConfig` from recipe + benchmark settings.
3. Lazily import eval-framework registries to resolve the
benchmark -> dataset + scorer, and build the backend.
4. Execute via ``EvalRunner.run()`` -> :class:`RunSummary`.
5. Map the summary into a :class:`TrialResult`.
"""
recipe = trial.to_recipe()
run_config = self._build_run_config(trial, recipe)
# Lazy imports so the optimize package stays lightweight
from openjarvis.evals.cli import (
_build_backend,
_build_dataset,
_build_judge_backend,
_build_scorer,
)
from openjarvis.evals.core.runner import EvalRunner
dataset = _build_dataset(self.benchmark)
backend = _build_backend(
run_config.backend,
run_config.engine_key,
run_config.agent_name or "orchestrator",
run_config.tools,
)
judge_backend = _build_judge_backend(run_config.judge_model)
scorer = _build_scorer(
self.benchmark, judge_backend, run_config.judge_model,
)
try:
summary: RunSummary = EvalRunner(
run_config, dataset, backend, scorer,
).run()
finally:
backend.close()
judge_backend.close()
return self._summary_to_result(trial, summary)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _build_run_config(self, trial: TrialConfig, recipe: Any) -> RunConfig:
"""Map recipe fields into a :class:`RunConfig`."""
model = recipe.model or "default"
backend_name = "jarvis-direct"
if recipe.agent_type is not None:
backend_name = "jarvis-agent"
model_slug = model.replace("/", "-").replace(":", "-")
output_path = str(
Path(self.output_dir) / f"{trial.trial_id}_{model_slug}.jsonl",
)
return RunConfig(
benchmark=self.benchmark,
backend=backend_name,
model=model,
max_samples=self.max_samples,
temperature=recipe.temperature if recipe.temperature is not None else 0.0,
judge_model=self.judge_model,
engine_key=recipe.engine_key,
agent_name=recipe.agent_type,
tools=list(recipe.tools) if recipe.tools else [],
output_path=output_path,
)
@staticmethod
def _summary_to_result(
trial: TrialConfig, summary: RunSummary,
) -> TrialResult:
"""Convert a :class:`RunSummary` to a :class:`TrialResult`."""
total_tokens = summary.total_input_tokens + summary.total_output_tokens
failure_modes: List[str] = []
if summary.errors > 0:
failure_modes.append(f"{summary.errors} evaluation errors")
return TrialResult(
trial_id=trial.trial_id,
config=trial,
accuracy=summary.accuracy,
mean_latency_seconds=summary.mean_latency_seconds,
total_cost_usd=summary.total_cost_usd,
total_energy_joules=summary.total_energy_joules,
total_tokens=total_tokens,
samples_evaluated=summary.total_samples,
failure_modes=failure_modes,
summary=summary,
)
__all__ = ["TrialRunner"]
+239
View File
@@ -0,0 +1,239 @@
"""Tests for openjarvis.optimize.feedback.collector module."""
from __future__ import annotations
from unittest.mock import MagicMock
from openjarvis.core.types import Trace
from openjarvis.optimize.feedback.collector import FeedbackCollector
from openjarvis.optimize.feedback.judge import TraceJudge
def _make_trace(trace_id: str = "trace-001") -> Trace:
return Trace(trace_id=trace_id, query="Hello", result="Hi there")
# ---------------------------------------------------------------------------
# record_explicit
# ---------------------------------------------------------------------------
class TestRecordExplicit:
"""FeedbackCollector.record_explicit stores records."""
def test_stores_record(self) -> None:
fc = FeedbackCollector()
fc.record_explicit("t1", 0.8)
records = fc.get_records()
assert len(records) == 1
assert records[0]["trace_id"] == "t1"
assert records[0]["score"] == 0.8
assert records[0]["source"] == "api"
def test_custom_source(self) -> None:
fc = FeedbackCollector()
fc.record_explicit("t1", 0.5, source="human")
assert fc.get_records()[0]["source"] == "human"
def test_clamps_score_above_one(self) -> None:
fc = FeedbackCollector()
fc.record_explicit("t1", 1.5)
assert fc.get_records()[0]["score"] == 1.0
def test_clamps_score_below_zero(self) -> None:
fc = FeedbackCollector()
fc.record_explicit("t1", -0.5)
assert fc.get_records()[0]["score"] == 0.0
def test_has_timestamp(self) -> None:
fc = FeedbackCollector()
fc.record_explicit("t1", 0.7)
assert "timestamp" in fc.get_records()[0]
assert fc.get_records()[0]["timestamp"] > 0
def test_multiple_records(self) -> None:
fc = FeedbackCollector()
fc.record_explicit("t1", 0.5)
fc.record_explicit("t2", 0.9)
assert len(fc.get_records()) == 2
# ---------------------------------------------------------------------------
# record_thumbs
# ---------------------------------------------------------------------------
class TestRecordThumbs:
"""FeedbackCollector.record_thumbs converts boolean to score."""
def test_thumbs_up_is_one(self) -> None:
fc = FeedbackCollector()
fc.record_thumbs("t1", thumbs_up=True)
assert fc.get_records()[0]["score"] == 1.0
def test_thumbs_down_is_zero(self) -> None:
fc = FeedbackCollector()
fc.record_thumbs("t1", thumbs_up=False)
assert fc.get_records()[0]["score"] == 0.0
def test_source_is_thumbs(self) -> None:
fc = FeedbackCollector()
fc.record_thumbs("t1", thumbs_up=True)
assert fc.get_records()[0]["source"] == "thumbs"
# ---------------------------------------------------------------------------
# evaluate_traces
# ---------------------------------------------------------------------------
class TestEvaluateTraces:
"""FeedbackCollector.evaluate_traces uses the judge."""
def test_returns_new_records(self) -> None:
backend = MagicMock()
backend.generate.return_value = "Score: 0.85\nGood"
judge = TraceJudge(backend=backend, model="m")
fc = FeedbackCollector()
traces = [_make_trace("t1"), _make_trace("t2")]
new = fc.evaluate_traces(traces, judge)
assert len(new) == 2
assert new[0]["trace_id"] == "t1"
assert new[1]["trace_id"] == "t2"
def test_records_stored_internally(self) -> None:
backend = MagicMock()
backend.generate.return_value = "0.7\nOk"
judge = TraceJudge(backend=backend, model="m")
fc = FeedbackCollector()
fc.evaluate_traces([_make_trace("t1")], judge)
assert len(fc.get_records()) == 1
assert fc.get_records()[0]["source"] == "judge"
def test_record_has_feedback_text(self) -> None:
backend = MagicMock()
backend.generate.return_value = "Score: 0.6\nNeeds improvement"
judge = TraceJudge(backend=backend, model="m")
fc = FeedbackCollector()
fc.evaluate_traces([_make_trace()], judge)
record = fc.get_records()[0]
assert "feedback" in record
assert "Needs improvement" in record["feedback"]
def test_empty_traces_returns_empty(self) -> None:
judge = MagicMock(spec=TraceJudge)
fc = FeedbackCollector()
result = fc.evaluate_traces([], judge)
assert result == []
# ---------------------------------------------------------------------------
# get_records
# ---------------------------------------------------------------------------
class TestGetRecords:
"""FeedbackCollector.get_records filters by trace_id."""
def test_all_records_when_no_filter(self) -> None:
fc = FeedbackCollector()
fc.record_explicit("t1", 0.5)
fc.record_explicit("t2", 0.8)
assert len(fc.get_records()) == 2
def test_filter_by_trace_id(self) -> None:
fc = FeedbackCollector()
fc.record_explicit("t1", 0.5)
fc.record_explicit("t2", 0.8)
fc.record_explicit("t1", 0.9)
records = fc.get_records(trace_id="t1")
assert len(records) == 2
assert all(r["trace_id"] == "t1" for r in records)
def test_filter_returns_empty_for_unknown_id(self) -> None:
fc = FeedbackCollector()
fc.record_explicit("t1", 0.5)
assert fc.get_records(trace_id="unknown") == []
def test_returns_copies(self) -> None:
fc = FeedbackCollector()
fc.record_explicit("t1", 0.5)
records = fc.get_records()
records.clear()
assert len(fc.get_records()) == 1
# ---------------------------------------------------------------------------
# stats
# ---------------------------------------------------------------------------
class TestStats:
"""FeedbackCollector.stats returns aggregate statistics."""
def test_empty_stats(self) -> None:
fc = FeedbackCollector()
s = fc.stats()
assert s["count"] == 0
assert s["mean_score"] == 0.0
assert s["distribution"] == {"low": 0, "medium": 0, "high": 0}
def test_count(self) -> None:
fc = FeedbackCollector()
fc.record_explicit("t1", 0.5)
fc.record_explicit("t2", 0.9)
assert fc.stats()["count"] == 2
def test_mean_score(self) -> None:
fc = FeedbackCollector()
fc.record_explicit("t1", 0.4)
fc.record_explicit("t2", 0.8)
assert abs(fc.stats()["mean_score"] - 0.6) < 1e-6
def test_distribution_low(self) -> None:
fc = FeedbackCollector()
fc.record_explicit("t1", 0.1)
fc.record_explicit("t2", 0.2)
s = fc.stats()
assert s["distribution"]["low"] == 2
assert s["distribution"]["medium"] == 0
assert s["distribution"]["high"] == 0
def test_distribution_medium(self) -> None:
fc = FeedbackCollector()
fc.record_explicit("t1", 0.5)
fc.record_explicit("t2", 0.6)
s = fc.stats()
assert s["distribution"]["medium"] == 2
def test_distribution_high(self) -> None:
fc = FeedbackCollector()
fc.record_explicit("t1", 0.8)
fc.record_explicit("t2", 1.0)
s = fc.stats()
assert s["distribution"]["high"] == 2
def test_distribution_mixed(self) -> None:
fc = FeedbackCollector()
fc.record_explicit("a", 0.1) # low
fc.record_explicit("b", 0.5) # medium
fc.record_explicit("c", 0.9) # high
s = fc.stats()
assert s["distribution"]["low"] == 1
assert s["distribution"]["medium"] == 1
assert s["distribution"]["high"] == 1
def test_stats_with_thumbs(self) -> None:
fc = FeedbackCollector()
fc.record_thumbs("t1", thumbs_up=True)
fc.record_thumbs("t2", thumbs_up=False)
s = fc.stats()
assert s["count"] == 2
assert abs(s["mean_score"] - 0.5) < 1e-6
+209
View File
@@ -0,0 +1,209 @@
"""Tests for openjarvis.optimize.feedback.judge module."""
from __future__ import annotations
from unittest.mock import MagicMock
from openjarvis.core.types import StepType, Trace, TraceStep
from openjarvis.optimize.feedback.judge import TraceJudge, _parse_score
# ---------------------------------------------------------------------------
# _parse_score unit tests
# ---------------------------------------------------------------------------
class TestParseScore:
"""Tests for the internal _parse_score helper."""
def test_plain_decimal(self) -> None:
assert _parse_score("0.85") == 0.85
def test_score_prefix(self) -> None:
assert _parse_score("Score: 0.90") == 0.90
def test_rating_prefix(self) -> None:
assert _parse_score("Rating: 0.75\nGood work.") == 0.75
def test_fraction_format(self) -> None:
score = _parse_score("Rating: 7/10\nSolid answer")
assert abs(score - 0.7) < 1e-6
def test_fraction_eight_over_ten(self) -> None:
score = _parse_score("Score: 8/10")
assert abs(score - 0.8) < 1e-6
def test_whole_number_over_one_treated_as_ten_scale(self) -> None:
score = _parse_score("Quality: 9\nExcellent")
assert abs(score - 0.9) < 1e-6
def test_zero_score(self) -> None:
assert _parse_score("Score: 0.0\nTerrible") == 0.0
def test_perfect_score(self) -> None:
assert _parse_score("1.0\nPerfect") == 1.0
def test_no_score_defaults_to_half(self) -> None:
assert _parse_score("This is just a comment.") == 0.5
def test_negative_clamped_to_zero(self) -> None:
# Edge case: if somehow negative appeared
score = _parse_score("Score: 0.0")
assert score == 0.0
def test_over_one_on_ten_scale_capped(self) -> None:
score = _parse_score("Score: 11")
assert score <= 1.0
def test_quality_prefix(self) -> None:
assert _parse_score("Quality: 0.65") == 0.65
# ---------------------------------------------------------------------------
# TraceJudge
# ---------------------------------------------------------------------------
def _make_trace(**overrides) -> Trace:
"""Create a minimal Trace for testing."""
defaults = dict(
trace_id="trace-001",
query="What is 2+2?",
agent="orchestrator",
model="qwen3:8b",
result="The answer is 4.",
)
defaults.update(overrides)
return Trace(**defaults)
class TestTraceJudgeInit:
"""TraceJudge constructor stores backend and model."""
def test_stores_backend_and_model(self) -> None:
backend = MagicMock()
judge = TraceJudge(backend=backend, model="judge-model")
assert judge._backend is backend
assert judge._model == "judge-model"
class TestScoreTrace:
"""TraceJudge.score_trace calls backend and parses result."""
def test_returns_score_and_feedback(self) -> None:
backend = MagicMock()
backend.generate.return_value = "Score: 0.85\nGood reasoning and correct answer."
judge = TraceJudge(backend=backend, model="judge-model")
trace = _make_trace()
score, feedback = judge.score_trace(trace)
assert score == 0.85
assert "Good reasoning" in feedback
backend.generate.assert_called_once()
def test_prompt_includes_query(self) -> None:
backend = MagicMock()
backend.generate.return_value = "0.9\nGreat"
judge = TraceJudge(backend=backend, model="m")
trace = _make_trace(query="Explain gravity")
judge.score_trace(trace)
call_args = backend.generate.call_args
prompt = call_args[0][0]
assert "Explain gravity" in prompt
def test_prompt_includes_result(self) -> None:
backend = MagicMock()
backend.generate.return_value = "0.7\nOk"
judge = TraceJudge(backend=backend, model="m")
trace = _make_trace(result="Gravity is a fundamental force.")
judge.score_trace(trace)
call_args = backend.generate.call_args
prompt = call_args[0][0]
assert "Gravity is a fundamental force." in prompt
def test_prompt_includes_steps(self) -> None:
backend = MagicMock()
backend.generate.return_value = "0.8\nGood"
judge = TraceJudge(backend=backend, model="m")
step = TraceStep(
step_type=StepType.GENERATE,
timestamp=1.0,
duration_seconds=0.5,
input={"content": "some input"},
output={"content": "some output"},
)
trace = _make_trace(steps=[step])
judge.score_trace(trace)
call_args = backend.generate.call_args
prompt = call_args[0][0]
assert "generate" in prompt
assert "some input" in prompt
def test_uses_system_prompt(self) -> None:
backend = MagicMock()
backend.generate.return_value = "0.5"
judge = TraceJudge(backend=backend, model="m")
judge.score_trace(_make_trace())
call_args = backend.generate.call_args
assert call_args[1]["system"] != ""
def test_fraction_score_parsing(self) -> None:
backend = MagicMock()
backend.generate.return_value = "Rating: 7/10\nDecent answer"
judge = TraceJudge(backend=backend, model="m")
score, _ = judge.score_trace(_make_trace())
assert abs(score - 0.7) < 1e-6
class TestBatchEvaluate:
"""TraceJudge.batch_evaluate processes multiple traces."""
def test_returns_one_result_per_trace(self) -> None:
backend = MagicMock()
backend.generate.return_value = "0.8\nGood"
judge = TraceJudge(backend=backend, model="m")
traces = [_make_trace(trace_id=f"t{i}") for i in range(3)]
results = judge.batch_evaluate(traces)
assert len(results) == 3
assert all(isinstance(r, tuple) and len(r) == 2 for r in results)
def test_calls_score_trace_for_each(self) -> None:
backend = MagicMock()
backend.generate.return_value = "0.6\nAverage"
judge = TraceJudge(backend=backend, model="m")
traces = [_make_trace(trace_id=f"t{i}") for i in range(5)]
judge.batch_evaluate(traces)
assert backend.generate.call_count == 5
def test_empty_list_returns_empty(self) -> None:
backend = MagicMock()
judge = TraceJudge(backend=backend, model="m")
assert judge.batch_evaluate([]) == []
def test_different_scores_per_trace(self) -> None:
backend = MagicMock()
backend.generate.side_effect = [
"Score: 0.9\nExcellent",
"Score: 0.3\nPoor",
]
judge = TraceJudge(backend=backend, model="m")
traces = [_make_trace(trace_id="a"), _make_trace(trace_id="b")]
results = judge.batch_evaluate(traces)
assert results[0][0] == 0.9
assert results[1][0] == 0.3
+340
View File
@@ -0,0 +1,340 @@
"""Tests for openjarvis.optimize.trial_runner module."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from openjarvis.evals.core.types import RunConfig, RunSummary
from openjarvis.optimize.trial_runner import TrialRunner
from openjarvis.optimize.types import TrialConfig, TrialResult
class TestTrialRunnerInit:
"""TrialRunner.__init__ stores parameters correctly."""
def test_default_params(self) -> None:
runner = TrialRunner(benchmark="supergpqa")
assert runner.benchmark == "supergpqa"
assert runner.max_samples == 50
assert runner.judge_model == "gpt-5-mini-2025-08-07"
assert runner.output_dir == "results/optimize/"
def test_custom_params(self) -> None:
runner = TrialRunner(
benchmark="gaia",
max_samples=100,
judge_model="custom-judge",
output_dir="/tmp/results/",
)
assert runner.benchmark == "gaia"
assert runner.max_samples == 100
assert runner.judge_model == "custom-judge"
assert runner.output_dir == "/tmp/results/"
class TestBuildRunConfig:
"""TrialRunner._build_run_config maps recipe fields correctly."""
def test_model_mapping(self) -> None:
runner = TrialRunner(benchmark="supergpqa")
trial = TrialConfig(
trial_id="t1",
params={"intelligence.model": "qwen3:8b"},
)
recipe = trial.to_recipe()
cfg = runner._build_run_config(trial, recipe)
assert isinstance(cfg, RunConfig)
assert cfg.model == "qwen3:8b"
assert cfg.benchmark == "supergpqa"
def test_agent_maps_to_agent_backend(self) -> None:
runner = TrialRunner(benchmark="gaia")
trial = TrialConfig(
trial_id="t2",
params={
"intelligence.model": "llama3.1:8b",
"agent.type": "native_react",
},
)
recipe = trial.to_recipe()
cfg = runner._build_run_config(trial, recipe)
assert cfg.backend == "jarvis-agent"
assert cfg.agent_name == "native_react"
def test_no_agent_maps_to_direct_backend(self) -> None:
runner = TrialRunner(benchmark="supergpqa")
trial = TrialConfig(
trial_id="t3",
params={"intelligence.model": "qwen3:8b"},
)
recipe = trial.to_recipe()
cfg = runner._build_run_config(trial, recipe)
assert cfg.backend == "jarvis-direct"
assert cfg.agent_name is None
def test_tools_mapping(self) -> None:
runner = TrialRunner(benchmark="supergpqa")
trial = TrialConfig(
trial_id="t4",
params={
"agent.type": "orchestrator",
"tools.tool_set": ["calculator", "think"],
},
)
recipe = trial.to_recipe()
cfg = runner._build_run_config(trial, recipe)
assert cfg.tools == ["calculator", "think"]
def test_temperature_mapping(self) -> None:
runner = TrialRunner(benchmark="supergpqa")
trial = TrialConfig(
trial_id="t5",
params={"intelligence.temperature": 0.7},
)
recipe = trial.to_recipe()
cfg = runner._build_run_config(trial, recipe)
assert cfg.temperature == 0.7
def test_engine_key_mapping(self) -> None:
runner = TrialRunner(benchmark="supergpqa")
trial = TrialConfig(
trial_id="t6",
params={"engine.backend": "vllm"},
)
recipe = trial.to_recipe()
cfg = runner._build_run_config(trial, recipe)
assert cfg.engine_key == "vllm"
def test_max_samples_from_runner(self) -> None:
runner = TrialRunner(benchmark="supergpqa", max_samples=25)
trial = TrialConfig(trial_id="t7")
recipe = trial.to_recipe()
cfg = runner._build_run_config(trial, recipe)
assert cfg.max_samples == 25
def test_judge_model_from_runner(self) -> None:
runner = TrialRunner(benchmark="supergpqa", judge_model="my-judge")
trial = TrialConfig(trial_id="t8")
recipe = trial.to_recipe()
cfg = runner._build_run_config(trial, recipe)
assert cfg.judge_model == "my-judge"
def test_output_path_contains_trial_id(self) -> None:
runner = TrialRunner(benchmark="supergpqa", output_dir="out/")
trial = TrialConfig(
trial_id="trial-abc",
params={"intelligence.model": "qwen3:8b"},
)
recipe = trial.to_recipe()
cfg = runner._build_run_config(trial, recipe)
assert "trial-abc" in cfg.output_path
assert cfg.output_path.startswith("out/")
def test_default_model_fallback(self) -> None:
runner = TrialRunner(benchmark="supergpqa")
trial = TrialConfig(trial_id="t9")
recipe = trial.to_recipe()
cfg = runner._build_run_config(trial, recipe)
assert cfg.model == "default"
def test_default_temperature_fallback(self) -> None:
runner = TrialRunner(benchmark="supergpqa")
trial = TrialConfig(trial_id="t10")
recipe = trial.to_recipe()
cfg = runner._build_run_config(trial, recipe)
assert cfg.temperature == 0.0
class TestRunTrial:
"""TrialRunner.run_trial integration (all eval deps mocked)."""
def _make_summary(self, **overrides) -> RunSummary:
defaults = dict(
benchmark="supergpqa",
category="reasoning",
backend="jarvis-direct",
model="qwen3:8b",
total_samples=50,
scored_samples=48,
correct=40,
accuracy=0.8333,
errors=2,
mean_latency_seconds=1.5,
total_cost_usd=0.10,
total_energy_joules=500.0,
total_input_tokens=10000,
total_output_tokens=5000,
)
defaults.update(overrides)
return RunSummary(**defaults)
@patch("openjarvis.evals.cli._build_scorer")
@patch("openjarvis.evals.cli._build_judge_backend")
@patch("openjarvis.evals.cli._build_dataset")
@patch("openjarvis.evals.cli._build_backend")
@patch("openjarvis.evals.core.runner.EvalRunner")
def test_run_trial_returns_trial_result(
self, mock_runner_cls, mock_build_backend, mock_build_dataset,
mock_build_judge, mock_build_scorer,
) -> None:
summary = self._make_summary()
mock_runner_instance = MagicMock()
mock_runner_instance.run.return_value = summary
mock_runner_cls.return_value = mock_runner_instance
mock_backend = MagicMock()
mock_build_backend.return_value = mock_backend
mock_judge = MagicMock()
mock_build_judge.return_value = mock_judge
runner = TrialRunner(benchmark="supergpqa", max_samples=50)
trial = TrialConfig(
trial_id="t-run",
params={"intelligence.model": "qwen3:8b"},
)
result = runner.run_trial(trial)
assert isinstance(result, TrialResult)
assert result.trial_id == "t-run"
assert result.config is trial
mock_runner_cls.assert_called_once()
mock_runner_instance.run.assert_called_once()
@patch("openjarvis.evals.cli._build_scorer")
@patch("openjarvis.evals.cli._build_judge_backend")
@patch("openjarvis.evals.cli._build_dataset")
@patch("openjarvis.evals.cli._build_backend")
@patch("openjarvis.evals.core.runner.EvalRunner")
def test_run_trial_accuracy_from_summary(
self, mock_runner_cls, mock_build_backend, mock_build_dataset,
mock_build_judge, mock_build_scorer,
) -> None:
summary = self._make_summary(accuracy=0.92)
mock_runner_cls.return_value.run.return_value = summary
mock_build_backend.return_value = MagicMock()
mock_build_judge.return_value = MagicMock()
runner = TrialRunner(benchmark="supergpqa")
trial = TrialConfig(trial_id="t-acc", params={})
result = runner.run_trial(trial)
assert result.accuracy == 0.92
@patch("openjarvis.evals.cli._build_scorer")
@patch("openjarvis.evals.cli._build_judge_backend")
@patch("openjarvis.evals.cli._build_dataset")
@patch("openjarvis.evals.cli._build_backend")
@patch("openjarvis.evals.core.runner.EvalRunner")
def test_run_trial_tokens_summed(
self, mock_runner_cls, mock_build_backend, mock_build_dataset,
mock_build_judge, mock_build_scorer,
) -> None:
summary = self._make_summary(
total_input_tokens=3000,
total_output_tokens=2000,
)
mock_runner_cls.return_value.run.return_value = summary
mock_build_backend.return_value = MagicMock()
mock_build_judge.return_value = MagicMock()
runner = TrialRunner(benchmark="supergpqa")
trial = TrialConfig(trial_id="t-tok", params={})
result = runner.run_trial(trial)
assert result.total_tokens == 5000
@patch("openjarvis.evals.cli._build_scorer")
@patch("openjarvis.evals.cli._build_judge_backend")
@patch("openjarvis.evals.cli._build_dataset")
@patch("openjarvis.evals.cli._build_backend")
@patch("openjarvis.evals.core.runner.EvalRunner")
def test_run_trial_summary_attached(
self, mock_runner_cls, mock_build_backend, mock_build_dataset,
mock_build_judge, mock_build_scorer,
) -> None:
summary = self._make_summary()
mock_runner_cls.return_value.run.return_value = summary
mock_build_backend.return_value = MagicMock()
mock_build_judge.return_value = MagicMock()
runner = TrialRunner(benchmark="supergpqa")
trial = TrialConfig(trial_id="t-sum", params={})
result = runner.run_trial(trial)
assert result.summary is summary
@patch("openjarvis.evals.cli._build_scorer")
@patch("openjarvis.evals.cli._build_judge_backend")
@patch("openjarvis.evals.cli._build_dataset")
@patch("openjarvis.evals.cli._build_backend")
@patch("openjarvis.evals.core.runner.EvalRunner")
def test_run_trial_failure_modes_on_errors(
self, mock_runner_cls, mock_build_backend, mock_build_dataset,
mock_build_judge, mock_build_scorer,
) -> None:
summary = self._make_summary(errors=5)
mock_runner_cls.return_value.run.return_value = summary
mock_build_backend.return_value = MagicMock()
mock_build_judge.return_value = MagicMock()
runner = TrialRunner(benchmark="supergpqa")
trial = TrialConfig(trial_id="t-err", params={})
result = runner.run_trial(trial)
assert len(result.failure_modes) == 1
assert "5" in result.failure_modes[0]
@patch("openjarvis.evals.cli._build_scorer")
@patch("openjarvis.evals.cli._build_judge_backend")
@patch("openjarvis.evals.cli._build_dataset")
@patch("openjarvis.evals.cli._build_backend")
@patch("openjarvis.evals.core.runner.EvalRunner")
def test_run_trial_no_failure_modes_when_clean(
self, mock_runner_cls, mock_build_backend, mock_build_dataset,
mock_build_judge, mock_build_scorer,
) -> None:
summary = self._make_summary(errors=0)
mock_runner_cls.return_value.run.return_value = summary
mock_build_backend.return_value = MagicMock()
mock_build_judge.return_value = MagicMock()
runner = TrialRunner(benchmark="supergpqa")
trial = TrialConfig(trial_id="t-ok", params={})
result = runner.run_trial(trial)
assert result.failure_modes == []
@patch("openjarvis.evals.cli._build_scorer")
@patch("openjarvis.evals.cli._build_judge_backend")
@patch("openjarvis.evals.cli._build_dataset")
@patch("openjarvis.evals.cli._build_backend")
@patch("openjarvis.evals.core.runner.EvalRunner")
def test_run_trial_closes_backends(
self, mock_runner_cls, mock_build_backend, mock_build_dataset,
mock_build_judge, mock_build_scorer,
) -> None:
summary = self._make_summary()
mock_runner_cls.return_value.run.return_value = summary
mock_backend = MagicMock()
mock_build_backend.return_value = mock_backend
mock_judge = MagicMock()
mock_build_judge.return_value = mock_judge
runner = TrialRunner(benchmark="supergpqa")
trial = TrialConfig(trial_id="t-close", params={})
runner.run_trial(trial)
mock_backend.close.assert_called_once()
mock_judge.close.assert_called_once()