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.
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}")