AgentSquares / compare_extractors.py
DHDRL's picture
Upload 107 files
6cb442b verified
Raw History Blame Contribute Delete
10.4 kB
"""Rule-based vs LLM extractors (multi-provider) on the fixtures, same agent, same scorer.
python compare_extractors.py --providers flint --limit 5 --sleep 4
python compare_extractors.py --mock # plumbing check only (not a result)
Options: --dev-only | --heldout-only, --only ID[,ID...] (e.g. --only s18 or --only real10,real11),
--limit N (new scenarios per provider this run), --sleep S, --fresh, --allow-heldout-rerun
(voids the "scored once" held-out lock — see README).
Partial results go in results/extractor_compare_state.json, and reruns skip finished (provider, scenario) pairs.
A provider without a key, or one marked unsupported, is SKIPPED with a reason. No score is ever filled in.
Per-scenario LLM errors fall back to the rule extractor inside LLMExtractor, so llm_errors are counted and shown."""
import argparse, hashlib, json, os, sys, time
from agent import Config, run
from eval_harness import bounds_for, load_scenarios, score, summary
from bounds import load_bounds
from interfaces import FixedPlanner, LLMExtractor, LLMPlanner, MockLLMClient, OpenAICompatClient, RegexGuardExtractor, RuleExtractor
MAX_FALLBACK = 0.10 # above this share of chunks falling back to rules, a row is NOT an LLM result
from tools import MockTools
STATE = "results/extractor_compare_state.json"
LOCK = "results/heldout_scored.json"
CODE_FILES = ("interfaces.py", "tools.py", "agent.py", "bounds.py", "packing.py", "providers.json")
def code_hash():
h = hashlib.sha256()
for f in CODE_FILES:
with open(f, "rb") as fh:
h.update(fh.read())
return h.hexdigest()[:16]
def run_one(sc, extractor, planner, base):
ms = sc.get("max_steps", 8)
rep = run(sc["prompt"], MockTools(sc["tools"]), bounds_for(sc, base), ms, Config(extractor=extractor, planner=planner))
r = score(rep, sc["expected"], ms)
r.update(id=sc["id"], held_out=sc["held_out"], llm_errors=sum("llm error" in n for n in rep["notes"]),
llm_chunks=sum(("llm error" in n) or ("llm chunk ok" in n) for n in rep["notes"]))
return r
def _provider_cfg(cfg, name):
p = dict(cfg.get(name) or {})
loc_path = "fixtures/private_unredacted/providers.local.json"
if os.path.exists(loc_path):
loc = (json.load(open(loc_path)).get(name) or {})
p.update({k: v for k, v in loc.items() if not str(k).startswith("_")})
return p
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--providers", default=""); ap.add_argument("--config", default="providers.json")
ap.add_argument("--mock", action="store_true"); ap.add_argument("--limit", type=int)
ap.add_argument("--sleep", type=float); ap.add_argument("--fresh", action="store_true")
ap.add_argument("--only", default="", help="comma-separated scenario ids to keep (e.g. s18 or real10,real11)")
ap.add_argument("--allow-heldout-rerun", action="store_true",
help="voids the held-out scored-once lock (results no longer count as scored once)")
ap.add_argument("--with-synthetic", action="store_true", help="include synthetic scenarios alongside real")
g = ap.add_mutually_exclusive_group(); g.add_argument("--dev-only", action="store_true"); g.add_argument("--heldout-only", action="store_true")
a = ap.parse_args()
split = "dev" if a.dev_only else "heldout" if a.heldout_only else "all"
# --only may name synthetic (s18) or real (real10) ids; without it, keep the real-fixture default.
scs = load_scenarios(split, real=None if (a.only or a.with_synthetic) else True)
if a.only:
want = {x.strip() for x in a.only.split(",") if x.strip()}
scs = [sc for sc in scs if sc["id"] in want or any(sc["id"].startswith(w) for w in want)]
if not scs:
raise SystemExit(f"--only matched no scenarios: {sorted(want)}")
watch = set(scs[0]["prompt"]["n"]) if scs else None
cfg = json.load(open(a.config))
base = load_bounds("bounds.json")
os.makedirs("results", exist_ok=True)
ch = code_hash()
state = {} if a.fresh or not os.path.exists(STATE) else json.load(open(STATE))
if state.get("_code_hash") != ch:
if any(k for k in state if not str(k).startswith("_")):
print("code/prompt/provider config changed: discarding resumed results")
state = {"_code_hash": ch}
else:
state["_code_hash"] = ch
lock = {} if not os.path.exists(LOCK) else json.load(open(LOCK))
state["rule"] = {sc["id"]: run_one(sc, RuleExtractor(), FixedPlanner(), base) for sc in scs}
state["regex_guards"] = {sc["id"]: run_one(sc, RegexGuardExtractor(), FixedPlanner(), base) for sc in scs}
meta, skipped = {}, {}
names = (["mock"] if a.mock else []) + [p for p in a.providers.split(",") if p]
for name in names:
if name == "mock":
client = MockLLMClient([{"claims": [], "queries": []}]); meta[name] = {"model": "mock (plumbing only, NOT a result)"}
else:
p = _provider_cfg(cfg, name)
if not p: skipped[name] = "not in config"; continue
if not p.get("supported", True): skipped[name] = p.get("reason", "unsupported"); continue
env = p.get("env", "SQ_LLM_API_KEY")
key = os.environ.get(env, "")
if not key: skipped[name] = f"${env} not set"; continue
model = p.get("model") or os.environ.get(p.get("model_env", "SQ_LLM_MODEL"), "")
base_url = p.get("base_url") or os.environ.get(p.get("base_url_env", "SQ_LLM_BASE_URL"), "")
if not model or not base_url:
skipped[name] = "model/base_url missing (set env or private local config)"; continue
client = OpenAICompatClient(model=model, base_url=base_url, api_key=key,
sleep_s=a.sleep if a.sleep is not None else p.get("sleep_s", 1.0),
timeout=p.get("timeout", 120),
json_mode=p.get("json_mode", True))
meta[name] = {"model": model, "cost": p.get("cost")}
done = state.setdefault(name, {}); n_new = 0
if not isinstance(done, dict):
done = {}; state[name] = done
for sc in scs:
if sc["id"] in done: continue
if a.limit is not None and n_new >= a.limit: break
if sc.get("held_out") and name != "mock" and name in lock and lock[name].get("code_hash") != ch and not a.allow_heldout_rerun:
skipped[name] = "held-out already scored under different code; pass --allow-heldout-rerun to override"
break
try:
done[sc["id"]] = run_one(sc, LLMExtractor(client, watch=watch), LLMPlanner(client), base)
except Exception as e: # unexpected failure: stop this provider, keep what we have
skipped[name] = f"stopped at {sc['id']}: {type(e).__name__}: {e}"[:200]; break
n_new += 1
if sc.get("held_out") and name != "mock":
entry = lock.get(name) or {"code_hash": ch, "arm": name, "held_out_scenarios": []}
entry["code_hash"] = ch
entry["arm"] = name
hs = list(entry.get("held_out_scenarios") or [])
if sc["id"] not in hs:
hs.append(sc["id"])
entry["held_out_scenarios"] = hs
lock[name] = entry
json.dump(lock, open(LOCK, "w"), indent=1)
json.dump(state, open(STATE, "w"), indent=1) # checkpoint after every scenario
meta[name]["usage_this_run"] = getattr(client, "usage", {})
json.dump(state, open(STATE, "w"), indent=1)
ids = [sc["id"] for sc in scs]
lines = [f"# Extractor comparison (local-run), split={split}",
"", "| system | model | scenarios done | pass | Wilson 95% | false imp | false cand | missed cand | llm fallback chunks | status |",
"|---|---|---|---|---|---|---|---|---|---|"]
rows = {}
name_list = ["rule", "regex_guards"] + [n for n in names if (n in state and n not in skipped) or n in meta]
for name in name_list:
if str(name).startswith("_"): continue
bucket = state.get(name, {})
if not isinstance(bucket, dict): continue
R = [bucket[i] for i in ids if i in bucket]
if not R: continue
s = summary(R, name)
fb, chs = sum(r.get("llm_errors", 0) for r in R), sum(r.get("llm_chunks", 0) for r in R)
is_llm = name not in ("rule", "regex_guards")
if is_llm and (chs == 0 or fb / chs > MAX_FALLBACK):
s["status"] = f"INSUFFICIENT: {fb}/{chs} chunks fell back to rules (> {MAX_FALLBACK:.0%}); not an LLM result"
lines.append(f"| {name} | {meta.get(name, {}).get('model', '?')} | {len(R)}/{len(ids)} | - | - | - | - | - | {fb}/{chs} | {s['status']} |")
else:
s["status"] = "ok" if is_llm else "deterministic"
lines.append(f"| {name} | {meta.get(name, {}).get('model', 'regex' if name == 'regex_guards' else 'rules')} | {len(R)}/{len(ids)} | {s['pass']} | {s['wilson95']} | "
f"{s['false_improvements']} | {s['false_candidates']} | {s['missed_candidates']} | {fb}/{chs} | {s['status']} |")
rows[name] = s
lines.append("")
lines += [f"- SKIPPED {k}: {v}" for k, v in skipped.items()]
lines.append("- Partial rows (scenarios done < total) only cover those scenarios and can't be compared head to head yet.")
lines.append("- --allow-heldout-rerun voids the held-out 'scored once' guarantee.")
for name in [k for k in rows if not str(rows[k]["status"]).startswith("INSUFFICIENT")]:
for i in ids:
bucket = state.get(name, {})
r = bucket.get(i) if isinstance(bucket, dict) else None
if r and not r["pass"]: lines.append(f"- {name} {'*' if r['held_out'] else ''}{i}: {'; '.join(r['fails'])}")
md = "\n".join(lines); print(md)
ts = "local"
open(f"results/extractor_compare_{ts}.md", "w").write(md + "\n")
per = {k: state[k] for k in rows if not str(k).startswith("_")}
json.dump({"split": split, "summary": rows, "skipped": skipped, "meta": meta, "code_hash": ch,
"per_scenario": per}, open(f"results/extractor_compare_{ts}.json", "w"), indent=1)
if __name__ == "__main__":
main()