logic-engine / test_sm_e2e.py
ghostdrive1's picture
Upload folder using huggingface_hub
116524e verified
Raw
History Blame Contribute Delete
15.1 kB
"""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())