HideIron commited on
Commit
898a901
·
verified ·
1 Parent(s): 2a8d74e

readme + dash cleanup in source

Browse files
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 — 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()
 
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" — 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()
 
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 — 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()
 
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 — 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()
 
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()