Spaces:
Running
Running
readme + dash cleanup in source
Browse files- src/selfheal_sql/audit_eval.py +107 -107
- src/selfheal_sql/gate_decision.py +115 -115
- src/selfheal_sql/heal.py +270 -270
- src/selfheal_sql/train_dpo.py +211 -211
src/selfheal_sql/audit_eval.py
CHANGED
|
@@ -1,107 +1,107 @@
|
|
| 1 |
-
"""AuditKIT agent_eval over a student run: independent outcome verification.
|
| 2 |
-
|
| 3 |
-
Each logged run row becomes a recorded AgentEpisode (user prompt -> assistant raw
|
| 4 |
-
output). The outcome oracle is a CustomPredicate that RE-EXECUTES the model's SQL
|
| 5 |
-
against the real database and compares with the gold result
|
| 6 |
-
fresh execution, not a read of our logged status. We then cross-check AuditKIT's
|
| 7 |
-
verdicts against the harness log (they must agree 100%).
|
| 8 |
-
|
| 9 |
-
Usage: python audit_eval.py --run baseline-test --split-file ../../data/splits/test.jsonl
|
| 10 |
-
"""
|
| 11 |
-
|
| 12 |
-
from __future__ import annotations
|
| 13 |
-
|
| 14 |
-
import argparse
|
| 15 |
-
import json
|
| 16 |
-
import os
|
| 17 |
-
import sys
|
| 18 |
-
from collections import Counter
|
| 19 |
-
|
| 20 |
-
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 21 |
-
sys.path.insert(0, HERE)
|
| 22 |
-
from execcheck import check # noqa: E402
|
| 23 |
-
from student import PROMPT # noqa: E402
|
| 24 |
-
|
| 25 |
-
RUNS_REPO = "HideIron/selfheal-sql-runs"
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
def main() -> None:
|
| 29 |
-
ap = argparse.ArgumentParser()
|
| 30 |
-
ap.add_argument("--run", required=True)
|
| 31 |
-
ap.add_argument("--split-file", required=True)
|
| 32 |
-
ap.add_argument("--out", default=None)
|
| 33 |
-
ap.add_argument("--no-push", action="store_true")
|
| 34 |
-
args = ap.parse_args()
|
| 35 |
-
|
| 36 |
-
from huggingface_hub import HfApi, hf_hub_download
|
| 37 |
-
|
| 38 |
-
from auditkit.agent_eval import (
|
| 39 |
-
AgentCase,
|
| 40 |
-
AgentEvalRunner,
|
| 41 |
-
AgentEvalSpec,
|
| 42 |
-
CustomPredicate,
|
| 43 |
-
episode_from_openai_messages,
|
| 44 |
-
)
|
| 45 |
-
|
| 46 |
-
rp = hf_hub_download(RUNS_REPO, f"runs/{args.run}/results.jsonl", repo_type="dataset")
|
| 47 |
-
rows = [json.loads(l) for l in open(rp, encoding="utf-8")]
|
| 48 |
-
splits = {r["qid"]: r for r in map(json.loads, open(args.split_file, encoding="utf-8"))}
|
| 49 |
-
|
| 50 |
-
def exec_predicate(episode, case):
|
| 51 |
-
s = splits[case.id]
|
| 52 |
-
res = check(s["db_path"], episode.final_answer or "", s["gold_sql"])
|
| 53 |
-
return bool(res.correct)
|
| 54 |
-
|
| 55 |
-
episodes, cases = [], []
|
| 56 |
-
for r in rows:
|
| 57 |
-
s = splits[r["qid"]]
|
| 58 |
-
episodes.append(
|
| 59 |
-
episode_from_openai_messages(
|
| 60 |
-
[
|
| 61 |
-
{"role": "user",
|
| 62 |
-
"content": PROMPT.format(schema=s["schema"], question=s["question"])},
|
| 63 |
-
{"role": "assistant", "content": r["raw_output"]},
|
| 64 |
-
],
|
| 65 |
-
case_id=r["qid"],
|
| 66 |
-
final_answer=r["raw_output"],
|
| 67 |
-
)
|
| 68 |
-
)
|
| 69 |
-
cases.append(
|
| 70 |
-
AgentCase(
|
| 71 |
-
id=r["qid"], task=s["question"], category="text_to_sql",
|
| 72 |
-
outcome=CustomPredicate(exec_predicate, version="execcheck-v1",
|
| 73 |
-
name="spider_execution_accuracy"),
|
| 74 |
-
metadata={"db_id": s["db_id"]},
|
| 75 |
-
)
|
| 76 |
-
)
|
| 77 |
-
|
| 78 |
-
spec = AgentEvalSpec(cases=cases, mode="recorded", episodes=episodes,
|
| 79 |
-
scorers=[], trials=1)
|
| 80 |
-
result = AgentEvalRunner().run(spec)
|
| 81 |
-
|
| 82 |
-
verdicts = {row.case_id: row.outcome["verdict"] for row in result.rows}
|
| 83 |
-
logged = {r["qid"]: ("success" if r["status"] == "correct" else "failure") for r in rows}
|
| 84 |
-
agree = sum(verdicts[q] == logged[q] for q in verdicts)
|
| 85 |
-
out = {
|
| 86 |
-
"run": args.run,
|
| 87 |
-
"auditkit_summary": result.summary(),
|
| 88 |
-
"verdict_counts": dict(Counter(verdicts.values())),
|
| 89 |
-
"n": len(verdicts),
|
| 90 |
-
"success_rate": round(sum(v == "success" for v in verdicts.values()) / len(verdicts), 4),
|
| 91 |
-
"crosscheck_agree_with_harness": f"{agree}/{len(verdicts)}",
|
| 92 |
-
"oracle": "CustomPredicate(spider_execution_accuracy, version=execcheck-v1)
|
| 93 |
-
}
|
| 94 |
-
out_dir = args.out or os.path.join(HERE, "..", "..", "data", "audit", args.run)
|
| 95 |
-
os.makedirs(out_dir, exist_ok=True)
|
| 96 |
-
with open(os.path.join(out_dir, "auditkit_report.json"), "w", encoding="utf-8") as f:
|
| 97 |
-
json.dump(out, f, indent=2, default=str)
|
| 98 |
-
print(json.dumps(out, indent=2, default=str))
|
| 99 |
-
|
| 100 |
-
if not args.no_push:
|
| 101 |
-
HfApi().upload_folder(repo_id=RUNS_REPO, repo_type="dataset", folder_path=out_dir,
|
| 102 |
-
path_in_repo=f"audit/{args.run}",
|
| 103 |
-
commit_message=f"auditkit report {args.run}")
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
if __name__ == "__main__":
|
| 107 |
-
main()
|
|
|
|
| 1 |
+
"""AuditKIT agent_eval over a student run: independent outcome verification.
|
| 2 |
+
|
| 3 |
+
Each logged run row becomes a recorded AgentEpisode (user prompt -> assistant raw
|
| 4 |
+
output). The outcome oracle is a CustomPredicate that RE-EXECUTES the model's SQL
|
| 5 |
+
against the real database and compares with the gold result - AuditKIT's verdict is a
|
| 6 |
+
fresh execution, not a read of our logged status. We then cross-check AuditKIT's
|
| 7 |
+
verdicts against the harness log (they must agree 100%).
|
| 8 |
+
|
| 9 |
+
Usage: python audit_eval.py --run baseline-test --split-file ../../data/splits/test.jsonl
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import sys
|
| 18 |
+
from collections import Counter
|
| 19 |
+
|
| 20 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 21 |
+
sys.path.insert(0, HERE)
|
| 22 |
+
from execcheck import check # noqa: E402
|
| 23 |
+
from student import PROMPT # noqa: E402
|
| 24 |
+
|
| 25 |
+
RUNS_REPO = "HideIron/selfheal-sql-runs"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def main() -> None:
|
| 29 |
+
ap = argparse.ArgumentParser()
|
| 30 |
+
ap.add_argument("--run", required=True)
|
| 31 |
+
ap.add_argument("--split-file", required=True)
|
| 32 |
+
ap.add_argument("--out", default=None)
|
| 33 |
+
ap.add_argument("--no-push", action="store_true")
|
| 34 |
+
args = ap.parse_args()
|
| 35 |
+
|
| 36 |
+
from huggingface_hub import HfApi, hf_hub_download
|
| 37 |
+
|
| 38 |
+
from auditkit.agent_eval import (
|
| 39 |
+
AgentCase,
|
| 40 |
+
AgentEvalRunner,
|
| 41 |
+
AgentEvalSpec,
|
| 42 |
+
CustomPredicate,
|
| 43 |
+
episode_from_openai_messages,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
rp = hf_hub_download(RUNS_REPO, f"runs/{args.run}/results.jsonl", repo_type="dataset")
|
| 47 |
+
rows = [json.loads(l) for l in open(rp, encoding="utf-8")]
|
| 48 |
+
splits = {r["qid"]: r for r in map(json.loads, open(args.split_file, encoding="utf-8"))}
|
| 49 |
+
|
| 50 |
+
def exec_predicate(episode, case):
|
| 51 |
+
s = splits[case.id]
|
| 52 |
+
res = check(s["db_path"], episode.final_answer or "", s["gold_sql"])
|
| 53 |
+
return bool(res.correct)
|
| 54 |
+
|
| 55 |
+
episodes, cases = [], []
|
| 56 |
+
for r in rows:
|
| 57 |
+
s = splits[r["qid"]]
|
| 58 |
+
episodes.append(
|
| 59 |
+
episode_from_openai_messages(
|
| 60 |
+
[
|
| 61 |
+
{"role": "user",
|
| 62 |
+
"content": PROMPT.format(schema=s["schema"], question=s["question"])},
|
| 63 |
+
{"role": "assistant", "content": r["raw_output"]},
|
| 64 |
+
],
|
| 65 |
+
case_id=r["qid"],
|
| 66 |
+
final_answer=r["raw_output"],
|
| 67 |
+
)
|
| 68 |
+
)
|
| 69 |
+
cases.append(
|
| 70 |
+
AgentCase(
|
| 71 |
+
id=r["qid"], task=s["question"], category="text_to_sql",
|
| 72 |
+
outcome=CustomPredicate(exec_predicate, version="execcheck-v1",
|
| 73 |
+
name="spider_execution_accuracy"),
|
| 74 |
+
metadata={"db_id": s["db_id"]},
|
| 75 |
+
)
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
spec = AgentEvalSpec(cases=cases, mode="recorded", episodes=episodes,
|
| 79 |
+
scorers=[], trials=1)
|
| 80 |
+
result = AgentEvalRunner().run(spec)
|
| 81 |
+
|
| 82 |
+
verdicts = {row.case_id: row.outcome["verdict"] for row in result.rows}
|
| 83 |
+
logged = {r["qid"]: ("success" if r["status"] == "correct" else "failure") for r in rows}
|
| 84 |
+
agree = sum(verdicts[q] == logged[q] for q in verdicts)
|
| 85 |
+
out = {
|
| 86 |
+
"run": args.run,
|
| 87 |
+
"auditkit_summary": result.summary(),
|
| 88 |
+
"verdict_counts": dict(Counter(verdicts.values())),
|
| 89 |
+
"n": len(verdicts),
|
| 90 |
+
"success_rate": round(sum(v == "success" for v in verdicts.values()) / len(verdicts), 4),
|
| 91 |
+
"crosscheck_agree_with_harness": f"{agree}/{len(verdicts)}",
|
| 92 |
+
"oracle": "CustomPredicate(spider_execution_accuracy, version=execcheck-v1) - fresh re-execution",
|
| 93 |
+
}
|
| 94 |
+
out_dir = args.out or os.path.join(HERE, "..", "..", "data", "audit", args.run)
|
| 95 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 96 |
+
with open(os.path.join(out_dir, "auditkit_report.json"), "w", encoding="utf-8") as f:
|
| 97 |
+
json.dump(out, f, indent=2, default=str)
|
| 98 |
+
print(json.dumps(out, indent=2, default=str))
|
| 99 |
+
|
| 100 |
+
if not args.no_push:
|
| 101 |
+
HfApi().upload_folder(repo_id=RUNS_REPO, repo_type="dataset", folder_path=out_dir,
|
| 102 |
+
path_in_repo=f"audit/{args.run}",
|
| 103 |
+
commit_message=f"auditkit report {args.run}")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
if __name__ == "__main__":
|
| 107 |
+
main()
|
src/selfheal_sql/gate_decision.py
CHANGED
|
@@ -1,115 +1,115 @@
|
|
| 1 |
-
"""Deployment gate: decide whether the retrained adapter ships, using AgentTune's
|
| 2 |
-
DeploymentGate verbatim (vendored at third_party/agenttune_gate, commit 1dfd054).
|
| 3 |
-
|
| 4 |
-
The gate is model-agnostic by design: `model_runner_fn(input_text) -> verdict` is
|
| 5 |
-
supplied by the caller and compared against `expected_verdict` per case ("keeps the
|
| 6 |
-
gate model-agnostic and testable without a GPU"
|
| 7 |
-
up each gate question's execution-checker outcome from the student harness's logged
|
| 8 |
-
runs, so the verdict IS Spider execution accuracy.
|
| 9 |
-
|
| 10 |
-
Usage:
|
| 11 |
-
python gate_decision.py --old baseline-gate --new healed-gate --out ../../data/gate
|
| 12 |
-
"""
|
| 13 |
-
|
| 14 |
-
from __future__ import annotations
|
| 15 |
-
|
| 16 |
-
import argparse
|
| 17 |
-
import importlib.util
|
| 18 |
-
import json
|
| 19 |
-
import os
|
| 20 |
-
import sys
|
| 21 |
-
import types
|
| 22 |
-
from dataclasses import asdict
|
| 23 |
-
|
| 24 |
-
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 25 |
-
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
| 26 |
-
RUNS_REPO = "HideIron/selfheal-sql-runs"
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def load_gate_module():
|
| 30 |
-
"""Import the vendored deployment_gate.py without installing agenttune
|
| 31 |
-
(its only non-stdlib import is the PASS_VERDICTS constant, stubbed here 1:1)."""
|
| 32 |
-
pkg_names = ["agenttune", "agenttune.decide"]
|
| 33 |
-
for n in pkg_names:
|
| 34 |
-
if n not in sys.modules:
|
| 35 |
-
m = types.ModuleType(n)
|
| 36 |
-
m.__path__ = [] # mark as package
|
| 37 |
-
sys.modules[n] = m
|
| 38 |
-
st = types.ModuleType("agenttune.decide.state")
|
| 39 |
-
st.PASS_VERDICTS = frozenset({"PASS", "APPROVE", "COMPLETE"}) # verbatim
|
| 40 |
-
sys.modules["agenttune.decide.state"] = st
|
| 41 |
-
path = os.path.join(ROOT, "third_party", "agenttune_gate", "deployment_gate.py")
|
| 42 |
-
spec = importlib.util.spec_from_file_location("deployment_gate", path)
|
| 43 |
-
mod = importlib.util.module_from_spec(spec)
|
| 44 |
-
sys.modules["deployment_gate"] = mod # dataclasses resolves cls.__module__ here
|
| 45 |
-
spec.loader.exec_module(mod)
|
| 46 |
-
return mod
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
def results_lookup(run_name: str) -> dict[str, str]:
|
| 50 |
-
from huggingface_hub import hf_hub_download
|
| 51 |
-
|
| 52 |
-
p = hf_hub_download(RUNS_REPO, f"runs/{run_name}/results.jsonl", repo_type="dataset")
|
| 53 |
-
out = {}
|
| 54 |
-
for line in open(p, encoding="utf-8"):
|
| 55 |
-
r = json.loads(line)
|
| 56 |
-
out[r["qid"]] = "CORRECT" if r["status"] == "correct" else r["status"].upper()
|
| 57 |
-
return out
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def main() -> None:
|
| 61 |
-
ap = argparse.ArgumentParser()
|
| 62 |
-
ap.add_argument("--old", default="baseline-gate")
|
| 63 |
-
ap.add_argument("--new", default="healed-gate")
|
| 64 |
-
ap.add_argument("--out", default=os.path.join(ROOT, "data", "gate"))
|
| 65 |
-
ap.add_argument("--task-regression-tol", type=float, default=0.0)
|
| 66 |
-
ap.add_argument("--no-push", action="store_true")
|
| 67 |
-
args = ap.parse_args()
|
| 68 |
-
|
| 69 |
-
gate_mod = load_gate_module()
|
| 70 |
-
old_v, new_v = results_lookup(args.old), results_lookup(args.new)
|
| 71 |
-
qids = sorted(old_v)
|
| 72 |
-
assert set(qids) == set(new_v), "old/new runs cover different questions"
|
| 73 |
-
|
| 74 |
-
test_set = [{"pipeline_id": q, "input_text": q, "expected_verdict": "CORRECT"}
|
| 75 |
-
for q in qids]
|
| 76 |
-
|
| 77 |
-
gate = gate_mod.DeploymentGate()
|
| 78 |
-
decision = gate.evaluate_decision(
|
| 79 |
-
test_set=test_set,
|
| 80 |
-
old_model_fn=lambda qid: old_v[qid],
|
| 81 |
-
new_model_fn=lambda qid: new_v[qid],
|
| 82 |
-
task_regression_tol=args.task_regression_tol,
|
| 83 |
-
)
|
| 84 |
-
comparison = gate.compare_models(
|
| 85 |
-
test_set=test_set,
|
| 86 |
-
old_model_fn=lambda qid: old_v[qid],
|
| 87 |
-
new_model_fn=lambda qid: new_v[qid],
|
| 88 |
-
)
|
| 89 |
-
|
| 90 |
-
out = {
|
| 91 |
-
"gate_decision": asdict(decision),
|
| 92 |
-
"ab_comparison": asdict(comparison),
|
| 93 |
-
"old_run": args.old, "new_run": args.new,
|
| 94 |
-
"per_question_flips": {
|
| 95 |
-
"fixed": [q for q in qids if old_v[q] != "CORRECT" and new_v[q] == "CORRECT"],
|
| 96 |
-
"broken": [q for q in qids if old_v[q] == "CORRECT" and new_v[q] != "CORRECT"],
|
| 97 |
-
},
|
| 98 |
-
"gate_source": "AgentTune deployment_gate.py @ 1dfd054 (vendored verbatim)",
|
| 99 |
-
}
|
| 100 |
-
os.makedirs(args.out, exist_ok=True)
|
| 101 |
-
with open(os.path.join(args.out, "gate_decision.json"), "w", encoding="utf-8") as f:
|
| 102 |
-
json.dump(out, f, indent=2)
|
| 103 |
-
print(json.dumps({k: out[k] for k in ("gate_decision", "ab_comparison")}, indent=2))
|
| 104 |
-
print("fixed:", len(out["per_question_flips"]["fixed"]),
|
| 105 |
-
"broken:", len(out["per_question_flips"]["broken"]))
|
| 106 |
-
|
| 107 |
-
if not args.no_push:
|
| 108 |
-
from huggingface_hub import HfApi
|
| 109 |
-
|
| 110 |
-
HfApi().upload_folder(repo_id=RUNS_REPO, repo_type="dataset", folder_path=args.out,
|
| 111 |
-
path_in_repo="gate", commit_message="gate decision")
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
if __name__ == "__main__":
|
| 115 |
-
main()
|
|
|
|
| 1 |
+
"""Deployment gate: decide whether the retrained adapter ships, using AgentTune's
|
| 2 |
+
DeploymentGate verbatim (vendored at third_party/agenttune_gate, commit 1dfd054).
|
| 3 |
+
|
| 4 |
+
The gate is model-agnostic by design: `model_runner_fn(input_text) -> verdict` is
|
| 5 |
+
supplied by the caller and compared against `expected_verdict` per case ("keeps the
|
| 6 |
+
gate model-agnostic and testable without a GPU" - their docstring). Our runners look
|
| 7 |
+
up each gate question's execution-checker outcome from the student harness's logged
|
| 8 |
+
runs, so the verdict IS Spider execution accuracy.
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
python gate_decision.py --old baseline-gate --new healed-gate --out ../../data/gate
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import argparse
|
| 17 |
+
import importlib.util
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import sys
|
| 21 |
+
import types
|
| 22 |
+
from dataclasses import asdict
|
| 23 |
+
|
| 24 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 25 |
+
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
| 26 |
+
RUNS_REPO = "HideIron/selfheal-sql-runs"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def load_gate_module():
|
| 30 |
+
"""Import the vendored deployment_gate.py without installing agenttune
|
| 31 |
+
(its only non-stdlib import is the PASS_VERDICTS constant, stubbed here 1:1)."""
|
| 32 |
+
pkg_names = ["agenttune", "agenttune.decide"]
|
| 33 |
+
for n in pkg_names:
|
| 34 |
+
if n not in sys.modules:
|
| 35 |
+
m = types.ModuleType(n)
|
| 36 |
+
m.__path__ = [] # mark as package
|
| 37 |
+
sys.modules[n] = m
|
| 38 |
+
st = types.ModuleType("agenttune.decide.state")
|
| 39 |
+
st.PASS_VERDICTS = frozenset({"PASS", "APPROVE", "COMPLETE"}) # verbatim
|
| 40 |
+
sys.modules["agenttune.decide.state"] = st
|
| 41 |
+
path = os.path.join(ROOT, "third_party", "agenttune_gate", "deployment_gate.py")
|
| 42 |
+
spec = importlib.util.spec_from_file_location("deployment_gate", path)
|
| 43 |
+
mod = importlib.util.module_from_spec(spec)
|
| 44 |
+
sys.modules["deployment_gate"] = mod # dataclasses resolves cls.__module__ here
|
| 45 |
+
spec.loader.exec_module(mod)
|
| 46 |
+
return mod
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def results_lookup(run_name: str) -> dict[str, str]:
|
| 50 |
+
from huggingface_hub import hf_hub_download
|
| 51 |
+
|
| 52 |
+
p = hf_hub_download(RUNS_REPO, f"runs/{run_name}/results.jsonl", repo_type="dataset")
|
| 53 |
+
out = {}
|
| 54 |
+
for line in open(p, encoding="utf-8"):
|
| 55 |
+
r = json.loads(line)
|
| 56 |
+
out[r["qid"]] = "CORRECT" if r["status"] == "correct" else r["status"].upper()
|
| 57 |
+
return out
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def main() -> None:
|
| 61 |
+
ap = argparse.ArgumentParser()
|
| 62 |
+
ap.add_argument("--old", default="baseline-gate")
|
| 63 |
+
ap.add_argument("--new", default="healed-gate")
|
| 64 |
+
ap.add_argument("--out", default=os.path.join(ROOT, "data", "gate"))
|
| 65 |
+
ap.add_argument("--task-regression-tol", type=float, default=0.0)
|
| 66 |
+
ap.add_argument("--no-push", action="store_true")
|
| 67 |
+
args = ap.parse_args()
|
| 68 |
+
|
| 69 |
+
gate_mod = load_gate_module()
|
| 70 |
+
old_v, new_v = results_lookup(args.old), results_lookup(args.new)
|
| 71 |
+
qids = sorted(old_v)
|
| 72 |
+
assert set(qids) == set(new_v), "old/new runs cover different questions"
|
| 73 |
+
|
| 74 |
+
test_set = [{"pipeline_id": q, "input_text": q, "expected_verdict": "CORRECT"}
|
| 75 |
+
for q in qids]
|
| 76 |
+
|
| 77 |
+
gate = gate_mod.DeploymentGate()
|
| 78 |
+
decision = gate.evaluate_decision(
|
| 79 |
+
test_set=test_set,
|
| 80 |
+
old_model_fn=lambda qid: old_v[qid],
|
| 81 |
+
new_model_fn=lambda qid: new_v[qid],
|
| 82 |
+
task_regression_tol=args.task_regression_tol,
|
| 83 |
+
)
|
| 84 |
+
comparison = gate.compare_models(
|
| 85 |
+
test_set=test_set,
|
| 86 |
+
old_model_fn=lambda qid: old_v[qid],
|
| 87 |
+
new_model_fn=lambda qid: new_v[qid],
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
out = {
|
| 91 |
+
"gate_decision": asdict(decision),
|
| 92 |
+
"ab_comparison": asdict(comparison),
|
| 93 |
+
"old_run": args.old, "new_run": args.new,
|
| 94 |
+
"per_question_flips": {
|
| 95 |
+
"fixed": [q for q in qids if old_v[q] != "CORRECT" and new_v[q] == "CORRECT"],
|
| 96 |
+
"broken": [q for q in qids if old_v[q] == "CORRECT" and new_v[q] != "CORRECT"],
|
| 97 |
+
},
|
| 98 |
+
"gate_source": "AgentTune deployment_gate.py @ 1dfd054 (vendored verbatim)",
|
| 99 |
+
}
|
| 100 |
+
os.makedirs(args.out, exist_ok=True)
|
| 101 |
+
with open(os.path.join(args.out, "gate_decision.json"), "w", encoding="utf-8") as f:
|
| 102 |
+
json.dump(out, f, indent=2)
|
| 103 |
+
print(json.dumps({k: out[k] for k in ("gate_decision", "ab_comparison")}, indent=2))
|
| 104 |
+
print("fixed:", len(out["per_question_flips"]["fixed"]),
|
| 105 |
+
"broken:", len(out["per_question_flips"]["broken"]))
|
| 106 |
+
|
| 107 |
+
if not args.no_push:
|
| 108 |
+
from huggingface_hub import HfApi
|
| 109 |
+
|
| 110 |
+
HfApi().upload_folder(repo_id=RUNS_REPO, repo_type="dataset", folder_path=args.out,
|
| 111 |
+
path_in_repo="gate", commit_message="gate decision")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
if __name__ == "__main__":
|
| 115 |
+
main()
|
src/selfheal_sql/heal.py
CHANGED
|
@@ -1,270 +1,270 @@
|
|
| 1 |
-
"""The heal step: classify each failure, generate a corrected SQL query, keep it only
|
| 2 |
-
if it ACTUALLY produces the gold result. Output: DPO preference pairs.
|
| 3 |
-
|
| 4 |
-
Replaces AgentTune's TrainingExampleGenerator, which is tool-call-specific (it drops
|
| 5 |
-
every failure whose root cause isn't wrong_tool/loop_collapse, asks the LLM for "the
|
| 6 |
-
EXACT JSON tool call", and trains on a placeholder prompt). The classification stage
|
| 7 |
-
keeps AgentTune's FailureClassifier contract ({root_cause, confidence, analysis}) but
|
| 8 |
-
with a SQL-specific taxonomy. The output rows carry chosen/rejected exactly as
|
| 9 |
-
agenttune.decide.closed_loop.contracts.TrainingExample expects, so the retrain step
|
| 10 |
-
consumes them through Lexsi's own examples_to_dpo_dataset / run_retrain.
|
| 11 |
-
|
| 12 |
-
The healer NEVER sees the gold SQL. Gold is used only to verify a proposed fix by
|
| 13 |
-
executing both and comparing results (execcheck). Failed fixes are retried with the
|
| 14 |
-
new error message, up to --max-attempts.
|
| 15 |
-
|
| 16 |
-
Runs locally (needs the Spider DBs on disk + the HF router for LLM calls).
|
| 17 |
-
|
| 18 |
-
Usage:
|
| 19 |
-
python heal.py --run baseline-pool600 --split-file ../../data/splits/pool.jsonl \
|
| 20 |
-
--out ../../data/heal [--limit 6] [--workers 6]
|
| 21 |
-
"""
|
| 22 |
-
|
| 23 |
-
from __future__ import annotations
|
| 24 |
-
|
| 25 |
-
import argparse
|
| 26 |
-
import json
|
| 27 |
-
import os
|
| 28 |
-
import re
|
| 29 |
-
import sys
|
| 30 |
-
import threading
|
| 31 |
-
import time
|
| 32 |
-
import urllib.error
|
| 33 |
-
import urllib.request
|
| 34 |
-
from collections import Counter
|
| 35 |
-
from concurrent.futures import ThreadPoolExecutor
|
| 36 |
-
|
| 37 |
-
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 38 |
-
sys.path.insert(0, HERE)
|
| 39 |
-
from execcheck import check, extract_sql # noqa: E402
|
| 40 |
-
from student import PROMPT # noqa: E402 (the student's prompt
|
| 41 |
-
|
| 42 |
-
ROUTER = "https://router.huggingface.co/v1/chat/completions"
|
| 43 |
-
HEALER_MODEL = "CohereLabs/aya-expanse-32b" # on the hackathon's allowed model list
|
| 44 |
-
RUNS_REPO = "HideIron/selfheal-sql-runs"
|
| 45 |
-
SCHEMA_CAP = 7000 # chars of schema shown to the healer
|
| 46 |
-
|
| 47 |
-
# SQL-specific root causes (replaces the tool-agent taxonomy in
|
| 48 |
-
# agenttune FailureClassifier.ROOT_CAUSES; same output contract).
|
| 49 |
-
ROOT_CAUSES = [
|
| 50 |
-
"schema_misread", # non-existent column/table, column on wrong table
|
| 51 |
-
"wrong_join", # wrong join path, missing join, wrong join keys
|
| 52 |
-
"wrong_aggregation", # count/sum/avg/group-by/having misuse
|
| 53 |
-
"missing_or_wrong_filter", # WHERE/HAVING condition missing or wrong
|
| 54 |
-
"value_mismatch", # wrong literal (e.g. 'US' vs 'United States')
|
| 55 |
-
"wrong_output_columns", # right rows, wrong selected columns/order/distinct
|
| 56 |
-
"overcomplicated_query", # needless subquery/join that changes the result
|
| 57 |
-
"other",
|
| 58 |
-
]
|
| 59 |
-
|
| 60 |
-
_counts_lock = threading.Lock()
|
| 61 |
-
TOKENS = [0, 0] # prompt, completion
|
| 62 |
-
CALLS = [0]
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
def llm(messages: list[dict], temperature: float, max_tokens: int = 500) -> str:
|
| 66 |
-
"""One chat call to the HF router, with backoff on 429/5xx. Returns content."""
|
| 67 |
-
body = json.dumps(
|
| 68 |
-
{"model": HEALER_MODEL, "messages": messages, "temperature": temperature,
|
| 69 |
-
"max_tokens": max_tokens}
|
| 70 |
-
).encode()
|
| 71 |
-
last = None
|
| 72 |
-
for i in range(5):
|
| 73 |
-
req = urllib.request.Request(
|
| 74 |
-
ROUTER, data=body,
|
| 75 |
-
headers={"Authorization": "Bearer " + os.environ["HF_TOKEN"],
|
| 76 |
-
"Content-Type": "application/json"},
|
| 77 |
-
)
|
| 78 |
-
try:
|
| 79 |
-
with urllib.request.urlopen(req, timeout=180) as r:
|
| 80 |
-
d = json.load(r)
|
| 81 |
-
u = d.get("usage", {})
|
| 82 |
-
with _counts_lock:
|
| 83 |
-
TOKENS[0] += u.get("prompt_tokens", 0)
|
| 84 |
-
TOKENS[1] += u.get("completion_tokens", 0)
|
| 85 |
-
CALLS[0] += 1
|
| 86 |
-
return d["choices"][0]["message"]["content"]
|
| 87 |
-
except urllib.error.HTTPError as e:
|
| 88 |
-
last = f"HTTP {e.code}: {e.read()[:200]!r}"
|
| 89 |
-
if e.code in (429, 500, 502, 503, 504):
|
| 90 |
-
time.sleep(2 ** i)
|
| 91 |
-
continue
|
| 92 |
-
raise RuntimeError(last) from e
|
| 93 |
-
except Exception as e: # noqa: BLE001 (timeouts, resets)
|
| 94 |
-
last = repr(e)
|
| 95 |
-
time.sleep(2 ** i)
|
| 96 |
-
raise RuntimeError(f"router call failed after retries: {last}")
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
def _json_block(text: str) -> dict:
|
| 100 |
-
m = re.search(r"\{.*\}", text, re.DOTALL)
|
| 101 |
-
return json.loads(m.group(0)) if m else {}
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
def classify(fail: dict, srow: dict) -> dict:
|
| 105 |
-
"""SQL-specific root-cause classification (AgentTune ClassifiedFailure contract)."""
|
| 106 |
-
sys_p = (
|
| 107 |
-
"You are an expert SQL failure analyst. Classify the failure into EXACTLY ONE "
|
| 108 |
-
"root cause from this list:\n" + ", ".join(ROOT_CAUSES) + "\n\n"
|
| 109 |
-
'Return ONLY a JSON object: {"root_cause": "<one of the list>", '
|
| 110 |
-
'"confidence": 0.0-1.0, "analysis": "one short sentence"}'
|
| 111 |
-
)
|
| 112 |
-
user = (
|
| 113 |
-
f"Schema:\n{srow['schema'][:SCHEMA_CAP]}\n\nQuestion: {srow['question']}\n\n"
|
| 114 |
-
f"Model's SQL:\n{fail['raw_output'][:1200]}\n\n"
|
| 115 |
-
f"Outcome: {fail['status']}"
|
| 116 |
-
+ (f"
|
| 117 |
-
+ (
|
| 118 |
-
f" (query returned {fail['pred_rows']} rows, expected result has {fail['gold_rows']} rows)"
|
| 119 |
-
if fail.get("pred_rows") is not None and fail.get("gold_rows") is not None
|
| 120 |
-
else ""
|
| 121 |
-
)
|
| 122 |
-
)
|
| 123 |
-
try:
|
| 124 |
-
p = _json_block(llm([{"role": "system", "content": sys_p},
|
| 125 |
-
{"role": "user", "content": user}], 0.0, 250))
|
| 126 |
-
rc = p.get("root_cause", "other")
|
| 127 |
-
return {
|
| 128 |
-
"root_cause": rc if rc in ROOT_CAUSES else "other",
|
| 129 |
-
"confidence": float(p.get("confidence", 0.5)),
|
| 130 |
-
"analysis": str(p.get("analysis", ""))[:400],
|
| 131 |
-
"classify_error": None,
|
| 132 |
-
}
|
| 133 |
-
except Exception as e: # noqa: BLE001
|
| 134 |
-
return {"root_cause": "other", "confidence": 0.0, "analysis": "",
|
| 135 |
-
"classify_error": str(e)[:300]}
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
def generate_fix(fail: dict, srow: dict, cls: dict, max_attempts: int) -> tuple[str | None, list]:
|
| 139 |
-
"""Ask the healer for corrected SQL; keep only a fix that executes to the gold
|
| 140 |
-
result. Retries feed the candidate's own error back. Returns (sql|None, attempts)."""
|
| 141 |
-
base = (
|
| 142 |
-
f"A SQLite query is wrong and you must fix it.\n\n"
|
| 143 |
-
f"Schema:\n{srow['schema'][:SCHEMA_CAP]}\n\n"
|
| 144 |
-
f"Question: {srow['question']}\n\n"
|
| 145 |
-
f"Wrong SQL:\n{fail['raw_output'][:1200]}\n\n"
|
| 146 |
-
f"Observed problem: {fail['status']}"
|
| 147 |
-
+ (f"
|
| 148 |
-
+ (f"\nLikely root cause: {cls['root_cause']} ({cls['analysis']})" if cls.get("analysis") else "")
|
| 149 |
-
+ "\n\nRules: make the MINIMAL edit that fixes the query. Keep the original's "
|
| 150 |
-
"formatting, aliasing style, capitalisation and column order; do not add joins, "
|
| 151 |
-
"aliases, extra columns or reformat. Change only what is wrong.\n"
|
| 152 |
-
"Return ONLY the corrected SQLite query, nothing else."
|
| 153 |
-
)
|
| 154 |
-
msgs = [{"role": "user", "content": base}]
|
| 155 |
-
attempts = []
|
| 156 |
-
for k in range(max_attempts):
|
| 157 |
-
try:
|
| 158 |
-
out = llm(msgs, 0.0 if k == 0 else 0.4, 400)
|
| 159 |
-
except Exception as e: # noqa: BLE001
|
| 160 |
-
attempts.append({"attempt": k + 1, "error": f"llm: {str(e)[:200]}"})
|
| 161 |
-
break
|
| 162 |
-
cand = extract_sql(out)
|
| 163 |
-
if not cand:
|
| 164 |
-
attempts.append({"attempt": k + 1, "error": "no SQL in healer output"})
|
| 165 |
-
feedback = "Your reply contained no SQL. Return ONLY the corrected SQLite query."
|
| 166 |
-
else:
|
| 167 |
-
res = check(srow["db_path"], cand, srow["gold_sql"])
|
| 168 |
-
attempts.append({"attempt": k + 1, "sql": cand, "status": res.status,
|
| 169 |
-
"error": res.error_message[:200]})
|
| 170 |
-
if res.correct:
|
| 171 |
-
return cand, attempts
|
| 172 |
-
feedback = (
|
| 173 |
-
f"Your query:\n{cand}\n\nIt is still wrong: {res.status}"
|
| 174 |
-
+ (f"
|
| 175 |
-
+ (
|
| 176 |
-
f" (it returned {res.pred_rows} rows; the correct result has {res.gold_rows} rows)"
|
| 177 |
-
if res.pred_rows is not None and res.gold_rows is not None
|
| 178 |
-
else ""
|
| 179 |
-
)
|
| 180 |
-
+ "\nTry again. Return ONLY the corrected SQLite query."
|
| 181 |
-
)
|
| 182 |
-
msgs = msgs + [{"role": "assistant", "content": out},
|
| 183 |
-
{"role": "user", "content": feedback}]
|
| 184 |
-
return None, attempts
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
def heal_one(fail: dict, srow: dict, max_attempts: int) -> dict:
|
| 188 |
-
cls = classify(fail, srow)
|
| 189 |
-
fixed_sql, attempts = generate_fix(fail, srow, cls, max_attempts)
|
| 190 |
-
rejected = extract_sql(fail["raw_output"]) or fail["raw_output"].strip()
|
| 191 |
-
return {
|
| 192 |
-
"qid": fail["qid"], "db_id": fail["db_id"], "question": srow["question"],
|
| 193 |
-
"status_before": fail["status"], "error_before": fail.get("error_message", ""),
|
| 194 |
-
**{k: cls[k] for k in ("root_cause", "confidence", "analysis", "classify_error")},
|
| 195 |
-
"healed": fixed_sql is not None,
|
| 196 |
-
"attempts_used": len(attempts), "attempts": attempts,
|
| 197 |
-
# --- TrainingExample-ready fields (consumed by train_dpo.py) ---
|
| 198 |
-
"prompt_user_content": PROMPT.format(schema=srow["schema"], question=srow["question"]),
|
| 199 |
-
"chosen_sql": fixed_sql,
|
| 200 |
-
"rejected_sql": rejected,
|
| 201 |
-
"healer_model": HEALER_MODEL,
|
| 202 |
-
}
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
def main() -> None:
|
| 206 |
-
ap = argparse.ArgumentParser()
|
| 207 |
-
ap.add_argument("--run", default="baseline-pool600", help="runs-repo folder with results.jsonl")
|
| 208 |
-
ap.add_argument("--split-file", required=True)
|
| 209 |
-
ap.add_argument("--out", required=True)
|
| 210 |
-
ap.add_argument("--limit", type=int, default=None)
|
| 211 |
-
ap.add_argument("--workers", type=int, default=6)
|
| 212 |
-
ap.add_argument("--max-attempts", type=int, default=3)
|
| 213 |
-
ap.add_argument("--no-push", action="store_true")
|
| 214 |
-
ap.add_argument("--tag", default="", help="suffix for the runs-repo push path")
|
| 215 |
-
args = ap.parse_args()
|
| 216 |
-
|
| 217 |
-
from huggingface_hub import HfApi, hf_hub_download
|
| 218 |
-
|
| 219 |
-
rp = hf_hub_download(RUNS_REPO, f"runs/{args.run}/results.jsonl", repo_type="dataset")
|
| 220 |
-
results = [json.loads(l) for l in open(rp, encoding="utf-8")]
|
| 221 |
-
splits = {r["qid"]: r for r in map(json.loads, open(args.split_file, encoding="utf-8"))}
|
| 222 |
-
fails = [r for r in results if r["status"] != "correct"]
|
| 223 |
-
if args.limit:
|
| 224 |
-
fails = fails[: args.limit]
|
| 225 |
-
print(f"[heal] {len(fails)} failures to heal (of {len(results)} runs)", flush=True)
|
| 226 |
-
|
| 227 |
-
t0 = time.time()
|
| 228 |
-
done = [0]
|
| 229 |
-
|
| 230 |
-
def work(f):
|
| 231 |
-
row = heal_one(f, splits[f["qid"]], args.max_attempts)
|
| 232 |
-
with _counts_lock:
|
| 233 |
-
done[0] += 1
|
| 234 |
-
if done[0] % 20 == 0:
|
| 235 |
-
print(f"[heal] {done[0]}/{len(fails)} ({time.time()-t0:.0f}s, "
|
| 236 |
-
f"{CALLS[0]} calls)", flush=True)
|
| 237 |
-
return row
|
| 238 |
-
|
| 239 |
-
with ThreadPoolExecutor(max_workers=args.workers) as ex:
|
| 240 |
-
rows = list(ex.map(work, fails))
|
| 241 |
-
|
| 242 |
-
os.makedirs(args.out, exist_ok=True)
|
| 243 |
-
with open(os.path.join(args.out, "pairs.jsonl"), "w", encoding="utf-8") as f:
|
| 244 |
-
for r in rows:
|
| 245 |
-
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
| 246 |
-
|
| 247 |
-
healed = [r for r in rows if r["healed"]]
|
| 248 |
-
summary = {
|
| 249 |
-
"run": args.run, "healer_model": HEALER_MODEL, "n_failures": len(fails),
|
| 250 |
-
"n_healed": len(healed), "heal_yield": round(len(healed) / max(len(fails), 1), 4),
|
| 251 |
-
"by_root_cause": dict(Counter(r["root_cause"] for r in rows)),
|
| 252 |
-
"healed_by_root_cause": dict(Counter(r["root_cause"] for r in healed)),
|
| 253 |
-
"attempts_histogram": dict(Counter(r["attempts_used"] for r in healed)),
|
| 254 |
-
"classify_errors": sum(1 for r in rows if r["classify_error"]),
|
| 255 |
-
"llm_calls": CALLS[0], "prompt_tokens": TOKENS[0], "completion_tokens": TOKENS[1],
|
| 256 |
-
"elapsed_s": round(time.time() - t0, 1), "max_attempts": args.max_attempts,
|
| 257 |
-
}
|
| 258 |
-
with open(os.path.join(args.out, "heal_summary.json"), "w", encoding="utf-8") as f:
|
| 259 |
-
json.dump(summary, f, indent=2)
|
| 260 |
-
print("[heal] SUMMARY", json.dumps(summary), flush=True)
|
| 261 |
-
|
| 262 |
-
if not args.no_push:
|
| 263 |
-
dest = f"heal/{args.run}{args.tag}"
|
| 264 |
-
HfApi().upload_folder(repo_id=RUNS_REPO, repo_type="dataset", folder_path=args.out,
|
| 265 |
-
path_in_repo=dest, commit_message=f"heal {dest}")
|
| 266 |
-
print("[heal] pushed to", RUNS_REPO, dest, flush=True)
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
if __name__ == "__main__":
|
| 270 |
-
main()
|
|
|
|
| 1 |
+
"""The heal step: classify each failure, generate a corrected SQL query, keep it only
|
| 2 |
+
if it ACTUALLY produces the gold result. Output: DPO preference pairs.
|
| 3 |
+
|
| 4 |
+
Replaces AgentTune's TrainingExampleGenerator, which is tool-call-specific (it drops
|
| 5 |
+
every failure whose root cause isn't wrong_tool/loop_collapse, asks the LLM for "the
|
| 6 |
+
EXACT JSON tool call", and trains on a placeholder prompt). The classification stage
|
| 7 |
+
keeps AgentTune's FailureClassifier contract ({root_cause, confidence, analysis}) but
|
| 8 |
+
with a SQL-specific taxonomy. The output rows carry chosen/rejected exactly as
|
| 9 |
+
agenttune.decide.closed_loop.contracts.TrainingExample expects, so the retrain step
|
| 10 |
+
consumes them through Lexsi's own examples_to_dpo_dataset / run_retrain.
|
| 11 |
+
|
| 12 |
+
The healer NEVER sees the gold SQL. Gold is used only to verify a proposed fix by
|
| 13 |
+
executing both and comparing results (execcheck). Failed fixes are retried with the
|
| 14 |
+
new error message, up to --max-attempts.
|
| 15 |
+
|
| 16 |
+
Runs locally (needs the Spider DBs on disk + the HF router for LLM calls).
|
| 17 |
+
|
| 18 |
+
Usage:
|
| 19 |
+
python heal.py --run baseline-pool600 --split-file ../../data/splits/pool.jsonl \
|
| 20 |
+
--out ../../data/heal [--limit 6] [--workers 6]
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import argparse
|
| 26 |
+
import json
|
| 27 |
+
import os
|
| 28 |
+
import re
|
| 29 |
+
import sys
|
| 30 |
+
import threading
|
| 31 |
+
import time
|
| 32 |
+
import urllib.error
|
| 33 |
+
import urllib.request
|
| 34 |
+
from collections import Counter
|
| 35 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 36 |
+
|
| 37 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 38 |
+
sys.path.insert(0, HERE)
|
| 39 |
+
from execcheck import check, extract_sql # noqa: E402
|
| 40 |
+
from student import PROMPT # noqa: E402 (the student's prompt - reused verbatim)
|
| 41 |
+
|
| 42 |
+
ROUTER = "https://router.huggingface.co/v1/chat/completions"
|
| 43 |
+
HEALER_MODEL = "CohereLabs/aya-expanse-32b" # on the hackathon's allowed model list
|
| 44 |
+
RUNS_REPO = "HideIron/selfheal-sql-runs"
|
| 45 |
+
SCHEMA_CAP = 7000 # chars of schema shown to the healer
|
| 46 |
+
|
| 47 |
+
# SQL-specific root causes (replaces the tool-agent taxonomy in
|
| 48 |
+
# agenttune FailureClassifier.ROOT_CAUSES; same output contract).
|
| 49 |
+
ROOT_CAUSES = [
|
| 50 |
+
"schema_misread", # non-existent column/table, column on wrong table
|
| 51 |
+
"wrong_join", # wrong join path, missing join, wrong join keys
|
| 52 |
+
"wrong_aggregation", # count/sum/avg/group-by/having misuse
|
| 53 |
+
"missing_or_wrong_filter", # WHERE/HAVING condition missing or wrong
|
| 54 |
+
"value_mismatch", # wrong literal (e.g. 'US' vs 'United States')
|
| 55 |
+
"wrong_output_columns", # right rows, wrong selected columns/order/distinct
|
| 56 |
+
"overcomplicated_query", # needless subquery/join that changes the result
|
| 57 |
+
"other",
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
_counts_lock = threading.Lock()
|
| 61 |
+
TOKENS = [0, 0] # prompt, completion
|
| 62 |
+
CALLS = [0]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def llm(messages: list[dict], temperature: float, max_tokens: int = 500) -> str:
|
| 66 |
+
"""One chat call to the HF router, with backoff on 429/5xx. Returns content."""
|
| 67 |
+
body = json.dumps(
|
| 68 |
+
{"model": HEALER_MODEL, "messages": messages, "temperature": temperature,
|
| 69 |
+
"max_tokens": max_tokens}
|
| 70 |
+
).encode()
|
| 71 |
+
last = None
|
| 72 |
+
for i in range(5):
|
| 73 |
+
req = urllib.request.Request(
|
| 74 |
+
ROUTER, data=body,
|
| 75 |
+
headers={"Authorization": "Bearer " + os.environ["HF_TOKEN"],
|
| 76 |
+
"Content-Type": "application/json"},
|
| 77 |
+
)
|
| 78 |
+
try:
|
| 79 |
+
with urllib.request.urlopen(req, timeout=180) as r:
|
| 80 |
+
d = json.load(r)
|
| 81 |
+
u = d.get("usage", {})
|
| 82 |
+
with _counts_lock:
|
| 83 |
+
TOKENS[0] += u.get("prompt_tokens", 0)
|
| 84 |
+
TOKENS[1] += u.get("completion_tokens", 0)
|
| 85 |
+
CALLS[0] += 1
|
| 86 |
+
return d["choices"][0]["message"]["content"]
|
| 87 |
+
except urllib.error.HTTPError as e:
|
| 88 |
+
last = f"HTTP {e.code}: {e.read()[:200]!r}"
|
| 89 |
+
if e.code in (429, 500, 502, 503, 504):
|
| 90 |
+
time.sleep(2 ** i)
|
| 91 |
+
continue
|
| 92 |
+
raise RuntimeError(last) from e
|
| 93 |
+
except Exception as e: # noqa: BLE001 (timeouts, resets)
|
| 94 |
+
last = repr(e)
|
| 95 |
+
time.sleep(2 ** i)
|
| 96 |
+
raise RuntimeError(f"router call failed after retries: {last}")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _json_block(text: str) -> dict:
|
| 100 |
+
m = re.search(r"\{.*\}", text, re.DOTALL)
|
| 101 |
+
return json.loads(m.group(0)) if m else {}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def classify(fail: dict, srow: dict) -> dict:
|
| 105 |
+
"""SQL-specific root-cause classification (AgentTune ClassifiedFailure contract)."""
|
| 106 |
+
sys_p = (
|
| 107 |
+
"You are an expert SQL failure analyst. Classify the failure into EXACTLY ONE "
|
| 108 |
+
"root cause from this list:\n" + ", ".join(ROOT_CAUSES) + "\n\n"
|
| 109 |
+
'Return ONLY a JSON object: {"root_cause": "<one of the list>", '
|
| 110 |
+
'"confidence": 0.0-1.0, "analysis": "one short sentence"}'
|
| 111 |
+
)
|
| 112 |
+
user = (
|
| 113 |
+
f"Schema:\n{srow['schema'][:SCHEMA_CAP]}\n\nQuestion: {srow['question']}\n\n"
|
| 114 |
+
f"Model's SQL:\n{fail['raw_output'][:1200]}\n\n"
|
| 115 |
+
f"Outcome: {fail['status']}"
|
| 116 |
+
+ (f" - {fail['error_message']}" if fail.get("error_message") else "")
|
| 117 |
+
+ (
|
| 118 |
+
f" (query returned {fail['pred_rows']} rows, expected result has {fail['gold_rows']} rows)"
|
| 119 |
+
if fail.get("pred_rows") is not None and fail.get("gold_rows") is not None
|
| 120 |
+
else ""
|
| 121 |
+
)
|
| 122 |
+
)
|
| 123 |
+
try:
|
| 124 |
+
p = _json_block(llm([{"role": "system", "content": sys_p},
|
| 125 |
+
{"role": "user", "content": user}], 0.0, 250))
|
| 126 |
+
rc = p.get("root_cause", "other")
|
| 127 |
+
return {
|
| 128 |
+
"root_cause": rc if rc in ROOT_CAUSES else "other",
|
| 129 |
+
"confidence": float(p.get("confidence", 0.5)),
|
| 130 |
+
"analysis": str(p.get("analysis", ""))[:400],
|
| 131 |
+
"classify_error": None,
|
| 132 |
+
}
|
| 133 |
+
except Exception as e: # noqa: BLE001
|
| 134 |
+
return {"root_cause": "other", "confidence": 0.0, "analysis": "",
|
| 135 |
+
"classify_error": str(e)[:300]}
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def generate_fix(fail: dict, srow: dict, cls: dict, max_attempts: int) -> tuple[str | None, list]:
|
| 139 |
+
"""Ask the healer for corrected SQL; keep only a fix that executes to the gold
|
| 140 |
+
result. Retries feed the candidate's own error back. Returns (sql|None, attempts)."""
|
| 141 |
+
base = (
|
| 142 |
+
f"A SQLite query is wrong and you must fix it.\n\n"
|
| 143 |
+
f"Schema:\n{srow['schema'][:SCHEMA_CAP]}\n\n"
|
| 144 |
+
f"Question: {srow['question']}\n\n"
|
| 145 |
+
f"Wrong SQL:\n{fail['raw_output'][:1200]}\n\n"
|
| 146 |
+
f"Observed problem: {fail['status']}"
|
| 147 |
+
+ (f" - {fail['error_message']}" if fail.get("error_message") else "")
|
| 148 |
+
+ (f"\nLikely root cause: {cls['root_cause']} ({cls['analysis']})" if cls.get("analysis") else "")
|
| 149 |
+
+ "\n\nRules: make the MINIMAL edit that fixes the query. Keep the original's "
|
| 150 |
+
"formatting, aliasing style, capitalisation and column order; do not add joins, "
|
| 151 |
+
"aliases, extra columns or reformat. Change only what is wrong.\n"
|
| 152 |
+
"Return ONLY the corrected SQLite query, nothing else."
|
| 153 |
+
)
|
| 154 |
+
msgs = [{"role": "user", "content": base}]
|
| 155 |
+
attempts = []
|
| 156 |
+
for k in range(max_attempts):
|
| 157 |
+
try:
|
| 158 |
+
out = llm(msgs, 0.0 if k == 0 else 0.4, 400)
|
| 159 |
+
except Exception as e: # noqa: BLE001
|
| 160 |
+
attempts.append({"attempt": k + 1, "error": f"llm: {str(e)[:200]}"})
|
| 161 |
+
break
|
| 162 |
+
cand = extract_sql(out)
|
| 163 |
+
if not cand:
|
| 164 |
+
attempts.append({"attempt": k + 1, "error": "no SQL in healer output"})
|
| 165 |
+
feedback = "Your reply contained no SQL. Return ONLY the corrected SQLite query."
|
| 166 |
+
else:
|
| 167 |
+
res = check(srow["db_path"], cand, srow["gold_sql"])
|
| 168 |
+
attempts.append({"attempt": k + 1, "sql": cand, "status": res.status,
|
| 169 |
+
"error": res.error_message[:200]})
|
| 170 |
+
if res.correct:
|
| 171 |
+
return cand, attempts
|
| 172 |
+
feedback = (
|
| 173 |
+
f"Your query:\n{cand}\n\nIt is still wrong: {res.status}"
|
| 174 |
+
+ (f" - {res.error_message[:300]}" if res.error_message else "")
|
| 175 |
+
+ (
|
| 176 |
+
f" (it returned {res.pred_rows} rows; the correct result has {res.gold_rows} rows)"
|
| 177 |
+
if res.pred_rows is not None and res.gold_rows is not None
|
| 178 |
+
else ""
|
| 179 |
+
)
|
| 180 |
+
+ "\nTry again. Return ONLY the corrected SQLite query."
|
| 181 |
+
)
|
| 182 |
+
msgs = msgs + [{"role": "assistant", "content": out},
|
| 183 |
+
{"role": "user", "content": feedback}]
|
| 184 |
+
return None, attempts
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def heal_one(fail: dict, srow: dict, max_attempts: int) -> dict:
|
| 188 |
+
cls = classify(fail, srow)
|
| 189 |
+
fixed_sql, attempts = generate_fix(fail, srow, cls, max_attempts)
|
| 190 |
+
rejected = extract_sql(fail["raw_output"]) or fail["raw_output"].strip()
|
| 191 |
+
return {
|
| 192 |
+
"qid": fail["qid"], "db_id": fail["db_id"], "question": srow["question"],
|
| 193 |
+
"status_before": fail["status"], "error_before": fail.get("error_message", ""),
|
| 194 |
+
**{k: cls[k] for k in ("root_cause", "confidence", "analysis", "classify_error")},
|
| 195 |
+
"healed": fixed_sql is not None,
|
| 196 |
+
"attempts_used": len(attempts), "attempts": attempts,
|
| 197 |
+
# --- TrainingExample-ready fields (consumed by train_dpo.py) ---
|
| 198 |
+
"prompt_user_content": PROMPT.format(schema=srow["schema"], question=srow["question"]),
|
| 199 |
+
"chosen_sql": fixed_sql,
|
| 200 |
+
"rejected_sql": rejected,
|
| 201 |
+
"healer_model": HEALER_MODEL,
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def main() -> None:
|
| 206 |
+
ap = argparse.ArgumentParser()
|
| 207 |
+
ap.add_argument("--run", default="baseline-pool600", help="runs-repo folder with results.jsonl")
|
| 208 |
+
ap.add_argument("--split-file", required=True)
|
| 209 |
+
ap.add_argument("--out", required=True)
|
| 210 |
+
ap.add_argument("--limit", type=int, default=None)
|
| 211 |
+
ap.add_argument("--workers", type=int, default=6)
|
| 212 |
+
ap.add_argument("--max-attempts", type=int, default=3)
|
| 213 |
+
ap.add_argument("--no-push", action="store_true")
|
| 214 |
+
ap.add_argument("--tag", default="", help="suffix for the runs-repo push path")
|
| 215 |
+
args = ap.parse_args()
|
| 216 |
+
|
| 217 |
+
from huggingface_hub import HfApi, hf_hub_download
|
| 218 |
+
|
| 219 |
+
rp = hf_hub_download(RUNS_REPO, f"runs/{args.run}/results.jsonl", repo_type="dataset")
|
| 220 |
+
results = [json.loads(l) for l in open(rp, encoding="utf-8")]
|
| 221 |
+
splits = {r["qid"]: r for r in map(json.loads, open(args.split_file, encoding="utf-8"))}
|
| 222 |
+
fails = [r for r in results if r["status"] != "correct"]
|
| 223 |
+
if args.limit:
|
| 224 |
+
fails = fails[: args.limit]
|
| 225 |
+
print(f"[heal] {len(fails)} failures to heal (of {len(results)} runs)", flush=True)
|
| 226 |
+
|
| 227 |
+
t0 = time.time()
|
| 228 |
+
done = [0]
|
| 229 |
+
|
| 230 |
+
def work(f):
|
| 231 |
+
row = heal_one(f, splits[f["qid"]], args.max_attempts)
|
| 232 |
+
with _counts_lock:
|
| 233 |
+
done[0] += 1
|
| 234 |
+
if done[0] % 20 == 0:
|
| 235 |
+
print(f"[heal] {done[0]}/{len(fails)} ({time.time()-t0:.0f}s, "
|
| 236 |
+
f"{CALLS[0]} calls)", flush=True)
|
| 237 |
+
return row
|
| 238 |
+
|
| 239 |
+
with ThreadPoolExecutor(max_workers=args.workers) as ex:
|
| 240 |
+
rows = list(ex.map(work, fails))
|
| 241 |
+
|
| 242 |
+
os.makedirs(args.out, exist_ok=True)
|
| 243 |
+
with open(os.path.join(args.out, "pairs.jsonl"), "w", encoding="utf-8") as f:
|
| 244 |
+
for r in rows:
|
| 245 |
+
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
| 246 |
+
|
| 247 |
+
healed = [r for r in rows if r["healed"]]
|
| 248 |
+
summary = {
|
| 249 |
+
"run": args.run, "healer_model": HEALER_MODEL, "n_failures": len(fails),
|
| 250 |
+
"n_healed": len(healed), "heal_yield": round(len(healed) / max(len(fails), 1), 4),
|
| 251 |
+
"by_root_cause": dict(Counter(r["root_cause"] for r in rows)),
|
| 252 |
+
"healed_by_root_cause": dict(Counter(r["root_cause"] for r in healed)),
|
| 253 |
+
"attempts_histogram": dict(Counter(r["attempts_used"] for r in healed)),
|
| 254 |
+
"classify_errors": sum(1 for r in rows if r["classify_error"]),
|
| 255 |
+
"llm_calls": CALLS[0], "prompt_tokens": TOKENS[0], "completion_tokens": TOKENS[1],
|
| 256 |
+
"elapsed_s": round(time.time() - t0, 1), "max_attempts": args.max_attempts,
|
| 257 |
+
}
|
| 258 |
+
with open(os.path.join(args.out, "heal_summary.json"), "w", encoding="utf-8") as f:
|
| 259 |
+
json.dump(summary, f, indent=2)
|
| 260 |
+
print("[heal] SUMMARY", json.dumps(summary), flush=True)
|
| 261 |
+
|
| 262 |
+
if not args.no_push:
|
| 263 |
+
dest = f"heal/{args.run}{args.tag}"
|
| 264 |
+
HfApi().upload_folder(repo_id=RUNS_REPO, repo_type="dataset", folder_path=args.out,
|
| 265 |
+
path_in_repo=dest, commit_message=f"heal {dest}")
|
| 266 |
+
print("[heal] pushed to", RUNS_REPO, dest, flush=True)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
if __name__ == "__main__":
|
| 270 |
+
main()
|
src/selfheal_sql/train_dpo.py
CHANGED
|
@@ -1,211 +1,211 @@
|
|
| 1 |
-
"""DPO retrain via AgentTune's closed-loop retrain path (run inside an HF GPU Job).
|
| 2 |
-
|
| 3 |
-
Consumes the healer's verified pairs, builds agenttune TrainingExample objects, and
|
| 4 |
-
trains an adapter-only LoRA through agenttune's own
|
| 5 |
-
examples_to_dpo_dataset -> build_retrainer (create_agentic_trainer) -> train()
|
| 6 |
-
exactly as the closed loop's run_retrain does (we call build_retrainer so we can save
|
| 7 |
-
the adapter explicitly afterwards).
|
| 8 |
-
|
| 9 |
-
Train/eval consistency: the student evaluates with the Cohere chat template
|
| 10 |
-
(student.build_prompt). examples_to_dpo_dataset flattens prompt messages as
|
| 11 |
-
"role: content", BUT with role="" it emits content verbatim
|
| 12 |
-
chat template here and hand it the final string. Training sees exactly the text the
|
| 13 |
-
model sees at inference.
|
| 14 |
-
|
| 15 |
-
Launched by launch_job.py, which injects --data/--out/--push-repo/--push-path.
|
| 16 |
-
"""
|
| 17 |
-
|
| 18 |
-
from __future__ import annotations
|
| 19 |
-
|
| 20 |
-
import argparse
|
| 21 |
-
import json
|
| 22 |
-
import os
|
| 23 |
-
import sys
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
def main() -> None:
|
| 27 |
-
ap = argparse.ArgumentParser()
|
| 28 |
-
ap.add_argument("--data", default=None) # injected by launcher; unused
|
| 29 |
-
ap.add_argument("--out", required=True)
|
| 30 |
-
ap.add_argument("--push-repo", default=None)
|
| 31 |
-
ap.add_argument("--push-path", default=None)
|
| 32 |
-
ap.add_argument("--pairs-repo", default="HideIron/selfheal-sql-runs")
|
| 33 |
-
ap.add_argument("--pairs-path", default="heal/baseline-pool600/pairs.jsonl")
|
| 34 |
-
ap.add_argument("--model", default="CohereLabs/tiny-aya-global")
|
| 35 |
-
ap.add_argument("--adapter-repo", default="HideIron/tiny-aya-global-sql-selfheal")
|
| 36 |
-
ap.add_argument("--epochs", type=float, default=3.0)
|
| 37 |
-
ap.add_argument("--lr", type=float, default=1e-5)
|
| 38 |
-
ap.add_argument("--beta", type=float, default=0.1)
|
| 39 |
-
ap.add_argument("--lora-r", type=int, default=16)
|
| 40 |
-
ap.add_argument("--batch-size", type=int, default=2)
|
| 41 |
-
ap.add_argument("--grad-accum", type=int, default=8)
|
| 42 |
-
ap.add_argument("--max-steps", type=int, default=None, help="smoke-test cap")
|
| 43 |
-
ap.add_argument("--max-prompt-len", type=int, default=1408)
|
| 44 |
-
ap.add_argument("--rpo-alpha", type=float, default=0.0)
|
| 45 |
-
ap.add_argument("--algorithm", default="dpo", choices=["dpo", "bco"])
|
| 46 |
-
ap.add_argument("--replay-run", default=None,
|
| 47 |
-
help="BCO only: student run whose CORRECT answers are added as "
|
| 48 |
-
"positive (label=True) replay rows against forgetting")
|
| 49 |
-
ap.add_argument("--no-push-adapter", action="store_true")
|
| 50 |
-
args = ap.parse_args()
|
| 51 |
-
|
| 52 |
-
from huggingface_hub import HfApi, hf_hub_download
|
| 53 |
-
from transformers import AutoTokenizer
|
| 54 |
-
|
| 55 |
-
from agenttune.decide.closed_loop.contracts import TrainingExample
|
| 56 |
-
from agenttune.decide.closed_loop.retrain_config import RetrainConfig, build_retrainer
|
| 57 |
-
|
| 58 |
-
pp = hf_hub_download(args.pairs_repo, args.pairs_path, repo_type="dataset")
|
| 59 |
-
pairs = [json.loads(l) for l in open(pp, encoding="utf-8")]
|
| 60 |
-
healed = [p for p in pairs if p.get("healed") and p.get("chosen_sql")]
|
| 61 |
-
print(f"[dpo] {len(healed)} verified pairs of {len(pairs)} failures", flush=True)
|
| 62 |
-
|
| 63 |
-
tok = AutoTokenizer.from_pretrained(args.model)
|
| 64 |
-
|
| 65 |
-
def templ(user_content: str) -> str:
|
| 66 |
-
return tok.apply_chat_template(
|
| 67 |
-
[{"role": "user", "content": user_content}],
|
| 68 |
-
add_generation_prompt=True, tokenize=False,
|
| 69 |
-
)
|
| 70 |
-
|
| 71 |
-
examples = [
|
| 72 |
-
TrainingExample(
|
| 73 |
-
trajectory_id=p["qid"],
|
| 74 |
-
original_failure_type=p["status_before"],
|
| 75 |
-
root_cause=p["root_cause"],
|
| 76 |
-
# role "" -> _messages_to_text emits content verbatim (no "user: " prefix)
|
| 77 |
-
prompt=[{"role": "", "content": templ(p["prompt_user_content"])}],
|
| 78 |
-
chosen=[{"role": "", "content": p["chosen_sql"]}],
|
| 79 |
-
rejected=[{"role": "", "content": p["rejected_sql"]}],
|
| 80 |
-
)
|
| 81 |
-
for p in healed
|
| 82 |
-
]
|
| 83 |
-
|
| 84 |
-
if args.replay_run:
|
| 85 |
-
assert args.algorithm == "bco", "success replay needs BCO's independent rows"
|
| 86 |
-
import re as _re
|
| 87 |
-
|
| 88 |
-
def _extract(t): # minimal extract_sql (fences + first statement)
|
| 89 |
-
t = _re.sub(r"<think>.*?</think>", "", t, flags=_re.S)
|
| 90 |
-
m = _re.search(r"```(?:sql)?\s*(.*?)```", t, _re.S | _re.I)
|
| 91 |
-
if m:
|
| 92 |
-
t = m.group(1)
|
| 93 |
-
m = _re.search(r"\b(select|with)\b", t, _re.I)
|
| 94 |
-
return t[m.start():].split(";")[0].strip() if m else t.strip()
|
| 95 |
-
|
| 96 |
-
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 97 |
-
from student import PROMPT # the exact student prompt builder
|
| 98 |
-
|
| 99 |
-
split_rows = {}
|
| 100 |
-
sp = os.path.join(args.data or "/data", "splits", "pool.jsonl")
|
| 101 |
-
for s in map(json.loads, open(sp, encoding="utf-8")):
|
| 102 |
-
split_rows[s["qid"]] = s
|
| 103 |
-
rp2 = hf_hub_download(args.pairs_repo, f"runs/{args.replay_run}/results.jsonl",
|
| 104 |
-
repo_type="dataset")
|
| 105 |
-
n0 = len(examples)
|
| 106 |
-
for r in map(json.loads, open(rp2, encoding="utf-8")):
|
| 107 |
-
if r["status"] == "correct" and r["qid"] in split_rows:
|
| 108 |
-
s = split_rows[r["qid"]]
|
| 109 |
-
examples.append(TrainingExample(
|
| 110 |
-
trajectory_id=r["qid"] + "-replay",
|
| 111 |
-
original_failure_type="success_replay", root_cause="none",
|
| 112 |
-
prompt=[{"role": "", "content": templ(
|
| 113 |
-
PROMPT.format(schema=s["schema"], question=s["question"]))}],
|
| 114 |
-
chosen=[{"role": "", "content": _extract(r["raw_output"])}],
|
| 115 |
-
))
|
| 116 |
-
print(f"[dpo] +{len(examples)-n0} success-replay rows (label=True)", flush=True)
|
| 117 |
-
|
| 118 |
-
cfg = RetrainConfig(
|
| 119 |
-
model=args.model,
|
| 120 |
-
algorithm=args.algorithm,
|
| 121 |
-
output_dir=os.path.join(args.out, "adapter"),
|
| 122 |
-
lora_r=args.lora_r,
|
| 123 |
-
lora_alpha=args.lora_r * 2,
|
| 124 |
-
lora_dropout=0.05,
|
| 125 |
-
lora_target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
|
| 126 |
-
"gate_proj", "up_proj", "down_proj"],
|
| 127 |
-
num_train_epochs=args.epochs,
|
| 128 |
-
per_device_train_batch_size=args.batch_size,
|
| 129 |
-
gradient_accumulation_steps=args.grad_accum,
|
| 130 |
-
learning_rate=args.lr,
|
| 131 |
-
beta=args.beta,
|
| 132 |
-
max_steps=args.max_steps,
|
| 133 |
-
extra={
|
| 134 |
-
"bf16": True,
|
| 135 |
-
"logging_steps": 2,
|
| 136 |
-
"save_strategy": "no",
|
| 137 |
-
"report_to": "none",
|
| 138 |
-
"gradient_checkpointing": True,
|
| 139 |
-
},
|
| 140 |
-
)
|
| 141 |
-
if args.algorithm == "dpo":
|
| 142 |
-
# The BCO wrapper instantiates the model itself and rejects model_init_kwargs.
|
| 143 |
-
cfg.extra["model_init_kwargs"] = {"dtype": "bfloat16"}
|
| 144 |
-
|
| 145 |
-
# Cohere vocab is ~256k: full-sequence fp32 logits are ~4 GiB per copy and OOM a
|
| 146 |
-
# 24 GB card. use_logits_to_keep restricts logits to completion tokens (~60 here).
|
| 147 |
-
# If this TRL lacks it, shrink sequence caps hard instead.
|
| 148 |
-
import inspect
|
| 149 |
-
|
| 150 |
-
import trl
|
| 151 |
-
|
| 152 |
-
cfg_keys = set(inspect.signature(trl.DPOConfig.__init__).parameters)
|
| 153 |
-
if "use_logits_to_keep" in cfg_keys:
|
| 154 |
-
cfg.extra.update({"use_logits_to_keep": True,
|
| 155 |
-
"max_prompt_length": 2944, "max_length": 3072})
|
| 156 |
-
print("[dpo] use_logits_to_keep=True (trl", trl.__version__, ")", flush=True)
|
| 157 |
-
else:
|
| 158 |
-
cfg.extra.update({"max_prompt_length": args.max_prompt_len,
|
| 159 |
-
"max_length": args.max_prompt_len + 128})
|
| 160 |
-
print("[dpo] trl lacks use_logits_to_keep -> caps", args.max_prompt_len, flush=True)
|
| 161 |
-
# rpo_alpha adds an NLL term on the chosen completion (DPO+SFT hybrid): the model
|
| 162 |
-
# must LEARN to produce the verified fix, not merely prefer it. v1 (pure DPO,
|
| 163 |
-
# 30 steps) failed to move pool accuracy at all
|
| 164 |
-
if args.rpo_alpha and "rpo_alpha" in cfg_keys:
|
| 165 |
-
cfg.extra["rpo_alpha"] = args.rpo_alpha
|
| 166 |
-
print("[dpo] rpo_alpha =", args.rpo_alpha, flush=True)
|
| 167 |
-
elif args.rpo_alpha:
|
| 168 |
-
print("[dpo] WARNING: trl lacks rpo_alpha; pure DPO", flush=True)
|
| 169 |
-
|
| 170 |
-
lens = sorted(len(tok(templ(p["prompt_user_content"]))["input_ids"]) for p in healed)
|
| 171 |
-
print(f"[dpo] prompt tokens: median={lens[len(lens)//2]} p90={lens[int(len(lens)*.9)]} "
|
| 172 |
-
f"max={lens[-1]} | cap={cfg.extra['max_prompt_length']}", flush=True)
|
| 173 |
-
|
| 174 |
-
trainer = build_retrainer(examples, cfg)
|
| 175 |
-
stats = trainer.train()
|
| 176 |
-
print("[dpo] train() returned:", str(stats)[:500], flush=True)
|
| 177 |
-
|
| 178 |
-
# Save the adapter explicitly (find the underlying HF trainer/model).
|
| 179 |
-
os.makedirs(cfg.output_dir, exist_ok=True)
|
| 180 |
-
inner = None
|
| 181 |
-
for attr in ("trainer", "_trainer", "dpo_trainer"):
|
| 182 |
-
inner = getattr(trainer, attr, None)
|
| 183 |
-
if inner is not None and hasattr(inner, "model"):
|
| 184 |
-
break
|
| 185 |
-
model_obj = getattr(inner, "model", None) or getattr(trainer, "model", None)
|
| 186 |
-
if model_obj is None:
|
| 187 |
-
raise RuntimeError(
|
| 188 |
-
f"cannot locate trained model on {type(trainer).__name__}; attrs: "
|
| 189 |
-
+ ", ".join(a for a in dir(trainer) if not a.startswith("__"))[:800]
|
| 190 |
-
)
|
| 191 |
-
model_obj.save_pretrained(cfg.output_dir) # PeftModel -> adapter only
|
| 192 |
-
print("[dpo] adapter saved:", os.listdir(cfg.output_dir), flush=True)
|
| 193 |
-
|
| 194 |
-
with open(os.path.join(args.out, "train_stats.json"), "w", encoding="utf-8") as f:
|
| 195 |
-
json.dump({"n_pairs": len(healed), "stats": str(stats)[:2000],
|
| 196 |
-
"config": {k: v for k, v in vars(args).items()}}, f, indent=2)
|
| 197 |
-
|
| 198 |
-
api = HfApi()
|
| 199 |
-
if not args.no_push_adapter:
|
| 200 |
-
api.create_repo(args.adapter_repo, private=True, exist_ok=True)
|
| 201 |
-
api.upload_folder(repo_id=args.adapter_repo, folder_path=cfg.output_dir,
|
| 202 |
-
commit_message=f"DPO LoRA ({len(healed)} self-heal pairs)")
|
| 203 |
-
print("[dpo] adapter pushed:", args.adapter_repo, flush=True)
|
| 204 |
-
if args.push_repo:
|
| 205 |
-
api.upload_folder(repo_id=args.push_repo, repo_type="dataset",
|
| 206 |
-
folder_path=args.out, path_in_repo=args.push_path or "dpo",
|
| 207 |
-
commit_message="dpo training stats")
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
if __name__ == "__main__":
|
| 211 |
-
main()
|
|
|
|
| 1 |
+
"""DPO retrain via AgentTune's closed-loop retrain path (run inside an HF GPU Job).
|
| 2 |
+
|
| 3 |
+
Consumes the healer's verified pairs, builds agenttune TrainingExample objects, and
|
| 4 |
+
trains an adapter-only LoRA through agenttune's own
|
| 5 |
+
examples_to_dpo_dataset -> build_retrainer (create_agentic_trainer) -> train()
|
| 6 |
+
exactly as the closed loop's run_retrain does (we call build_retrainer so we can save
|
| 7 |
+
the adapter explicitly afterwards).
|
| 8 |
+
|
| 9 |
+
Train/eval consistency: the student evaluates with the Cohere chat template
|
| 10 |
+
(student.build_prompt). examples_to_dpo_dataset flattens prompt messages as
|
| 11 |
+
"role: content", BUT with role="" it emits content verbatim - so we pre-apply the
|
| 12 |
+
chat template here and hand it the final string. Training sees exactly the text the
|
| 13 |
+
model sees at inference.
|
| 14 |
+
|
| 15 |
+
Launched by launch_job.py, which injects --data/--out/--push-repo/--push-path.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import argparse
|
| 21 |
+
import json
|
| 22 |
+
import os
|
| 23 |
+
import sys
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def main() -> None:
|
| 27 |
+
ap = argparse.ArgumentParser()
|
| 28 |
+
ap.add_argument("--data", default=None) # injected by launcher; unused
|
| 29 |
+
ap.add_argument("--out", required=True)
|
| 30 |
+
ap.add_argument("--push-repo", default=None)
|
| 31 |
+
ap.add_argument("--push-path", default=None)
|
| 32 |
+
ap.add_argument("--pairs-repo", default="HideIron/selfheal-sql-runs")
|
| 33 |
+
ap.add_argument("--pairs-path", default="heal/baseline-pool600/pairs.jsonl")
|
| 34 |
+
ap.add_argument("--model", default="CohereLabs/tiny-aya-global")
|
| 35 |
+
ap.add_argument("--adapter-repo", default="HideIron/tiny-aya-global-sql-selfheal")
|
| 36 |
+
ap.add_argument("--epochs", type=float, default=3.0)
|
| 37 |
+
ap.add_argument("--lr", type=float, default=1e-5)
|
| 38 |
+
ap.add_argument("--beta", type=float, default=0.1)
|
| 39 |
+
ap.add_argument("--lora-r", type=int, default=16)
|
| 40 |
+
ap.add_argument("--batch-size", type=int, default=2)
|
| 41 |
+
ap.add_argument("--grad-accum", type=int, default=8)
|
| 42 |
+
ap.add_argument("--max-steps", type=int, default=None, help="smoke-test cap")
|
| 43 |
+
ap.add_argument("--max-prompt-len", type=int, default=1408)
|
| 44 |
+
ap.add_argument("--rpo-alpha", type=float, default=0.0)
|
| 45 |
+
ap.add_argument("--algorithm", default="dpo", choices=["dpo", "bco"])
|
| 46 |
+
ap.add_argument("--replay-run", default=None,
|
| 47 |
+
help="BCO only: student run whose CORRECT answers are added as "
|
| 48 |
+
"positive (label=True) replay rows against forgetting")
|
| 49 |
+
ap.add_argument("--no-push-adapter", action="store_true")
|
| 50 |
+
args = ap.parse_args()
|
| 51 |
+
|
| 52 |
+
from huggingface_hub import HfApi, hf_hub_download
|
| 53 |
+
from transformers import AutoTokenizer
|
| 54 |
+
|
| 55 |
+
from agenttune.decide.closed_loop.contracts import TrainingExample
|
| 56 |
+
from agenttune.decide.closed_loop.retrain_config import RetrainConfig, build_retrainer
|
| 57 |
+
|
| 58 |
+
pp = hf_hub_download(args.pairs_repo, args.pairs_path, repo_type="dataset")
|
| 59 |
+
pairs = [json.loads(l) for l in open(pp, encoding="utf-8")]
|
| 60 |
+
healed = [p for p in pairs if p.get("healed") and p.get("chosen_sql")]
|
| 61 |
+
print(f"[dpo] {len(healed)} verified pairs of {len(pairs)} failures", flush=True)
|
| 62 |
+
|
| 63 |
+
tok = AutoTokenizer.from_pretrained(args.model)
|
| 64 |
+
|
| 65 |
+
def templ(user_content: str) -> str:
|
| 66 |
+
return tok.apply_chat_template(
|
| 67 |
+
[{"role": "user", "content": user_content}],
|
| 68 |
+
add_generation_prompt=True, tokenize=False,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
examples = [
|
| 72 |
+
TrainingExample(
|
| 73 |
+
trajectory_id=p["qid"],
|
| 74 |
+
original_failure_type=p["status_before"],
|
| 75 |
+
root_cause=p["root_cause"],
|
| 76 |
+
# role "" -> _messages_to_text emits content verbatim (no "user: " prefix)
|
| 77 |
+
prompt=[{"role": "", "content": templ(p["prompt_user_content"])}],
|
| 78 |
+
chosen=[{"role": "", "content": p["chosen_sql"]}],
|
| 79 |
+
rejected=[{"role": "", "content": p["rejected_sql"]}],
|
| 80 |
+
)
|
| 81 |
+
for p in healed
|
| 82 |
+
]
|
| 83 |
+
|
| 84 |
+
if args.replay_run:
|
| 85 |
+
assert args.algorithm == "bco", "success replay needs BCO's independent rows"
|
| 86 |
+
import re as _re
|
| 87 |
+
|
| 88 |
+
def _extract(t): # minimal extract_sql (fences + first statement)
|
| 89 |
+
t = _re.sub(r"<think>.*?</think>", "", t, flags=_re.S)
|
| 90 |
+
m = _re.search(r"```(?:sql)?\s*(.*?)```", t, _re.S | _re.I)
|
| 91 |
+
if m:
|
| 92 |
+
t = m.group(1)
|
| 93 |
+
m = _re.search(r"\b(select|with)\b", t, _re.I)
|
| 94 |
+
return t[m.start():].split(";")[0].strip() if m else t.strip()
|
| 95 |
+
|
| 96 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 97 |
+
from student import PROMPT # the exact student prompt builder
|
| 98 |
+
|
| 99 |
+
split_rows = {}
|
| 100 |
+
sp = os.path.join(args.data or "/data", "splits", "pool.jsonl")
|
| 101 |
+
for s in map(json.loads, open(sp, encoding="utf-8")):
|
| 102 |
+
split_rows[s["qid"]] = s
|
| 103 |
+
rp2 = hf_hub_download(args.pairs_repo, f"runs/{args.replay_run}/results.jsonl",
|
| 104 |
+
repo_type="dataset")
|
| 105 |
+
n0 = len(examples)
|
| 106 |
+
for r in map(json.loads, open(rp2, encoding="utf-8")):
|
| 107 |
+
if r["status"] == "correct" and r["qid"] in split_rows:
|
| 108 |
+
s = split_rows[r["qid"]]
|
| 109 |
+
examples.append(TrainingExample(
|
| 110 |
+
trajectory_id=r["qid"] + "-replay",
|
| 111 |
+
original_failure_type="success_replay", root_cause="none",
|
| 112 |
+
prompt=[{"role": "", "content": templ(
|
| 113 |
+
PROMPT.format(schema=s["schema"], question=s["question"]))}],
|
| 114 |
+
chosen=[{"role": "", "content": _extract(r["raw_output"])}],
|
| 115 |
+
))
|
| 116 |
+
print(f"[dpo] +{len(examples)-n0} success-replay rows (label=True)", flush=True)
|
| 117 |
+
|
| 118 |
+
cfg = RetrainConfig(
|
| 119 |
+
model=args.model,
|
| 120 |
+
algorithm=args.algorithm,
|
| 121 |
+
output_dir=os.path.join(args.out, "adapter"),
|
| 122 |
+
lora_r=args.lora_r,
|
| 123 |
+
lora_alpha=args.lora_r * 2,
|
| 124 |
+
lora_dropout=0.05,
|
| 125 |
+
lora_target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
|
| 126 |
+
"gate_proj", "up_proj", "down_proj"],
|
| 127 |
+
num_train_epochs=args.epochs,
|
| 128 |
+
per_device_train_batch_size=args.batch_size,
|
| 129 |
+
gradient_accumulation_steps=args.grad_accum,
|
| 130 |
+
learning_rate=args.lr,
|
| 131 |
+
beta=args.beta,
|
| 132 |
+
max_steps=args.max_steps,
|
| 133 |
+
extra={
|
| 134 |
+
"bf16": True,
|
| 135 |
+
"logging_steps": 2,
|
| 136 |
+
"save_strategy": "no",
|
| 137 |
+
"report_to": "none",
|
| 138 |
+
"gradient_checkpointing": True,
|
| 139 |
+
},
|
| 140 |
+
)
|
| 141 |
+
if args.algorithm == "dpo":
|
| 142 |
+
# The BCO wrapper instantiates the model itself and rejects model_init_kwargs.
|
| 143 |
+
cfg.extra["model_init_kwargs"] = {"dtype": "bfloat16"}
|
| 144 |
+
|
| 145 |
+
# Cohere vocab is ~256k: full-sequence fp32 logits are ~4 GiB per copy and OOM a
|
| 146 |
+
# 24 GB card. use_logits_to_keep restricts logits to completion tokens (~60 here).
|
| 147 |
+
# If this TRL lacks it, shrink sequence caps hard instead.
|
| 148 |
+
import inspect
|
| 149 |
+
|
| 150 |
+
import trl
|
| 151 |
+
|
| 152 |
+
cfg_keys = set(inspect.signature(trl.DPOConfig.__init__).parameters)
|
| 153 |
+
if "use_logits_to_keep" in cfg_keys:
|
| 154 |
+
cfg.extra.update({"use_logits_to_keep": True,
|
| 155 |
+
"max_prompt_length": 2944, "max_length": 3072})
|
| 156 |
+
print("[dpo] use_logits_to_keep=True (trl", trl.__version__, ")", flush=True)
|
| 157 |
+
else:
|
| 158 |
+
cfg.extra.update({"max_prompt_length": args.max_prompt_len,
|
| 159 |
+
"max_length": args.max_prompt_len + 128})
|
| 160 |
+
print("[dpo] trl lacks use_logits_to_keep -> caps", args.max_prompt_len, flush=True)
|
| 161 |
+
# rpo_alpha adds an NLL term on the chosen completion (DPO+SFT hybrid): the model
|
| 162 |
+
# must LEARN to produce the verified fix, not merely prefer it. v1 (pure DPO,
|
| 163 |
+
# 30 steps) failed to move pool accuracy at all - this is the main v2 fix.
|
| 164 |
+
if args.rpo_alpha and "rpo_alpha" in cfg_keys:
|
| 165 |
+
cfg.extra["rpo_alpha"] = args.rpo_alpha
|
| 166 |
+
print("[dpo] rpo_alpha =", args.rpo_alpha, flush=True)
|
| 167 |
+
elif args.rpo_alpha:
|
| 168 |
+
print("[dpo] WARNING: trl lacks rpo_alpha; pure DPO", flush=True)
|
| 169 |
+
|
| 170 |
+
lens = sorted(len(tok(templ(p["prompt_user_content"]))["input_ids"]) for p in healed)
|
| 171 |
+
print(f"[dpo] prompt tokens: median={lens[len(lens)//2]} p90={lens[int(len(lens)*.9)]} "
|
| 172 |
+
f"max={lens[-1]} | cap={cfg.extra['max_prompt_length']}", flush=True)
|
| 173 |
+
|
| 174 |
+
trainer = build_retrainer(examples, cfg)
|
| 175 |
+
stats = trainer.train()
|
| 176 |
+
print("[dpo] train() returned:", str(stats)[:500], flush=True)
|
| 177 |
+
|
| 178 |
+
# Save the adapter explicitly (find the underlying HF trainer/model).
|
| 179 |
+
os.makedirs(cfg.output_dir, exist_ok=True)
|
| 180 |
+
inner = None
|
| 181 |
+
for attr in ("trainer", "_trainer", "dpo_trainer"):
|
| 182 |
+
inner = getattr(trainer, attr, None)
|
| 183 |
+
if inner is not None and hasattr(inner, "model"):
|
| 184 |
+
break
|
| 185 |
+
model_obj = getattr(inner, "model", None) or getattr(trainer, "model", None)
|
| 186 |
+
if model_obj is None:
|
| 187 |
+
raise RuntimeError(
|
| 188 |
+
f"cannot locate trained model on {type(trainer).__name__}; attrs: "
|
| 189 |
+
+ ", ".join(a for a in dir(trainer) if not a.startswith("__"))[:800]
|
| 190 |
+
)
|
| 191 |
+
model_obj.save_pretrained(cfg.output_dir) # PeftModel -> adapter only
|
| 192 |
+
print("[dpo] adapter saved:", os.listdir(cfg.output_dir), flush=True)
|
| 193 |
+
|
| 194 |
+
with open(os.path.join(args.out, "train_stats.json"), "w", encoding="utf-8") as f:
|
| 195 |
+
json.dump({"n_pairs": len(healed), "stats": str(stats)[:2000],
|
| 196 |
+
"config": {k: v for k, v in vars(args).items()}}, f, indent=2)
|
| 197 |
+
|
| 198 |
+
api = HfApi()
|
| 199 |
+
if not args.no_push_adapter:
|
| 200 |
+
api.create_repo(args.adapter_repo, private=True, exist_ok=True)
|
| 201 |
+
api.upload_folder(repo_id=args.adapter_repo, folder_path=cfg.output_dir,
|
| 202 |
+
commit_message=f"DPO LoRA ({len(healed)} self-heal pairs)")
|
| 203 |
+
print("[dpo] adapter pushed:", args.adapter_repo, flush=True)
|
| 204 |
+
if args.push_repo:
|
| 205 |
+
api.upload_folder(repo_id=args.push_repo, repo_type="dataset",
|
| 206 |
+
folder_path=args.out, path_in_repo=args.push_path or "dpo",
|
| 207 |
+
commit_message="dpo training stats")
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
if __name__ == "__main__":
|
| 211 |
+
main()
|