File size: 10,136 Bytes
6cb442b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | """Bounded monitoring agent: search -> fetch (fresh first) -> extract -> validate -> compare -> report.
Planner is deterministic by default (no LLM spend). All comparison, deltas, conflicts,
staleness and geometry are deterministic. Optional LLM extraction via Config(extractor=...); see providers.json and SQ_LLM_* env vars.
Usage: python agent.py --demo (runs one offline fixture end to end)
"""
from __future__ import annotations
import argparse
import json
import math
import time
from dataclasses import dataclass
from bounds import IMPROVEMENT_MATERIAL, float_near_bound_margin, Bound, classify, find_conflicts, rel_score, load_bounds
from packing import parse_squares, validate
from tools import ToolError, eval_side
from baseline_regex import extract_naive
@dataclass
class Config:
mode: str = "agent" # "agent" | "regex_guards" | "baseline"
retries: int = 1 # retry once on tool error, then degrade
backoff_s: float = 0.0 # set >0 for real network
metric_tol: float = 5e-4 # tolerance for reported relative score vs recomputed (rounding)
extractor: object = None # interfaces.Extractor; default RuleExtractor
planner: object = None # interfaces.Planner; default FixedPlanner
def _call(fn, arg, state, cfg):
"""Execute a tool with budget accounting and retry/backoff. Returns (ok, result)."""
attempts = 1 + (cfg.retries if cfg.mode != "baseline" else 0)
last = None
for k in range(attempts):
if state["tool_calls"] >= state["max_steps"]:
state["budget_exhausted"] = True
return False, last or "budget exhausted"
state["tool_calls"] += 1
try:
return True, fn(arg)
except ToolError as e:
last = str(e)
if cfg.backoff_s and k + 1 < attempts:
time.sleep(cfg.backoff_s * (2 ** k))
state["errors"].append(last)
return False, last
def _validate_claim(c):
d = c["packing"]
try:
side = eval_side(d["side"])
sq = parse_squares(d["squares"])
return validate(sq, side, expected_n=c["n"])
except Exception as e:
return {"valid": False, "mode": "unparsed", "proof": False, "issues": [f"parse error: {e}"]}
def run(prompt, tools, bounds, max_steps=8, cfg: Config | None = None):
cfg = cfg or Config()
wanted = set(prompt.get("n", [])) or None
state = {"tool_calls": 0, "max_steps": max_steps, "errors": [], "budget_exhausted": False,
"notes": [], "claims": [], "fetched": []}
from interfaces import FixedPlanner, RegexGuardExtractor, RuleExtractor
planner = cfg.planner or FixedPlanner()
extractor = cfg.extractor or (RegexGuardExtractor() if cfg.mode == "regex_guards" else RuleExtractor())
agentic = cfg.mode != "baseline" # regex_guards gets every agent guard; only the extractor differs
# plan step 1: search (one tool call per planned query; all count against the budget)
results, any_ok = [], False
for q in (planner.plan(prompt) if cfg.mode != "baseline" else FixedPlanner().plan(prompt)):
ok, r = _call(tools.search, q, state, cfg)
if ok:
any_ok = True; results += r
if any_ok and not results:
state["errors"].append("search returned no sources")
# plan step 2: fresh first; agent also skips duplicate URLs
if cfg.mode != "baseline":
uniq = {r["url"]: r for r in results}
results = sorted(uniq.values(), key=lambda r: r.get("date", ""), reverse=True)
for i, meta in enumerate(results):
if state["tool_calls"] >= max_steps:
state["budget_exhausted"] = True
state["errors"].append(f"budget exhausted: {len(results) - i} source(s) not fetched")
break
ok, text = _call(tools.fetch, meta["url"], state, cfg)
if not ok:
continue
state["fetched"].append(meta["url"])
if cfg.mode == "baseline":
cl, notes = extract_naive(text, meta)
else:
cl, notes = extractor.extract(text, meta)
state["claims"] += cl
state["notes"] += [f"{meta['url']}: {x}" for x in notes]
return build_report(state, bounds, wanted, cfg)
def build_report(state, bounds, wanted, cfg):
claims = [c for c in state["claims"] if wanted is None or c["n"] in wanted]
rep = {"timestamp": "run-local",
"improvements": [], "candidates": [], "conflicts": [], "partial": [], "unchanged": [], "no_claim": [],
"worse": [], "invalid": [], "rejected": [], "stale": [], "no_baseline": [],
"metric_mismatch": [], "errors": list(state["errors"]), "notes": state["notes"],
"tool_calls": state["tool_calls"], "budget_exhausted": state["budget_exhausted"],
"sources_cited": sorted(set(state["fetched"])), "summary": ""}
agentic = cfg.mode != "baseline"
if agentic:
rep["rejected"] = [{k: c[k] for k in ("n", "s", "source_url", "rejected")}
for c in claims if c.get("rejected")]
claims = [c for c in claims if not c.get("rejected")]
# staleness: per (source_key, n) keep only the newest claim
newest = {}
for c in claims:
k = (c["source_key"], c["n"])
if k not in newest or c["date"] > newest[k]["date"]:
newest[k] = c
keep = {id(c) for c in newest.values()}
stale = [c for c in claims if id(c) not in keep and
c["date"] < newest[(c["source_key"], c["n"])]["date"]]
stale_ids = {id(c) for c in stale}
rep["stale"] = [{k: c[k] for k in ("n", "s", "source_url", "date")} for c in stale]
claims = [c for c in claims if id(c) not in stale_ids]
for c in claims:
b = bounds.get(c["n"])
val = _validate_claim(c) if (agentic and c["coordinates_available"]) else None
if cfg.mode == "baseline": # baseline trusts any lower number
val = {"valid": True, "mode": "none", "proof": False}
status, delta = classify(c["s"], b.s if b else None, val)
if agentic and status == "improvement" and not val.get("proof") and \
-delta <= float_near_bound_margin(c["n"]):
status = "unverified" # float check can't separate this from the bound; only exact/interval promotes
c = dict(c, _float_near_bound=True)
if agentic and status in ("improvement", "unverified") and \
c.get("s_uncertainty", 0) and c["s"] + c["s_uncertainty"] >= b.s - IMPROVEMENT_MATERIAL:
status = "unchanged" # printed precision cannot distinguish the claim from the bound
out = {k: c[k] for k in ("n", "s", "source_url", "source_type", "date", "extracted_text",
"coordinates_available")}
out.update(status=status, delta_vs_bound=delta, bound_source=b.source if b else None,
validation=None if val is None else {k: val.get(k) for k in ("valid", "mode", "proof", "issues")})
if c.get("score") is not None and c["s"] is not None:
rec = rel_score(c["n"], c["s"])
if abs(rec - c["score"]) > cfg.metric_tol:
rep["metric_mismatch"].append({**out, "reported_score": c["score"], "recomputed": rec})
bucket = {"improvement": "improvements", "unverified": "candidates", "invalid": "invalid",
"worse": "worse", "no_baseline": "no_baseline", "unchanged": "unchanged", "partial": None}[status]
if bucket:
rep[bucket].append(out)
if c.get("_float_near_bound"):
out["note"] = "float-validated but within accumulated tolerance of bound; needs exact/interval proof"
if not c["coordinates_available"] and agentic:
rep["partial"].append(out)
rep["conflicts"] = find_conflicts(claims) if agentic else find_conflicts(
[dict(c, source_url=c["source_url"] + f"#{i}") for i, c in enumerate(claims)])
# unchanged = claim present and within +-material of the bound (list of claim dicts, like other buckets).
# no_claim = wanted n with no surviving claim at all ("not checked", NOT "checked, no change").
rep["no_claim"] = sorted((wanted or set()) - {c["n"] for c in claims})
rep["summary"] = summarize(rep)
return rep
def summarize(rep):
lines = [f"{len(rep['improvements'])} validated improvement(s); {len(rep['candidates'])} unverified "
f"candidate(s); {len(rep['unchanged'])} unchanged; {len(rep['worse'])} worse; "
f"{len(rep['no_claim'])} n with no claim; {len(rep['conflicts'])} conflict(s); {len(rep['invalid'])} invalid; "
f"{len(rep['errors'])} error(s); {rep['tool_calls']} tool call(s)."]
for c in rep["improvements"]:
proof = "exact proof" if c["validation"]["proof"] else "float check, not a proof"
lines.append(f"- n={c['n']}: s={c['s']} (delta {c['delta_vs_bound']:+.3g}, {proof}) [{c['source_url']}]")
for c in rep["candidates"]:
lines.append(f"- CANDIDATE n={c['n']}: s={c['s']} below bound, unproven ({c.get('note') or 'no coordinates'}) [{c['source_url']}]")
for c in rep["conflicts"]:
lines.append(f"- CONFLICT n={c['n']}: spread {c['spread']:.3g} across {len(c['claims'])} claims")
if not rep["sources_cited"]:
lines.append("- No sources retrieved: refusing to assert progress (low confidence).")
return "\n".join(lines)
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--demo", action="store_true")
ap.add_argument("--scenario", default="scenarios/s10_true_improvement_exact.json")
a = ap.parse_args()
from tools import MockTools
sc = json.load(open(a.scenario))
bounds = load_bounds("bounds.json")
for n, s in sc.get("bounds_override", {}).items():
bounds[int(n)] = Bound(int(n), s, "synthetic (scenario override)", "", False)
rep = run(sc["prompt"], MockTools(sc["tools"]), bounds, sc.get("max_steps", 8))
print(rep["summary"])
print(json.dumps({k: rep[k] for k in ("improvements", "candidates", "errors", "tool_calls")}, indent=1, default=str)[:3000])
|