methodlm 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- benchmark_causal.py +119 -0
- benchmark_models.py +224 -0
- benchmark_real_examples.py +452 -0
- methodlm-1.0.1.dist-info/METADATA +155 -0
- methodlm-1.0.1.dist-info/RECORD +14 -0
- methodlm-1.0.1.dist-info/WHEEL +5 -0
- methodlm-1.0.1.dist-info/entry_points.txt +3 -0
- methodlm-1.0.1.dist-info/licenses/LICENSE +21 -0
- methodlm-1.0.1.dist-info/top_level.txt +8 -0
- methodlm.py +839 -0
- methodlm_gui.py +103 -0
- methodlm_io.py +246 -0
- methodlm_models.py +294 -0
- rescore.py +17 -0
methodlm_gui.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""MethodLM desktop GUI -- a local quantum console. No cloud, no framework.
|
|
3
|
+
|
|
4
|
+
python methodlm_gui.py # opens http://127.0.0.1:8777 in your browser
|
|
5
|
+
|
|
6
|
+
Serves methodlm_gui.html (the quantum front-end) wrapped in an HTML skeleton, plus a
|
|
7
|
+
tiny JSON API the page talks to when it detects the backend:
|
|
8
|
+
GET /api/ping -> {ok:true} (page flips to 'live' mode)
|
|
9
|
+
POST /api/describe {path,...} -> validate() result (columns, candidates, suggestions)
|
|
10
|
+
POST /api/run {path,target} -> runs the real investigation, returns the ledger text
|
|
11
|
+
The same HTML runs standalone as an Artifact (no backend -> simulation mode).
|
|
12
|
+
"""
|
|
13
|
+
import json, os, threading, webbrowser
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
15
|
+
|
|
16
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
17
|
+
HTML = os.path.join(HERE, "methodlm_gui.html")
|
|
18
|
+
PORT = 8777
|
|
19
|
+
|
|
20
|
+
SKELETON = ("<!doctype html><html lang='en'><head><meta charset='utf-8'>"
|
|
21
|
+
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
|
22
|
+
"<style>html,body{margin:0;background:#0a0603}</style></head><body>{body}</body></html>")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _read_html():
|
|
26
|
+
with open(HTML, encoding="utf-8") as f:
|
|
27
|
+
return SKELETON.replace("{body}", f.read())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class H(BaseHTTPRequestHandler):
|
|
31
|
+
def _send(self, code, body, ctype="application/json"):
|
|
32
|
+
b = body.encode("utf-8") if isinstance(body, str) else body
|
|
33
|
+
self.send_response(code)
|
|
34
|
+
self.send_header("Content-Type", ctype)
|
|
35
|
+
self.send_header("Content-Length", str(len(b)))
|
|
36
|
+
self.end_headers()
|
|
37
|
+
self.wfile.write(b)
|
|
38
|
+
|
|
39
|
+
def log_message(self, *a): # quiet
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
def do_GET(self):
|
|
43
|
+
if self.path in ("/", "/index.html"):
|
|
44
|
+
self._send(200, _read_html(), "text/html; charset=utf-8")
|
|
45
|
+
elif self.path == "/api/ping":
|
|
46
|
+
self._send(200, json.dumps({"ok": True}))
|
|
47
|
+
else:
|
|
48
|
+
self._send(404, json.dumps({"error": "not found"}))
|
|
49
|
+
|
|
50
|
+
def _body(self):
|
|
51
|
+
n = int(self.headers.get("Content-Length", 0))
|
|
52
|
+
return json.loads(self.rfile.read(n) or b"{}")
|
|
53
|
+
|
|
54
|
+
def do_POST(self):
|
|
55
|
+
try:
|
|
56
|
+
data = self._body()
|
|
57
|
+
except Exception as e:
|
|
58
|
+
return self._send(400, json.dumps({"error": f"bad json: {e}"}))
|
|
59
|
+
if self.path == "/api/describe":
|
|
60
|
+
from methodlm_io import validate
|
|
61
|
+
r = validate(data.get("path", ""), data.get("target"),
|
|
62
|
+
table=data.get("table"), query=data.get("query"))
|
|
63
|
+
self._send(200, json.dumps(r))
|
|
64
|
+
elif self.path == "/api/run":
|
|
65
|
+
self._send(200, json.dumps(self._run(data)))
|
|
66
|
+
elif self.path == "/api/race":
|
|
67
|
+
self._send(200, json.dumps(self._run(data, race=True)))
|
|
68
|
+
else:
|
|
69
|
+
self._send(404, json.dumps({"error": "not found"}))
|
|
70
|
+
|
|
71
|
+
def _run(self, data, race=False):
|
|
72
|
+
import methodlm as M
|
|
73
|
+
from methodlm_io import load_any, featurize, format_report
|
|
74
|
+
path, target = data.get("path", ""), data.get("target", "")
|
|
75
|
+
raw, notes = load_any(path, table=data.get("table"), query=data.get("query"))
|
|
76
|
+
tbl, rep = featurize(raw, target)
|
|
77
|
+
report = format_report(notes, rep, target)
|
|
78
|
+
cols = [c for c in tbl if c != target]
|
|
79
|
+
q = (f"Investigate what actually drives {target} in this dataset "
|
|
80
|
+
f"(columns: {', '.join(cols[:12])}). Do not trust raw correlations.")
|
|
81
|
+
name = os.path.splitext(os.path.basename(path))[0]
|
|
82
|
+
res = M.investigate(name, tbl, target, q, False, ingest_report=report)
|
|
83
|
+
with open(os.path.join(HERE, f"ledger_{name}.txt"), encoding="utf-8") as f:
|
|
84
|
+
out = {"ledger": f.read(), "verdict": res["verdict"],
|
|
85
|
+
"tested": res["nrun"], "prereg": res["pre"], "gate": res["gate"]}
|
|
86
|
+
if race:
|
|
87
|
+
out["vanilla"] = M.vanilla_answer(q)
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main():
|
|
92
|
+
srv = ThreadingHTTPServer(("127.0.0.1", PORT), H)
|
|
93
|
+
url = f"http://127.0.0.1:{PORT}"
|
|
94
|
+
print(f"MethodLM quantum console -> {url} (Ctrl+C to stop)")
|
|
95
|
+
threading.Timer(0.6, lambda: webbrowser.open(url)).start()
|
|
96
|
+
try:
|
|
97
|
+
srv.serve_forever()
|
|
98
|
+
except KeyboardInterrupt:
|
|
99
|
+
print("\nstopped.")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
main()
|
methodlm_io.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""MethodLM ingestion -- convert many data formats into the canonical table
|
|
2
|
+
(numeric columns + a target) that both halves of MethodLM need, and REPORT every
|
|
3
|
+
transformation so the ledger shows exactly what was done to the data.
|
|
4
|
+
|
|
5
|
+
load_any(path) -> (raw column dict, load notes) dispatch on extension
|
|
6
|
+
featurize(cols,target) -> (numeric column dict, report) coerce mixed -> numeric
|
|
7
|
+
|
|
8
|
+
Supported now: .csv .tsv .txt, .json, .jsonl/.ndjson, .parquet/.pq, .db/.sqlite,
|
|
9
|
+
.npz, .xlsx (needs openpyxl). Featurizer handles: numeric, boolean, datetime ->
|
|
10
|
+
date parts, numeric-looking strings, binary + low-card categoricals (one-hot),
|
|
11
|
+
and DROPS free text / high-cardinality columns (stated, with the count).
|
|
12
|
+
|
|
13
|
+
BOUNDARY (honest): MethodLM asks "what drives the target?" -- it needs columns and a
|
|
14
|
+
target. A lone image, raw audio, or a free-text document is not that until something
|
|
15
|
+
featurizes it into columns with a target. This layer does the tabular conversions;
|
|
16
|
+
it does not pretend a single unlabeled blob is a causal question.
|
|
17
|
+
"""
|
|
18
|
+
import os, re, sqlite3, warnings
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
SUPPORTED_EXT = {".csv", ".tsv", ".txt", ".json", ".jsonl", ".ndjson", ".parquet",
|
|
22
|
+
".pq", ".db", ".sqlite", ".sqlite3", ".npz", ".xlsx", ".xls"}
|
|
23
|
+
IMG_EXT = {".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp"}
|
|
24
|
+
TXT_EXT = {".txt", ".md", ".log"}
|
|
25
|
+
|
|
26
|
+
_DATEISH = re.compile(r"\d{4}[-/]\d{1,2}[-/]\d{1,2}|\d{1,2}:\d{2}")
|
|
27
|
+
|
|
28
|
+
def _looks_datelike(series):
|
|
29
|
+
sample = series.dropna().astype(str).head(20)
|
|
30
|
+
return len(sample) > 0 and (sample.str.contains(_DATEISH).mean() > 0.7)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_any(path, table=None, query=None):
|
|
34
|
+
ext = os.path.splitext(path)[1].lower()
|
|
35
|
+
notes = []
|
|
36
|
+
import pandas as pd
|
|
37
|
+
if ext in (".csv", ".txt"):
|
|
38
|
+
df = pd.read_csv(path, sep=None, engine="python")
|
|
39
|
+
elif ext == ".tsv":
|
|
40
|
+
df = pd.read_csv(path, sep="\t")
|
|
41
|
+
elif ext == ".json":
|
|
42
|
+
df = pd.read_json(path)
|
|
43
|
+
elif ext in (".jsonl", ".ndjson"):
|
|
44
|
+
df = pd.read_json(path, lines=True)
|
|
45
|
+
elif ext in (".parquet", ".pq"):
|
|
46
|
+
df = pd.read_parquet(path)
|
|
47
|
+
elif ext in (".xlsx", ".xls"):
|
|
48
|
+
try:
|
|
49
|
+
df = pd.read_excel(path)
|
|
50
|
+
except ImportError:
|
|
51
|
+
raise SystemExit("Excel support needs: pip install openpyxl")
|
|
52
|
+
elif ext in (".db", ".sqlite", ".sqlite3"):
|
|
53
|
+
con = sqlite3.connect(path)
|
|
54
|
+
if query:
|
|
55
|
+
df = pd.read_sql_query(query, con)
|
|
56
|
+
else:
|
|
57
|
+
if not table:
|
|
58
|
+
tabs = pd.read_sql_query(
|
|
59
|
+
"SELECT name FROM sqlite_master WHERE type='table'", con)["name"].tolist()
|
|
60
|
+
if not tabs:
|
|
61
|
+
raise SystemExit("no tables in sqlite file")
|
|
62
|
+
table = tabs[0]
|
|
63
|
+
notes.append(f"sqlite: auto-selected table '{table}' (of {tabs})")
|
|
64
|
+
df = pd.read_sql_query(f"SELECT * FROM {table}", con)
|
|
65
|
+
con.close()
|
|
66
|
+
elif ext == ".npz":
|
|
67
|
+
z = np.load(path)
|
|
68
|
+
df = pd.DataFrame({k: z[k].ravel() for k in z.files})
|
|
69
|
+
notes.append(f"npz arrays -> columns {list(z.files)}")
|
|
70
|
+
else:
|
|
71
|
+
raise SystemExit(f"unsupported extension '{ext}'. Convert to CSV/JSON/Parquet/SQLite.")
|
|
72
|
+
return {c: df[c].to_numpy() for c in df.columns}, notes
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def featurize(cols, target):
|
|
76
|
+
import pandas as pd
|
|
77
|
+
num, rep = {}, {"kept": [], "encoded": [], "derived": [], "dropped": []}
|
|
78
|
+
|
|
79
|
+
def add(name, series):
|
|
80
|
+
v = pd.to_numeric(series, errors="coerce")
|
|
81
|
+
num[name] = v.fillna(v.mean()).astype(float).to_numpy()
|
|
82
|
+
|
|
83
|
+
for name, arr in cols.items():
|
|
84
|
+
if name == target:
|
|
85
|
+
continue
|
|
86
|
+
s = pd.Series(arr)
|
|
87
|
+
if pd.api.types.is_bool_dtype(s):
|
|
88
|
+
num[name] = s.astype(float).to_numpy(); rep["encoded"].append(f"{name}: bool->0/1"); continue
|
|
89
|
+
if pd.api.types.is_numeric_dtype(s):
|
|
90
|
+
add(name, s); rep["kept"].append(name); continue
|
|
91
|
+
co = pd.to_numeric(s, errors="coerce")
|
|
92
|
+
if co.notna().mean() > 0.9:
|
|
93
|
+
add(name, s); rep["kept"].append(name); continue
|
|
94
|
+
if _looks_datelike(s):
|
|
95
|
+
with warnings.catch_warnings():
|
|
96
|
+
warnings.simplefilter("ignore")
|
|
97
|
+
dt = pd.to_datetime(s, errors="coerce")
|
|
98
|
+
if dt.notna().mean() > 0.8:
|
|
99
|
+
made = []
|
|
100
|
+
for part, vals in [("year", dt.dt.year), ("month", dt.dt.month),
|
|
101
|
+
("dow", dt.dt.dayofweek), ("hour", dt.dt.hour)]:
|
|
102
|
+
if vals.nunique(dropna=True) > 1:
|
|
103
|
+
num[f"{name}_{part}"] = vals.fillna(vals.median()).astype(float).to_numpy()
|
|
104
|
+
made.append(part)
|
|
105
|
+
rep["derived"].append(f"{name}: datetime -> {made}"); continue
|
|
106
|
+
nun = s.nunique(dropna=True)
|
|
107
|
+
if nun == 2:
|
|
108
|
+
cats = sorted(map(str, s.dropna().unique()))
|
|
109
|
+
num[name] = (s.astype(str) == cats[-1]).astype(float).to_numpy()
|
|
110
|
+
rep["encoded"].append(f"{name}: binary {cats}"); continue
|
|
111
|
+
if 2 < nun <= 10:
|
|
112
|
+
for c in sorted(map(str, s.dropna().unique())):
|
|
113
|
+
num[f"{name}={c}"] = (s.astype(str) == c).astype(float).to_numpy()
|
|
114
|
+
rep["encoded"].append(f"{name}: one-hot ({nun} levels)"); continue
|
|
115
|
+
rep["dropped"].append(f"{name}: {nun} categories / free text")
|
|
116
|
+
|
|
117
|
+
# target
|
|
118
|
+
ts = pd.Series(cols[target])
|
|
119
|
+
ct = pd.to_numeric(ts, errors="coerce")
|
|
120
|
+
if ct.notna().mean() > 0.9:
|
|
121
|
+
num[target] = ct.fillna(ct.mean()).astype(float).to_numpy()
|
|
122
|
+
elif ts.nunique(dropna=True) == 2:
|
|
123
|
+
cats = sorted(map(str, ts.dropna().unique()))
|
|
124
|
+
num[target] = (ts.astype(str) == cats[-1]).astype(float).to_numpy()
|
|
125
|
+
rep["encoded"].append(f"TARGET {target}: binary {cats} -> {cats[-1]}=1")
|
|
126
|
+
else:
|
|
127
|
+
raise SystemExit(f"target '{target}' is neither numeric nor binary -- "
|
|
128
|
+
f"MethodLM needs a target it can pose 'what drives it' about "
|
|
129
|
+
f"({ts.nunique()} distinct values found).")
|
|
130
|
+
|
|
131
|
+
n = min(len(v) for v in num.values())
|
|
132
|
+
return {k: v[:n] for k, v in num.items()}, rep
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def describe(path, table=None, query=None):
|
|
136
|
+
"""Cheap column census for validation / GUI dropdowns -- names, inferred kinds,
|
|
137
|
+
row count, and which columns can serve as a target."""
|
|
138
|
+
import pandas as pd
|
|
139
|
+
raw, notes = load_any(path, table=table, query=query)
|
|
140
|
+
cols, n = [], 0
|
|
141
|
+
for name, arr in raw.items():
|
|
142
|
+
s = pd.Series(arr); n = max(n, len(s))
|
|
143
|
+
if pd.api.types.is_bool_dtype(s):
|
|
144
|
+
kind = "boolean"
|
|
145
|
+
elif pd.api.types.is_numeric_dtype(s):
|
|
146
|
+
kind = "numeric"
|
|
147
|
+
elif pd.to_numeric(s, errors="coerce").notna().mean() > 0.9:
|
|
148
|
+
kind = "numeric-text"
|
|
149
|
+
elif _looks_datelike(s):
|
|
150
|
+
kind = "datetime"
|
|
151
|
+
else:
|
|
152
|
+
nun = int(s.nunique(dropna=True))
|
|
153
|
+
kind = "binary" if nun == 2 else ("categorical" if nun <= 10 else f"text({nun})")
|
|
154
|
+
cols.append({"name": name, "kind": kind})
|
|
155
|
+
cands = [c["name"] for c in cols if c["kind"] in ("numeric", "numeric-text", "boolean", "binary")]
|
|
156
|
+
return {"columns": cols, "n_rows": n, "notes": notes, "target_candidates": cands}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def validate(path, target=None, table=None, query=None):
|
|
160
|
+
"""Return {ok, errors, suggestions, info} -- so a caller can reject bad input and
|
|
161
|
+
show the user exactly what IS valid instead of a stack trace."""
|
|
162
|
+
import os, difflib
|
|
163
|
+
out = {"ok": False, "errors": [], "suggestions": [], "info": None}
|
|
164
|
+
if not os.path.exists(path):
|
|
165
|
+
out["errors"].append(f"file not found: {path}")
|
|
166
|
+
return out
|
|
167
|
+
ext = os.path.splitext(path)[1].lower()
|
|
168
|
+
if os.path.isfile(path) and ext not in SUPPORTED_EXT:
|
|
169
|
+
out["errors"].append(f"unsupported format '{ext}'")
|
|
170
|
+
out["suggestions"] = ["convert to .csv, .json, .parquet, or .sqlite",
|
|
171
|
+
"or use --folder for a labelled folder of files"]
|
|
172
|
+
return out
|
|
173
|
+
try:
|
|
174
|
+
info = describe(path, table, query)
|
|
175
|
+
except SystemExit as e:
|
|
176
|
+
out["errors"].append(str(e)); return out
|
|
177
|
+
except Exception as e:
|
|
178
|
+
out["errors"].append(f"could not read file: {e}"); return out
|
|
179
|
+
out["info"] = info
|
|
180
|
+
cols = [c["name"] for c in info["columns"]]
|
|
181
|
+
if not target:
|
|
182
|
+
out["errors"].append("choose a target column")
|
|
183
|
+
out["suggestions"] = info["target_candidates"][:8]
|
|
184
|
+
return out
|
|
185
|
+
if target not in cols:
|
|
186
|
+
near = difflib.get_close_matches(target, cols, n=5, cutoff=0.3)
|
|
187
|
+
out["errors"].append(f"target '{target}' is not a column")
|
|
188
|
+
out["suggestions"] = near or info["target_candidates"][:8]
|
|
189
|
+
return out
|
|
190
|
+
if target not in info["target_candidates"]:
|
|
191
|
+
out["errors"].append(f"target '{target}' is text/high-cardinality; MethodLM needs "
|
|
192
|
+
f"a numeric or binary target")
|
|
193
|
+
out["suggestions"] = info["target_candidates"][:8]
|
|
194
|
+
return out
|
|
195
|
+
out["ok"] = True
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def load_folder(root, label="__dir__"):
|
|
200
|
+
"""A labelled folder of files -> a feature table. Text files become lexical features,
|
|
201
|
+
images become basic pixel-statistic features; the label is the parent folder name
|
|
202
|
+
(so root/spam/*.txt vs root/ham/*.txt gives label=spam/ham). The honest bridge from
|
|
203
|
+
non-tabular files to MethodLM's 'columns + target' question."""
|
|
204
|
+
import glob
|
|
205
|
+
import numpy as np
|
|
206
|
+
rows = []
|
|
207
|
+
for path in glob.glob(os.path.join(root, "**", "*"), recursive=True):
|
|
208
|
+
if not os.path.isfile(path):
|
|
209
|
+
continue
|
|
210
|
+
ext = os.path.splitext(path)[1].lower()
|
|
211
|
+
lab = os.path.basename(os.path.dirname(path)) if label == "__dir__" else label
|
|
212
|
+
if ext in TXT_EXT:
|
|
213
|
+
t = open(path, encoding="utf-8", errors="ignore").read()
|
|
214
|
+
w = t.split()
|
|
215
|
+
rows.append({"n_chars": len(t), "n_words": len(w),
|
|
216
|
+
"avg_word_len": float(np.mean([len(x) for x in w])) if w else 0.0,
|
|
217
|
+
"digit_share": sum(c.isdigit() for c in t) / max(len(t), 1),
|
|
218
|
+
"upper_share": sum(c.isupper() for c in t) / max(len(t), 1),
|
|
219
|
+
"label": lab})
|
|
220
|
+
elif ext in IMG_EXT:
|
|
221
|
+
from PIL import Image
|
|
222
|
+
im = Image.open(path).convert("RGB")
|
|
223
|
+
a = np.asarray(im, dtype=float) / 255.0
|
|
224
|
+
rows.append({"width": im.width, "height": im.height,
|
|
225
|
+
"aspect": im.width / max(im.height, 1),
|
|
226
|
+
"r_mean": float(a[..., 0].mean()), "g_mean": float(a[..., 1].mean()),
|
|
227
|
+
"b_mean": float(a[..., 2].mean()), "brightness": float(a.mean()),
|
|
228
|
+
"contrast": float(a.std()), "label": lab})
|
|
229
|
+
if not rows:
|
|
230
|
+
raise SystemExit(f"no .txt/.md or image files found under {root}")
|
|
231
|
+
keys = rows[0].keys()
|
|
232
|
+
return {k: np.array([r.get(k, np.nan) for r in rows], dtype=object if k == "label" else float)
|
|
233
|
+
for k in keys}, [f"folder {root}: {len(rows)} files -> {len(keys)-1} features + label"]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def format_report(notes, rep, target):
|
|
237
|
+
lines = []
|
|
238
|
+
for nt in notes:
|
|
239
|
+
lines.append(f" {nt}")
|
|
240
|
+
if rep["kept"]:
|
|
241
|
+
lines.append(f" kept numeric ({len(rep['kept'])}): {', '.join(rep['kept'])}")
|
|
242
|
+
for k in ("encoded", "derived", "dropped"):
|
|
243
|
+
for item in rep[k]:
|
|
244
|
+
lines.append(f" {k}: {item}")
|
|
245
|
+
lines.append(f" target: {target}")
|
|
246
|
+
return "\n".join(lines)
|
methodlm_models.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Model backends for MethodLM -- the discipline is the product, not the model.
|
|
2
|
+
|
|
3
|
+
The enforced protocol (pre-registration, tools, honest ledger, ternary second witness)
|
|
4
|
+
is identical no matter which model generates the text. A backend only has to turn
|
|
5
|
+
(system, messages) into a reply string. Swap the backend, keep the method.
|
|
6
|
+
|
|
7
|
+
local : Qwen-3B via llama-completion.exe -- private, offline, free (default)
|
|
8
|
+
claude : Anthropic API (Claude Opus 4.8) -- frontier reasoning; needs
|
|
9
|
+
`pip install anthropic` + credentials (ANTHROPIC_API_KEY or `ant auth login`)
|
|
10
|
+
|
|
11
|
+
messages is a list of {"role": "user"|"assistant", "content": str}, first turn user.
|
|
12
|
+
"""
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LocalLlama:
|
|
18
|
+
kind = "local"
|
|
19
|
+
label = "local Qwen-3B (offline)"
|
|
20
|
+
|
|
21
|
+
def __init__(self, llama, gguf, threads=8, ctx=None):
|
|
22
|
+
# ctx=3072 was the original default and is too small for multi-candidate panels:
|
|
23
|
+
# investigate()'s transcript grows by one ADJUST exchange (~500-1000 chars) per
|
|
24
|
+
# candidate column, and a 7-8 column panel fills 3072 tokens of prompt by turn 8-9
|
|
25
|
+
# -- with 0 tokens left for the response, llama-completion.exe returns an EMPTY
|
|
26
|
+
# completion (not a syntax error), which investigate()'s no-progress guard reads as
|
|
27
|
+
# 3 consecutive unrecognized-tool turns and kills the run with no FINAL. Verified
|
|
28
|
+
# directly: the qwen3b.gguf here reports n_ctx_train up to 32768 when -c is left at
|
|
29
|
+
# its own default, so 3072 was an artificial cap, not a model limit. Override via
|
|
30
|
+
# METHODLM_LOCAL_CTX if a given panel is even larger; 8192 gives headroom for a
|
|
31
|
+
# ~10-candidate panel (ADJUST + one REFUTE re-check per candidate) at the same
|
|
32
|
+
# threads/batch settings.
|
|
33
|
+
if ctx is None:
|
|
34
|
+
ctx = int(os.environ.get("METHODLM_LOCAL_CTX", "8192"))
|
|
35
|
+
self.llama, self.gguf, self.threads, self.ctx = llama, gguf, threads, ctx
|
|
36
|
+
self.label = f"local {os.path.splitext(os.path.basename(gguf))[0]} (gguf)"
|
|
37
|
+
|
|
38
|
+
def generate(self, system, messages, max_tokens=230, temp=0.3):
|
|
39
|
+
parts = [f"<|im_start|>system\n{system}<|im_end|>"]
|
|
40
|
+
for m in messages:
|
|
41
|
+
parts.append(f"<|im_start|>{m['role']}\n{m['content']}<|im_end|>")
|
|
42
|
+
parts.append("<|im_start|>assistant\n")
|
|
43
|
+
prompt = "\n".join(parts)
|
|
44
|
+
p = subprocess.run([self.llama, "-m", self.gguf, "-p", prompt, "-n", str(max_tokens),
|
|
45
|
+
"-t", str(self.threads), "-c", str(self.ctx), "--special",
|
|
46
|
+
"-no-cnv", "--temp", str(temp)],
|
|
47
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
|
48
|
+
return p.stdout.split("<|im_start|>assistant\n")[-1].split("<|im_end|>")[0].strip()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ClaudeAPI:
|
|
52
|
+
kind = "claude"
|
|
53
|
+
|
|
54
|
+
def __init__(self, model="claude-opus-4-8"):
|
|
55
|
+
try:
|
|
56
|
+
import anthropic
|
|
57
|
+
except ImportError:
|
|
58
|
+
raise SystemExit("claude backend needs the SDK: pip install anthropic")
|
|
59
|
+
self.model = model
|
|
60
|
+
self.label = f"Claude ({model})"
|
|
61
|
+
try:
|
|
62
|
+
self._client = anthropic.Anthropic() # resolves key/profile from env
|
|
63
|
+
self._client.models.list(limit=1) # validate credentials up front
|
|
64
|
+
except Exception as e:
|
|
65
|
+
raise SystemExit(f"Claude unavailable (need ANTHROPIC_API_KEY): {str(e)[:70]}")
|
|
66
|
+
|
|
67
|
+
def generate(self, system, messages, max_tokens=230, temp=None):
|
|
68
|
+
# Opus 4.8: system is separate; temperature/thinking omitted (rejected on 4.8).
|
|
69
|
+
resp = self._client.messages.create(
|
|
70
|
+
model=self.model, max_tokens=max(max_tokens, 512), system=system,
|
|
71
|
+
messages=[{"role": m["role"], "content": m["content"]} for m in messages])
|
|
72
|
+
return "".join(b.text for b in resp.content if b.type == "text").strip()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class OllamaModel:
|
|
76
|
+
"""A local model served by Ollama (http://localhost:11434) -- unlike LocalLlama, no
|
|
77
|
+
separate binary/GGUF path management: whatever's already been `ollama pull`ed just
|
|
78
|
+
works, GPU-accelerated where the model fits in VRAM, transparently CPU-offloaded for
|
|
79
|
+
the layers that don't. Uses /api/chat (not /api/generate) since the harness's tool
|
|
80
|
+
loop needs real multi-turn system+messages semantics, not a single flat prompt."""
|
|
81
|
+
kind = "ollama"
|
|
82
|
+
|
|
83
|
+
def __init__(self, model, url=None):
|
|
84
|
+
import urllib.request
|
|
85
|
+
self._urllib = urllib.request
|
|
86
|
+
self.model = model
|
|
87
|
+
self.url = url or os.environ.get("OLLAMA_URL", "http://localhost:11434/api/chat")
|
|
88
|
+
self.label = f"local {model} (ollama)"
|
|
89
|
+
|
|
90
|
+
def generate(self, system, messages, max_tokens=230, temp=0.3):
|
|
91
|
+
import json
|
|
92
|
+
payload = {
|
|
93
|
+
"model": self.model,
|
|
94
|
+
"messages": [{"role": "system", "content": system}] + list(messages),
|
|
95
|
+
"stream": False,
|
|
96
|
+
"options": {"temperature": temp, "num_predict": max_tokens},
|
|
97
|
+
}
|
|
98
|
+
body = json.dumps(payload).encode("utf-8")
|
|
99
|
+
req = self._urllib.Request(self.url, data=body, headers={"Content-Type": "application/json"})
|
|
100
|
+
with self._urllib.urlopen(req, timeout=600) as resp:
|
|
101
|
+
return json.loads(resp.read())["message"]["content"].strip()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class HFModel:
|
|
105
|
+
"""A local Hugging Face causal LM (torch) -- e.g. the method-baked Qwen-0.5B."""
|
|
106
|
+
kind = "hf"
|
|
107
|
+
|
|
108
|
+
def __init__(self, path, label=None):
|
|
109
|
+
import torch
|
|
110
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
111
|
+
self._torch = torch
|
|
112
|
+
self.tok = AutoTokenizer.from_pretrained(path)
|
|
113
|
+
self.model = AutoModelForCausalLM.from_pretrained(path, dtype=torch.float32).eval()
|
|
114
|
+
self.label = label or f"HF ({os.path.basename(str(path))})"
|
|
115
|
+
|
|
116
|
+
def generate(self, system, messages, max_tokens=230, temp=0.0):
|
|
117
|
+
chat = [{"role": "system", "content": system}] + list(messages)
|
|
118
|
+
enc = self.tok.apply_chat_template(chat, add_generation_prompt=True,
|
|
119
|
+
return_tensors="pt", return_dict=True)
|
|
120
|
+
with self._torch.no_grad():
|
|
121
|
+
out = self.model.generate(**enc, max_new_tokens=max_tokens, do_sample=False)
|
|
122
|
+
return self.tok.decode(out[0][enc["input_ids"].shape[1]:], skip_special_tokens=True).strip()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ContrastiveQwenHF:
|
|
126
|
+
"""Contrastive-decoding backend: a real strong/weak Qwen2.5-Instruct pair
|
|
127
|
+
(default 1.5B strong vs 0.5B weak, same family/tokenizer -- Qwen2.5-0.5B-
|
|
128
|
+
Instruct and Qwen2.5-1.5B-Instruct), amplified via
|
|
129
|
+
structured_attention.contrastive_next_token_logits (Li et al. 2022:
|
|
130
|
+
amplified = strong + alpha*(strong - weak), masked to the strong model's
|
|
131
|
+
own plausible tokens). Same idea as LocalLlama/HFModel above -- turn
|
|
132
|
+
(system, messages) into a reply string -- so it drops straight into the
|
|
133
|
+
backend-selection slot; the method (PREREGISTER/tools/ledger) doesn't
|
|
134
|
+
change, only which model proposes each turn's tool call and verdict.
|
|
135
|
+
|
|
136
|
+
Built from, and reuses, the already-tested amplification primitive from
|
|
137
|
+
golden-attention/demo_contrastive_qwen.py -- this class adds a real
|
|
138
|
+
KV-cache (past_key_values reused across steps for BOTH models) instead
|
|
139
|
+
of that demo's recompute-the-whole-sequence-every-token loop, because
|
|
140
|
+
investigate() calls generate() repeatedly on a growing multi-turn
|
|
141
|
+
transcript (up to 24 turns) -- without a cache that loop is quadratic in
|
|
142
|
+
generated length per call and compounds across turns; with it, each new
|
|
143
|
+
token is one incremental forward pass per model, same as any normal
|
|
144
|
+
HF .generate() call.
|
|
145
|
+
|
|
146
|
+
Needs `pip install transformers` plus the (editable, not on PyPI)
|
|
147
|
+
structured-attention package -- tries a plain `import structured_attention`
|
|
148
|
+
first (already pip-installed editable in this checkout's environment);
|
|
149
|
+
if that fails, falls back to METHODLM_STRUCTURED_ATTENTION pointing at a
|
|
150
|
+
checkout of https://github.com/gbranaa4-hue/structured-attention, same
|
|
151
|
+
optional-path-override pattern as METHODLM_TRITKIT."""
|
|
152
|
+
kind = "contrastive"
|
|
153
|
+
|
|
154
|
+
def __init__(self, weak_name=None, strong_name=None, alpha=None, beta=None):
|
|
155
|
+
import torch
|
|
156
|
+
try:
|
|
157
|
+
from structured_attention import contrastive_next_token_logits
|
|
158
|
+
except ImportError:
|
|
159
|
+
sa_path = os.environ.get("METHODLM_STRUCTURED_ATTENTION")
|
|
160
|
+
if not sa_path:
|
|
161
|
+
raise SystemExit(
|
|
162
|
+
"contrastive backend needs structured_attention: "
|
|
163
|
+
"pip install -e /path/to/structured-attention, or set "
|
|
164
|
+
"METHODLM_STRUCTURED_ATTENTION=/path/to/structured-attention")
|
|
165
|
+
import sys
|
|
166
|
+
sys.path.insert(0, sa_path)
|
|
167
|
+
try:
|
|
168
|
+
from structured_attention import contrastive_next_token_logits
|
|
169
|
+
except ImportError as e:
|
|
170
|
+
raise SystemExit(f"contrastive backend: structured_attention still "
|
|
171
|
+
f"not importable from {sa_path}: {e}")
|
|
172
|
+
try:
|
|
173
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
174
|
+
except ImportError:
|
|
175
|
+
raise SystemExit("contrastive backend needs the SDK: pip install transformers")
|
|
176
|
+
|
|
177
|
+
self._torch = torch
|
|
178
|
+
self._amplify = contrastive_next_token_logits
|
|
179
|
+
self.weak_name = weak_name or os.environ.get("METHODLM_CD_WEAK", "Qwen/Qwen2.5-0.5B-Instruct")
|
|
180
|
+
self.strong_name = strong_name or os.environ.get("METHODLM_CD_STRONG", "Qwen/Qwen2.5-1.5B-Instruct")
|
|
181
|
+
self.alpha = alpha if alpha is not None else float(os.environ.get("METHODLM_CD_ALPHA", "1.0"))
|
|
182
|
+
self.beta = beta if beta is not None else float(os.environ.get("METHODLM_CD_BETA", "0.1"))
|
|
183
|
+
self.device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
184
|
+
dtype = torch.bfloat16 if self.device == "cuda" else torch.float32
|
|
185
|
+
|
|
186
|
+
self.tok = AutoTokenizer.from_pretrained(self.strong_name)
|
|
187
|
+
self.weak = AutoModelForCausalLM.from_pretrained(self.weak_name, dtype=dtype).to(self.device).eval()
|
|
188
|
+
self.strong = AutoModelForCausalLM.from_pretrained(self.strong_name, dtype=dtype).to(self.device).eval()
|
|
189
|
+
eos_cfg = getattr(self.strong.generation_config, "eos_token_id", None)
|
|
190
|
+
self.eos_ids = set(eos_cfg) if isinstance(eos_cfg, (list, tuple, set)) else \
|
|
191
|
+
({eos_cfg} if eos_cfg is not None else {self.tok.eos_token_id})
|
|
192
|
+
self.label = (f"contrastive {self.strong_name.split('/')[-1]} vs "
|
|
193
|
+
f"{self.weak_name.split('/')[-1]} (alpha={self.alpha}, {self.device})")
|
|
194
|
+
|
|
195
|
+
def _sample(self, logits, temp, top_k):
|
|
196
|
+
torch = self._torch
|
|
197
|
+
if temp is None or temp <= 1e-6:
|
|
198
|
+
return logits.argmax(dim=-1, keepdim=True)
|
|
199
|
+
logits = logits / temp
|
|
200
|
+
finite = torch.isfinite(logits).sum(dim=-1, keepdim=True).item()
|
|
201
|
+
k = max(1, min(top_k, finite))
|
|
202
|
+
v, _ = torch.topk(logits, k)
|
|
203
|
+
logits = logits.masked_fill(logits < v[:, [-1]], float("-inf"))
|
|
204
|
+
probs = torch.softmax(logits, dim=-1)
|
|
205
|
+
return torch.multinomial(probs, 1)
|
|
206
|
+
|
|
207
|
+
def generate(self, system, messages, max_tokens=230, temp=0.3, top_k=40):
|
|
208
|
+
torch = self._torch
|
|
209
|
+
chat = [{"role": "system", "content": system}] + list(messages)
|
|
210
|
+
# tokenize=False then a separate tok(...) call, not apply_chat_template's own
|
|
211
|
+
# return_tensors="pt" -- that path returned a BatchEncoding instead of a plain
|
|
212
|
+
# tensor against this checkout's transformers version (5.12.1); same two-step
|
|
213
|
+
# route the already-tested demo_contrastive_qwen.py uses.
|
|
214
|
+
text = self.tok.apply_chat_template(chat, tokenize=False, add_generation_prompt=True)
|
|
215
|
+
input_ids = self.tok(text, return_tensors="pt").input_ids.to(self.device)
|
|
216
|
+
with torch.no_grad():
|
|
217
|
+
s_out = self.strong(input_ids, use_cache=True)
|
|
218
|
+
w_out = self.weak(input_ids, use_cache=True)
|
|
219
|
+
s_past, w_past = s_out.past_key_values, w_out.past_key_values
|
|
220
|
+
generated = []
|
|
221
|
+
for _ in range(max_tokens):
|
|
222
|
+
s_logits = s_out.logits[:, -1, :].float()
|
|
223
|
+
w_logits = w_out.logits[:, -1, :].float()
|
|
224
|
+
amplified = self._amplify(s_logits, w_logits, alpha=self.alpha, plausibility_beta=self.beta)
|
|
225
|
+
next_tok = self._sample(amplified, temp, top_k)
|
|
226
|
+
tok_id = int(next_tok.item())
|
|
227
|
+
if tok_id in self.eos_ids:
|
|
228
|
+
break
|
|
229
|
+
generated.append(tok_id)
|
|
230
|
+
s_out = self.strong(next_tok, past_key_values=s_past, use_cache=True)
|
|
231
|
+
w_out = self.weak(next_tok, past_key_values=w_past, use_cache=True)
|
|
232
|
+
s_past, w_past = s_out.past_key_values, w_out.past_key_values
|
|
233
|
+
text = self.tok.decode(generated, skip_special_tokens=True).strip()
|
|
234
|
+
# Real problem, found running this against investigate()'s multi-turn loop (up to 24
|
|
235
|
+
# turns, transcript growing every turn): each generate() call re-prefills the WHOLE
|
|
236
|
+
# growing conversation from scratch (no cache persists across separate calls, by
|
|
237
|
+
# design -- investigate() calls generate() fresh each turn), and PyTorch's CUDA
|
|
238
|
+
# caching allocator doesn't return freed blocks to the driver between calls. On an
|
|
239
|
+
# 8GB card this compounded turn over turn -- watched it go from ~1GB free after load
|
|
240
|
+
# to ~100MB free by turn ~13 of a single item, 100% GPU util but barely progressing
|
|
241
|
+
# (allocator thrashing/fragmentation, not real compute). del the local references and
|
|
242
|
+
# empty_cache() explicitly once per turn so the freed blocks actually go back; small,
|
|
243
|
+
# fixed per-turn cost, but keeps a multi-turn run from starving itself out by the time
|
|
244
|
+
# a panel has many candidate columns.
|
|
245
|
+
del s_out, w_out, s_past, w_past, input_ids
|
|
246
|
+
if self.device == "cuda":
|
|
247
|
+
torch.cuda.empty_cache()
|
|
248
|
+
return text
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def get_model(name, here):
|
|
252
|
+
name = (name or "local").lower()
|
|
253
|
+
# The real llama-completion.exe + DLLs + GGUF files are a multi-GB local build, not
|
|
254
|
+
# something to duplicate into every checkout that imports methodlm.py -- optional override
|
|
255
|
+
# for when they live somewhere other than next to this file (e.g. mesh_rag_server.py imports
|
|
256
|
+
# methodlm.py from the canonical repo, but the actual binary/GGUFs only exist in llama_demo).
|
|
257
|
+
# Defaults to `here` (unchanged behavior) if unset.
|
|
258
|
+
bin_dir = os.environ.get("METHODLM_LOCAL_BIN_DIR", here)
|
|
259
|
+
llama = os.path.join(bin_dir, "bin", "llama-completion.exe")
|
|
260
|
+
if name in ("local", "qwen", "llama", "qwen3b", "3b"):
|
|
261
|
+
return LocalLlama(llama, os.path.join(bin_dir, "qwen3b.gguf"))
|
|
262
|
+
if name in ("qwen05b", "qwen0.5b", "0.5b", "small"):
|
|
263
|
+
return LocalLlama(llama, os.path.join(bin_dir, "qwen.gguf"))
|
|
264
|
+
if name in ("baked", "method-baked", "baked05b"):
|
|
265
|
+
# Same "the real weights live in llama_demo, not next to this file" situation as
|
|
266
|
+
# the llama.cpp binary/GGUFs above -- reuse the same bin_dir override rather than
|
|
267
|
+
# inventing a second env var for the same real problem.
|
|
268
|
+
return HFModel(os.path.join(bin_dir, "method_baked_v2"), "method-baked Qwen-0.5B")
|
|
269
|
+
# Any already-`ollama pull`ed model, GPU-accelerated with automatic CPU offload for
|
|
270
|
+
# what doesn't fit in VRAM -- no separate binary/GGUF path management like LocalLlama.
|
|
271
|
+
# "ollama:<tag>" is the general form; a couple of real, currently-installed models get
|
|
272
|
+
# short aliases since they're the ones actually used for the harness benchmark.
|
|
273
|
+
ollama_aliases = {"qwen7b": "qwen2.5-coder:7b", "qwen2.5-coder-7b": "qwen2.5-coder:7b",
|
|
274
|
+
"qwen14b": "qwen2.5-coder:14b", "qwen2.5-coder-14b": "qwen2.5-coder:14b",
|
|
275
|
+
# official Microsoft BitNet b1.58 2B-4T -- real ternary {-1,0,+1}
|
|
276
|
+
# weights (not the 4-bit-quantized-down-to-"1-bit"-branding kind), pulled
|
|
277
|
+
# via hf.co/microsoft/bitnet-b1.58-2B-4T-gguf since the locally-downloaded
|
|
278
|
+
# bitnet.gguf used a since-removed llama.cpp quant type (TYPE_IQ4_NL_4_4)
|
|
279
|
+
# and can no longer be loaded by any current build.
|
|
280
|
+
"bitnet": "hf.co/microsoft/bitnet-b1.58-2B-4T-gguf"}
|
|
281
|
+
if name in ollama_aliases:
|
|
282
|
+
return OllamaModel(ollama_aliases[name])
|
|
283
|
+
if name.startswith("ollama:"):
|
|
284
|
+
return OllamaModel(name.split(":", 1)[1])
|
|
285
|
+
if name in ("contrastive", "cd", "contrastive-qwen", "cdqwen"):
|
|
286
|
+
# real 1.5B-vs-0.5B Qwen2.5-Instruct pair, amplified via
|
|
287
|
+
# structured_attention.contrastive_next_token_logits -- see
|
|
288
|
+
# ContrastiveQwenHF's docstring above for the full rationale.
|
|
289
|
+
return ContrastiveQwenHF()
|
|
290
|
+
frontier = {"claude": "claude-opus-4-8", "opus": "claude-opus-4-8",
|
|
291
|
+
"sonnet": "claude-sonnet-5", "haiku": "claude-haiku-4-5", "api": "claude-opus-4-8"}
|
|
292
|
+
if name in frontier:
|
|
293
|
+
return ClaudeAPI(frontier[name])
|
|
294
|
+
raise SystemExit(f"unknown model backend '{name}' -- use: qwen3b|qwen05b|baked|qwen7b|qwen14b|ollama:<tag>|contrastive|opus|sonnet|haiku")
|
rescore.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Re-score a saved benchmark audit JSON with the CURRENT judge (no API spend)."""
|
|
2
|
+
import json, sys
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from benchmark_models import judge
|
|
5
|
+
|
|
6
|
+
path = sys.argv[1] if len(sys.argv) > 1 else "benchmark_models_audit.json"
|
|
7
|
+
d = json.load(open(path, encoding="utf-8"))
|
|
8
|
+
models = dict.fromkeys(r["model"] for r in d)
|
|
9
|
+
for model in models:
|
|
10
|
+
for cond in ("plain", "harness"):
|
|
11
|
+
rows = [r for r in d if r["model"] == model and r["cond"] == cond]
|
|
12
|
+
if not rows:
|
|
13
|
+
continue
|
|
14
|
+
t = Counter(judge(r["verdict"], r["cause"], r["decoy"], r["conf"]) for r in rows)
|
|
15
|
+
n = len(rows)
|
|
16
|
+
print(f"{model:>22} | {cond:>7} | CAUSE {t['cause']}/{n} | DECOY {t['decoy']}/{n} | "
|
|
17
|
+
f"conf {t['conf']}/{n} | wrong {t['wrong']}/{n} | reject {t['reject']}/{n} | hedge {t['hedge']}/{n}")
|