"""Challenging end-to-end test for the agentic SkillManager. Runs the full ACE pipeline (Agent → Evaluate → Reflect → Update) over a math-word-problem benchmark for multiple epochs. Measures whether the skillbook actually improves the Agent's accuracy and whether the SM keeps the skillbook hygienic across many mutations. Usage:: uv run python test_sm_e2e.py LIVE_E2E_MODEL=bedrock/us.anthropic.claude-sonnet-4-6 uv run python test_sm_e2e.py LIVE_E2E_EPOCHS=3 uv run python test_sm_e2e.py Default: haiku 4.5 on Bedrock, 2 epochs, 15 samples. ~60–90 LLM calls. """ from __future__ import annotations import os import statistics import sys import time from collections import Counter from typing import Any from dotenv import find_dotenv, load_dotenv load_dotenv(find_dotenv()) from ace import ( ACE, Agent, Reflector, Sample, SimpleEnvironment, SkillManager, Skillbook, ) from ace.core.recursive_agent import AgenticConfig from ace.deduplication.detector import SimilarityDetector from ace.protocols.deduplication import DeduplicationConfig MODEL = os.environ.get( "LIVE_E2E_MODEL", "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0" ) EPOCHS = int(os.environ.get("LIVE_E2E_EPOCHS", "2")) # Math-word-problem benchmark. Mix of one-step, multi-step, and subtle # traps (percentage base, unit conversion, order of operations). Small # enough to run in a few minutes; large enough that single-sample noise # doesn't dominate the signal. # Adversarial benchmark — hand-picked problems that frontier small models # frequently miss (classic traps, subtle word-problem wording, counting that # looks easy but rewards systematic care). Ground truths are concise so # SimpleEnvironment's substring match is robust. SAMPLES: list[Sample] = [ # Classic "Boy-born-on-Tuesday" conditional-probability twist (without # the day-of-week qualifier; the twist is recognising the 1/3 answer). Sample( question=( "A family has two children. You learn that at least one of them is " "a boy. What is the probability that both children are boys? " "Assume boys and girls are equally likely and independent. " "Answer as a fraction in lowest terms." ), ground_truth="1/3", ), # Monty Hall — frequently missed. Sample( question=( "In the Monty Hall problem with three doors (one car, two goats), " "you pick a door, the host opens a different door revealing a goat, " "and offers to let you switch. What is the probability of winning " "the car if you always switch? Answer as a fraction." ), ground_truth="2/3", ), # Birthday-ish Sample( question=( "In a group of 23 people, what is the probability that at least two " "share a birthday (ignoring leap years)? Round to 2 decimal places." ), ground_truth="0.51", ), # Percentage-base trap Sample( question=( "A store raises a $50 item by 20%, then lowers the new price by 20%. " "What is the final price in dollars?" ), ground_truth="48", ), # Reverse-percentage — the trap is confusing discount base. Sample( question=( "An item is sold for $63 after a 30% discount. What was the original " "price in dollars?" ), ground_truth="90", ), # Compound interest with trap. Sample( question=( "You invest $1000 at 10% annual interest, compounded annually, for " "3 years. What is the final value in dollars? Round to the nearest " "whole dollar." ), ground_truth="1331", ), # Rate word problem — classic "work together" trap. Sample( question=( "Alice can paint a room in 6 hours. Bob can paint the same room in " "4 hours. How many hours would it take them working together? " "Answer as a fraction in lowest terms." ), ground_truth="12/5", ), # Age problem — multi-step algebra with a slightly awkward wording. Sample( question=( "Mary is twice as old as her brother. In 10 years, she will be 1.5 " "times his age. How old is Mary now?" ), ground_truth="20", ), # Counting / combinatorics trap. Sample( question=( "How many different ways can the letters of the word 'MISSISSIPPI' " "be arranged?" ), ground_truth="34650", ), # Combinatorics — distinct-handshakes. Sample( question=( "Ten people at a party each shake hands exactly once with every " "other person. How many handshakes occur in total?" ), ground_truth="45", ), # Number-theory trap: trailing zeros in 100! Sample( question="How many trailing zeros are in 100 factorial (100!)?", ground_truth="24", ), # Classic rate problem — mixture. Sample( question=( "How many liters of pure water must be added to 30 liters of a 40% " "salt solution to dilute it to a 25% salt solution?" ), ground_truth="18", ), # Rate-distance-time with unit trap. Sample( question=( "A train travels 150 kilometers in 1 hour 15 minutes. What is its " "speed in kilometers per hour?" ), ground_truth="120", ), # Averages trap. Sample( question=( "A student's average on four tests is 85. What score on a fifth " "test would raise her average to 87?" ), ground_truth="95", ), # Geometry — area of annulus. Sample( question=( "A circular ring (annulus) has an outer radius of 10 and an inner " "radius of 6. What is its area? Express in terms of pi." ), ground_truth="64pi", ), # Logic/wording trap — classic "how many X does each sibling have". Sample( question=( "If a brother has as many sisters as brothers, and each of his " "sisters has twice as many brothers as sisters, how many boys and " "girls are in the family? Give the total number of children." ), ground_truth="7", ), # Rate problem with ratio twist. Sample( question=( "If it takes 5 machines 5 minutes to make 5 widgets, how long would " "it take 100 machines to make 100 widgets? Answer in minutes." ), ground_truth="5", ), # Lily-pad doubling trap. Sample( question=( "A lily pad doubles in size every day. It takes 48 days to cover a " "pond. On what day did it cover half the pond?" ), ground_truth="47", ), # Bat-and-ball cost — CRT classic. Sample( question=( "A bat and a ball cost $1.10 in total. The bat costs $1.00 more " "than the ball. How much does the ball cost in cents?" ), ground_truth="5", ), # Geometric — Pythagorean with subtle unit. Sample( question=( "A 13-foot ladder leans against a wall. The bottom is 5 feet from " "the wall. How high up the wall does the ladder reach, in feet?" ), ground_truth="12", ), ] def _accuracy(results: list[Any]) -> tuple[float, int, int]: correct = 0 total = 0 for r in results: ctx = getattr(r, "output", None) if ctx is None or ctx.agent_output is None or ctx.sample is None: continue gt = (ctx.sample.ground_truth or "").strip().lower() ans = (ctx.agent_output.final_answer or "").strip().lower() if not gt: continue if gt in ans: correct += 1 total += 1 return (correct / total if total else 0.0, correct, total) def _operation_histogram(skillbook: Skillbook) -> Counter: """Count operations implied by current skillbook state. We infer from the skills that exist: an ADD was performed for each active skill. UPDATEs and TAGs aren't visible from the final state alone — we track them separately via SM outputs.""" return Counter({"skills_end": len(skillbook.skills())}) def _counter_stats(skillbook: Skillbook) -> dict[str, float]: skills = skillbook.skills() if not skills: return {} return { "skills_total": len(skills), "used_sum": sum(s.used_count for s in skills), "helpful_sum": sum(s.helpful_count for s in skills), "harmful_sum": sum(s.harmful_count for s in skills), "neutral_sum": sum(s.neutral_count for s in skills), "used_mean": statistics.mean(s.used_count for s in skills), "helpful_mean": statistics.mean(s.helpful_count for s in skills), "harmful_mean": statistics.mean(s.harmful_count for s in skills), } def _near_duplicate_pairs(skillbook: Skillbook, threshold: float = 0.85) -> int: """Count pairs of active skills with cosine similarity >= threshold.""" detector = SimilarityDetector(DeduplicationConfig()) detector.ensure_embeddings(skillbook) skills = [s for s in skillbook.skills() if s.embedding is not None] pairs = 0 for i in range(len(skills)): for j in range(i + 1, len(skills)): sim = detector.cosine_similarity(skills[i].embedding, skills[j].embedding) if sim >= threshold: pairs += 1 return pairs def _print_header(msg: str) -> None: bar = "=" * 72 print(f"\n{bar}\n{msg}\n{bar}") def _print_skills(skillbook: Skillbook, limit: int = 40) -> None: for s in skillbook.skills()[:limit]: counters = f"(u={s.used_count},+{s.helpful_count},-{s.harmful_count},={s.neutral_count})" content = s.content[:90] + ("…" if len(s.content) > 90 else "") print(f" [{s.id}] {counters} {content}") def main() -> int: _print_header(f"E2E SkillManager test — model={MODEL} epochs={EPOCHS} N={len(SAMPLES)}") skillbook = Skillbook() ace = ACE.from_roles( agent=Agent(MODEL), reflector=Reflector(MODEL), skill_manager=SkillManager( MODEL, config=AgenticConfig(max_requests=12) ), environment=SimpleEnvironment(), skillbook=skillbook, ) per_epoch_accuracy: list[float] = [] per_epoch_skill_count: list[int] = [] per_epoch_stats: list[dict[str, float]] = [] wall_times: list[float] = [] for epoch in range(1, EPOCHS + 1): _print_header(f"Epoch {epoch}/{EPOCHS}") t0 = time.time() results = ace.run(SAMPLES, epochs=1) wall = time.time() - t0 wall_times.append(wall) acc, correct, total = _accuracy(results) per_epoch_accuracy.append(acc) per_epoch_skill_count.append(len(skillbook.skills())) per_epoch_stats.append(_counter_stats(skillbook)) print(f" accuracy: {acc:.2%} ({correct}/{total})") print(f" skillbook: {len(skillbook.skills())} skills") print(f" wall: {wall:.1f}s") _print_skills(skillbook, limit=8) # Final diagnostics _print_header("Final skillbook") _print_skills(skillbook, limit=40) _print_header("Hygiene metrics") dup_pairs = _near_duplicate_pairs(skillbook) stats = _counter_stats(skillbook) print(f" near-duplicate pairs (cos>=0.85): {dup_pairs}") print(f" counter stats: {stats}") _print_header("Summary") print(f" model: {MODEL}") print(f" samples: {len(SAMPLES)} epochs: {EPOCHS}") for i, (acc, count, wall) in enumerate( zip(per_epoch_accuracy, per_epoch_skill_count, wall_times), 1 ): print(f" epoch {i}: acc={acc:.2%} skills={count} wall={wall:.1f}s") # Pass criteria print() checks: list[tuple[str, bool, str]] = [] # 1. No regression: final epoch accuracy >= first epoch (allow a small slack for noise) if len(per_epoch_accuracy) >= 2: delta = per_epoch_accuracy[-1] - per_epoch_accuracy[0] noise_slack = 1.0 / len(SAMPLES) # 1-sample worth of noise no_regression = delta >= -noise_slack checks.append( ( "no accuracy regression vs. epoch 1", no_regression, f"Δ = {delta:+.2%} (slack ±{noise_slack:.2%})", ) ) # 2. Skillbook is bounded — shouldn't blow up past 2 skills per sample processed max_reasonable_skills = len(SAMPLES) * EPOCHS * 2 bounded = len(skillbook.skills()) <= max_reasonable_skills checks.append( ( "skillbook size bounded", bounded, f"{len(skillbook.skills())} skills / ceiling {max_reasonable_skills}", ) ) # 3. At least some skills were created — otherwise SM isn't working any_skills = len(skillbook.skills()) >= 1 checks.append( ( "SM created >=1 skill", any_skills, f"final skills = {len(skillbook.skills())}", ) ) # 4. Near-duplicate rate stays low dup_rate_ok = len(skillbook.skills()) == 0 or dup_pairs / max(len(skillbook.skills()), 1) < 0.3 checks.append( ( "near-duplicate rate <30% of skill count", dup_rate_ok, f"{dup_pairs} pairs / {len(skillbook.skills())} skills", ) ) # 5. Some skills got used (Agent step bumped used_count) — sanity on injection tracking if len(skillbook.skills()) >= 1: any_used = any(s.used_count > 0 for s in skillbook.skills()) # used_count only ticks on skills that existed *before* the Agent runs, # so epoch-1 skills will only be used in epoch 2. Only assert when epochs>=2. if EPOCHS >= 2: checks.append( ( "injected_skill_ids bumped used_count", any_used, f"{sum(1 for s in skillbook.skills() if s.used_count > 0)} skills have used_count>0", ) ) all_pass = all(passed for _, passed, _ in checks) for name, passed, detail in checks: mark = "PASS" if passed else "FAIL" print(f" [{mark}] {name} — {detail}") print() if all_pass: print("All checks passed.") return 0 else: print("One or more checks failed.") return 1 if __name__ == "__main__": sys.exit(main())