Skip to content

Commit bfe8d07

Browse files
savioloboclaude
andcommitted
Phase 4: eval ablation runner — 3-config comparison
Adds the 3-config ablation eval that measures benefit of each layer: - Config 1: Single Groq call — baseline (no multi-agent, no MCP) - Config 2: Multi-agent without MCP (routing + parallel annotation) - Config 3: Full pipeline — multi-agent + Redis examples + guidelines eval/evaluators.py — EvalResult/EvalSummary dataclasses, compute_summary, print_comparison with MAFA/ML reference baselines eval/run_eval.py — CLI runner (--smoke --n --configs --output) tests/test_eval.py — 10 unit tests for metric functions (no API calls) Also: - Add use_mcp=True param to build_annotation_context and annotate/validate so Config 2 can run agents without Redis/guidelines lookup - Fix Makefile eval-smoke to use python -m eval.run_eval Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3e8ccd0 commit bfe8d07

6 files changed

Lines changed: 443 additions & 6 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,4 @@ format:
1515
uv run ruff check --fix .
1616

1717
eval-smoke:
18-
uv run python eval/run_eval.py --smoke --n 10
18+
uv run python -m eval.run_eval --smoke --n 10

agents/primary_annotator.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,16 @@ def label_must_be_valid(cls, v: str) -> str:
5959
return v
6060

6161

62-
def build_annotation_context(query: str) -> str:
62+
def build_annotation_context(query: str, use_mcp: bool = True) -> str:
6363
"""Build the shared user prompt used by Primary and Validator agents."""
6464
valid = list_valid_intents()
6565
intent_list = "\n".join(f"- {e['intent_name']}" for e in valid)
6666

67+
if not use_mcp:
68+
return (
69+
f"Customer query: {query}\n\nValid intent labels (choose exactly one):\n{intent_list}"
70+
)
71+
6772
similar = search_similar_examples(query, k=5)
6873
examples_block = "\n".join(f' "{ex["text"]}" → {ex["intent_name"]}' for ex in similar)
6974

@@ -109,9 +114,9 @@ def _get_client() -> Cerebras:
109114
wait=wait_exponential(multiplier=1, min=2, max=30),
110115
stop=stop_after_attempt(3),
111116
)
112-
def annotate(query: str) -> AnnotatorOutput:
117+
def annotate(query: str, use_mcp: bool = True) -> AnnotatorOutput:
113118
"""Assign an intent label to a banking customer query."""
114-
user_prompt = build_annotation_context(query)
119+
user_prompt = build_annotation_context(query, use_mcp=use_mcp)
115120
response = _get_client().chat.completions.create(
116121
model="qwen-3-235b-a22b-instruct-2507",
117122
messages=[

agents/validator.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,13 @@ def _get_client() -> Cerebras:
5454
wait=wait_exponential(multiplier=1, min=2, max=30),
5555
stop=stop_after_attempt(3),
5656
)
57-
def validate(query: str) -> AnnotatorOutput:
57+
def validate(query: str, use_mcp: bool = True) -> AnnotatorOutput:
5858
"""Independently assign an intent label — blind to any prior annotation."""
5959
response = _get_client().chat.completions.create(
6060
model="qwen-3-235b-a22b-instruct-2507",
6161
messages=[
6262
{"role": "system", "content": _SYSTEM},
63-
{"role": "user", "content": build_annotation_context(query)},
63+
{"role": "user", "content": build_annotation_context(query, use_mcp=use_mcp)},
6464
],
6565
response_format={"type": "json_object"},
6666
temperature=0,

eval/evaluators.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Evaluation metrics for the annotation pipeline."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
7+
from sklearn.metrics import f1_score
8+
9+
10+
@dataclass
11+
class EvalResult:
12+
query_id: str
13+
predicted_label: str
14+
true_label: str
15+
route_to_human: bool = False
16+
elapsed_seconds: float = 0.0
17+
18+
19+
@dataclass
20+
class EvalSummary:
21+
config_name: str
22+
n_items: int
23+
agreement_rate: float
24+
macro_f1: float
25+
human_review_rate: float
26+
items_per_hour: float
27+
total_elapsed_seconds: float
28+
29+
30+
def compute_summary(
31+
config_name: str,
32+
results: list[EvalResult],
33+
total_elapsed: float,
34+
) -> EvalSummary:
35+
n = len(results)
36+
if n == 0:
37+
raise ValueError("No results to summarize")
38+
39+
human_count = sum(1 for r in results if r.route_to_human)
40+
correct = sum(1 for r in results if not r.route_to_human and r.predicted_label == r.true_label)
41+
42+
# Human-routed items use a sentinel that won't match any true label
43+
preds = ["__human__" if r.route_to_human else r.predicted_label for r in results]
44+
trues = [r.true_label for r in results]
45+
mf1 = float(f1_score(trues, preds, average="macro", zero_division=0))
46+
47+
return EvalSummary(
48+
config_name=config_name,
49+
n_items=n,
50+
agreement_rate=correct / n,
51+
macro_f1=mf1,
52+
human_review_rate=human_count / n,
53+
items_per_hour=n / total_elapsed * 3600 if total_elapsed > 0 else 0.0,
54+
total_elapsed_seconds=total_elapsed,
55+
)
56+
57+
58+
def print_comparison(summaries: list[EvalSummary]) -> None:
59+
w = 35
60+
header = (
61+
f"{'Config':<{w}} {'N':>6} {'Agree%':>8} {'Macro-F1':>10} {'Human%':>8} {'items/hr':>10}"
62+
)
63+
print()
64+
print(header)
65+
print("-" * len(header))
66+
for s in summaries:
67+
print(
68+
f"{s.config_name:<{w}} {s.n_items:>6} "
69+
f"{s.agreement_rate * 100:>7.1f}% "
70+
f"{s.macro_f1:>10.4f} "
71+
f"{s.human_review_rate * 100:>7.1f}% "
72+
f"{s.items_per_hour:>10.1f}"
73+
)
74+
print()
75+
print("Reference baselines:")
76+
print(" JP Morgan MAFA (AAAI 2026) 86.0% agreement")
77+
print(" Best supervised ML 87.35% Macro-F1")
78+
print(" Manual annotation cost ~$0.15/item")
79+
print()

eval/run_eval.py

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
"""Ablation runner — 3-config evaluation of the annotation pipeline.
2+
3+
Config 1: Single LLM call, no multi-agent, no MCP (baseline)
4+
Config 2: Multi-agent without MCP (structural benefit only)
5+
Config 3: Full pipeline — multi-agent + MCP guidelines + Redis examples
6+
7+
Usage:
8+
uv run python eval/run_eval.py --smoke --n 10
9+
uv run python eval/run_eval.py --configs 3 --n 50
10+
uv run python eval/run_eval.py --output results.json
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import argparse
16+
import json
17+
import sys
18+
import time
19+
from pathlib import Path
20+
21+
from dotenv import load_dotenv
22+
23+
load_dotenv()
24+
25+
FIXTURES_DIR = Path(__file__).parent.parent / "tests" / "fixtures" / "items"
26+
HOLDOUT_PATH = Path(__file__).parent.parent / "data" / "splits" / "holdout.parquet"
27+
28+
29+
# ---------- item loading ----------
30+
31+
32+
def _load_smoke_items(n: int) -> list[dict]:
33+
items = [json.loads(f.read_text()) for f in sorted(FIXTURES_DIR.glob("item_*.json"))]
34+
return items[:n]
35+
36+
37+
def _load_holdout_items(n: int | None) -> list[dict]:
38+
if not HOLDOUT_PATH.exists():
39+
print(
40+
f"ERROR: {HOLDOUT_PATH} not found. Run eval/datasets.py first.",
41+
file=sys.stderr,
42+
)
43+
sys.exit(1)
44+
import pandas as pd
45+
46+
df = pd.read_parquet(HOLDOUT_PATH)
47+
if n:
48+
df = df.head(n)
49+
return [
50+
{"query_id": row.query_id, "text": row.text, "true_intent": row.category}
51+
for row in df.itertuples()
52+
]
53+
54+
55+
# ---------- Config 1: single LLM ----------
56+
57+
_SINGLE_LLM_SYSTEM = """You are a banking customer service intent classifier.
58+
Assign exactly one intent label from the provided list.
59+
Respond with ONLY valid JSON: {"label": "<intent_name>"}"""
60+
61+
62+
def _run_config1(items: list[dict]) -> tuple[list, float]:
63+
from groq import Groq
64+
65+
from eval.evaluators import EvalResult
66+
from mcp_servers.label_schema_mcp.server import list_valid_intents
67+
68+
client = Groq()
69+
valid = {e["intent_name"] for e in list_valid_intents()}
70+
intent_list = "\n".join(f"- {n}" for n in sorted(valid))
71+
72+
results: list[EvalResult] = []
73+
t_start = time.perf_counter()
74+
75+
for i, item in enumerate(items, 1):
76+
print(f" [1/{len(items)}{i}] {item['query_id']}", end=" ", flush=True)
77+
t0 = time.perf_counter()
78+
try:
79+
resp = client.chat.completions.create(
80+
model="llama-3.1-8b-instant",
81+
messages=[
82+
{"role": "system", "content": _SINGLE_LLM_SYSTEM},
83+
{
84+
"role": "user",
85+
"content": (
86+
f"Customer query: {item['text']}\n\nValid intents:\n{intent_list}"
87+
),
88+
},
89+
],
90+
response_format={"type": "json_object"},
91+
temperature=0,
92+
)
93+
raw = json.loads(resp.choices[0].message.content)
94+
label = raw.get("label", "").strip()
95+
if label not in valid:
96+
label = "__invalid__"
97+
except Exception as exc:
98+
print(f"[ERROR: {exc}]", end=" ")
99+
label = "__error__"
100+
101+
elapsed = time.perf_counter() - t0
102+
print(f"→ {label} ({elapsed:.1f}s)")
103+
results.append(
104+
EvalResult(
105+
query_id=item["query_id"],
106+
predicted_label=label,
107+
true_label=item["true_intent"],
108+
elapsed_seconds=elapsed,
109+
)
110+
)
111+
112+
return results, time.perf_counter() - t_start
113+
114+
115+
# ---------- Config 2: multi-agent, no MCP ----------
116+
117+
118+
def _run_config2(items: list[dict]) -> tuple[list, float]:
119+
from agents.arbitrator import CONFIDENCE_THRESHOLD, arbitrate
120+
from agents.primary_annotator import annotate
121+
from agents.router import route as router_route
122+
from agents.validator import agrees_with, validate
123+
from eval.evaluators import EvalResult
124+
125+
results: list[EvalResult] = []
126+
t_start = time.perf_counter()
127+
128+
for i, item in enumerate(items, 1):
129+
print(f" [2/{len(items)}{i}] {item['query_id']}", end=" ", flush=True)
130+
t0 = time.perf_counter()
131+
query = item["text"]
132+
133+
route_decision = router_route(query)
134+
primary = annotate(query, use_mcp=False)
135+
final_label = primary.label
136+
route_to_human = False
137+
138+
if route_decision.route == "COMPLEX":
139+
validator = validate(query, use_mcp=False)
140+
if not agrees_with(validator, primary.label):
141+
arb = arbitrate(query, primary, validator)
142+
final_label = arb.final_label
143+
route_to_human = arb.confidence < CONFIDENCE_THRESHOLD
144+
145+
elapsed = time.perf_counter() - t0
146+
status = "→human" if route_to_human else f"→ {final_label}"
147+
print(f"{route_decision.route} {status} ({elapsed:.1f}s)")
148+
results.append(
149+
EvalResult(
150+
query_id=item["query_id"],
151+
predicted_label=final_label,
152+
true_label=item["true_intent"],
153+
route_to_human=route_to_human,
154+
elapsed_seconds=elapsed,
155+
)
156+
)
157+
158+
return results, time.perf_counter() - t_start
159+
160+
161+
# ---------- Config 3: full pipeline ----------
162+
163+
164+
def _run_config3(items: list[dict]) -> tuple[list, float]:
165+
from eval.evaluators import EvalResult
166+
from graph.pipeline import build_pipeline
167+
168+
pipeline = build_pipeline()
169+
results: list[EvalResult] = []
170+
t_start = time.perf_counter()
171+
172+
for i, item in enumerate(items, 1):
173+
print(f" [3/{len(items)}{i}] {item['query_id']}", end=" ", flush=True)
174+
t0 = time.perf_counter()
175+
state = pipeline.invoke(
176+
{
177+
"query_id": item["query_id"],
178+
"query": item["text"],
179+
"batch_stats": {},
180+
}
181+
)
182+
elapsed = time.perf_counter() - t0
183+
final_label = state.get("final_label", "__none__")
184+
route_to_human = bool(state.get("route_to_human"))
185+
status = "→human" if route_to_human else f"→ {final_label}"
186+
print(f"{state.get('route', '?')} {status} ({elapsed:.1f}s)")
187+
results.append(
188+
EvalResult(
189+
query_id=item["query_id"],
190+
predicted_label=final_label,
191+
true_label=item["true_intent"],
192+
route_to_human=route_to_human,
193+
elapsed_seconds=elapsed,
194+
)
195+
)
196+
197+
return results, time.perf_counter() - t_start
198+
199+
200+
# ---------- main ----------
201+
202+
_CONFIG_NAMES = {
203+
"1": "Config 1: Single LLM (baseline)",
204+
"2": "Config 2: Multi-agent, no MCP",
205+
"3": "Config 3: Full pipeline (MCP)",
206+
}
207+
208+
_CONFIG_FNS = {
209+
"1": _run_config1,
210+
"2": _run_config2,
211+
"3": _run_config3,
212+
}
213+
214+
215+
def main() -> None:
216+
parser = argparse.ArgumentParser(description="Run ablation eval on the annotation pipeline")
217+
parser.add_argument("--smoke", action="store_true", help="Use fixture items instead of holdout")
218+
parser.add_argument("--n", type=int, default=10, help="Number of items to evaluate")
219+
parser.add_argument(
220+
"--configs",
221+
nargs="+",
222+
choices=["1", "2", "3"],
223+
default=["1", "2", "3"],
224+
metavar="N",
225+
help="Which configs to run (default: 1 2 3)",
226+
)
227+
parser.add_argument("--output", type=str, default=None, help="Save results to JSON file")
228+
args = parser.parse_args()
229+
230+
if args.smoke:
231+
items = _load_smoke_items(args.n)
232+
print(f"Smoke mode: {len(items)} fixture items")
233+
else:
234+
items = _load_holdout_items(args.n)
235+
print(f"Holdout mode: {len(items)} items")
236+
237+
if not items:
238+
print("No items to evaluate.", file=sys.stderr)
239+
sys.exit(1)
240+
241+
from eval.evaluators import compute_summary, print_comparison
242+
243+
all_results: dict[str, list] = {}
244+
all_summaries = []
245+
246+
for cfg in args.configs:
247+
print(f"\nRunning {_CONFIG_NAMES[cfg]} ...")
248+
results, elapsed = _CONFIG_FNS[cfg](items)
249+
summary = compute_summary(_CONFIG_NAMES[cfg], results, elapsed)
250+
all_results[cfg] = [vars(r) for r in results]
251+
all_summaries.append(summary)
252+
253+
print_comparison(all_summaries)
254+
255+
if args.output:
256+
out_path = Path(args.output)
257+
payload = {
258+
"summaries": [vars(s) for s in all_summaries],
259+
"per_config_results": all_results,
260+
}
261+
out_path.write_text(json.dumps(payload, indent=2))
262+
print(f"Results saved to {out_path}")
263+
264+
265+
if __name__ == "__main__":
266+
main()

0 commit comments

Comments
 (0)