methodlm 1.0.1__tar.gz

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-1.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gavin Branaa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: methodlm
3
+ Version: 1.0.1
4
+ Summary: A verifiable causal-reasoning harness: pre-registers every test, runs real backdoor adjustment / IV / refutation, and keeps an audit ledger, so any model has to prove its causal claims instead of asserting them.
5
+ Author-email: Gavin Branaa <gbranaa4@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/tritsystem/methodlm
8
+ Project-URL: Repository, https://github.com/tritsystem/methodlm
9
+ Project-URL: Issues, https://github.com/tritsystem/methodlm/issues
10
+ Project-URL: Changelog, https://github.com/tritsystem/methodlm/blob/main/CHANGELOG.md
11
+ Keywords: causal inference,reasoning,llm,backdoor adjustment,instrumental variables,reproducibility
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Topic :: Scientific/Engineering
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: numpy
23
+ Provides-Extra: frontier
24
+ Requires-Dist: anthropic; extra == "frontier"
25
+ Provides-Extra: data
26
+ Requires-Dist: pandas; extra == "data"
27
+ Requires-Dist: pyarrow; extra == "data"
28
+ Requires-Dist: openpyxl; extra == "data"
29
+ Provides-Extra: ternary
30
+ Requires-Dist: torch; extra == "ternary"
31
+ Provides-Extra: refute
32
+ Requires-Dist: dowhy; extra == "refute"
33
+ Provides-Extra: contrastive
34
+ Requires-Dist: transformers; extra == "contrastive"
35
+ Provides-Extra: dev
36
+ Requires-Dist: pytest; extra == "dev"
37
+ Dynamic: license-file
38
+
39
+ # MethodLM
40
+
41
+ **The method, wrapped around a language model, kept honest by a ledger.** Point it at data;
42
+ it **computes** on it (a ternary two-timescale readout) and **reasons** about it with the
43
+ gbranaa-hue research method — pre-registering every test and keeping an audit trail — so any
44
+ model has to *prove* its causal claims instead of asserting them.
45
+
46
+ > Part of the ternary line — sibling to **[OBSERVE / 012-trit-search](https://github.com/tritsystem/012-trit-search)**
47
+ > (local, private semantic code search). OBSERVE searches your code privately; MethodLM
48
+ > reasons about your data honestly. The optional ternary "second witness" here uses the same
49
+ > `tritkit` two-timescale layer.
50
+
51
+ ## What it's for
52
+
53
+ Language models confuse correlation with causation constantly. MethodLM is a **verifiable
54
+ causal-reasoning harness**: it makes any model refuse false causation and back its answers
55
+ with real tests, leaving a checkable ledger. On a benchmark of confounded scenarios its
56
+ backdoor-adjustment test **cuts false causal claims from 100% (naive correlation) to ~1%**
57
+ while keeping 100% detection of the true cause (`benchmark_causal.py`).
58
+
59
+ The discipline — not the model — is the product. A weak local model and a frontier model are
60
+ held to the *same* standard: no `FINAL` verdict without a real, pre-registered test.
61
+
62
+ ### Causal tools the copilot can run
63
+
64
+ | Tool | What it does |
65
+ |------|--------------|
66
+ | `CORR` | observational correlation (a clue, never a verdict) |
67
+ | `RUN` | a true controlled experiment (interventional demo world) |
68
+ | `STRAT` | stratified check — does the link survive inside bands of a confounder? |
69
+ | `ADJUST` | **backdoor adjustment + sensitivity + bias audit** — effect of X on the target controlling for named confounders, with a Cinelli–Hazlett **robustness value** (how strong a *hidden* confounder would need to be to overturn it; `RV < 0.10` = fragile), **plus a collider/mediator audit** that flags when conditioning on a variable would *introduce* bias (the "Table 2 fallacy"). It detects the data-visible danger (a collider) and honestly defers mediator-vs-confounder to your DAG — so it never tells you to blindly "adjust for everything." Its robustness value can only speak to confounders you actually measured; it can't rule out an *unmeasured* one — that's what `IV` is for. |
70
+ | `IV` | **instrumental variables (2SLS)** — the remedy for confounding ADJUST structurally cannot reach: an *unmeasured* common cause of X and the target. Given a genuine instrument (moves X, no direct effect on the target except through X), does a real two-stage least squares fit and reports the **first-stage F-statistic** (`F < 10` = weak instrument, the standard Stock–Yogo-adjacent rule of thumb — a weak instrument makes 2SLS *worse* than plain OLS, not better, and this tool says so plainly rather than silently reporting a bad estimate). The exclusion restriction (no direct X-free path from the instrument to the target) is stated on every call as an assumption that **cannot be verified from data alone** — same honest boundary as ADJUST's collider/mediator split. Measured: on a synthetic scenario with an unmeasured confounder that fools both naive correlation and ADJUST (whose own RV reads as *robust*, RV≈0.8, because it cannot see the hidden confound), IV recovers an estimate close to the true effect; on a deliberately weak instrument the F-statistic diagnostic reliably flags it (`F<10` on every trial across 5 reruns, with wildly unstable point estimates — proof the flag is doing real work, not decoration). |
71
+ | `REFUTE` | **DoWhy-backed refutation testing** (optional — needs `pip install dowhy`) — a second, independently-derived robustness check on a candidate ADJUST already found promising: DoWhy's own `backdoor.linear_regression` estimator, then three real perturbation tests (placebo treatment, random common cause, data-subset). A real effect should collapse toward 0 under the placebo and barely move under the other two. Checks numerical robustness, not causal role — it doesn't replace ADJUST's collider/mediator audit. |
72
+ | `ATTR` | the ternary compute gate's independent evidence per column (the optional second witness) |
73
+
74
+ Pre-registration is enforced: no `FINAL` is accepted until at least one real test
75
+ (`ADJUST`/`STRAT`/`RUN`/`INTERACT`/`IV`/`REFUTE`) has run.
76
+
77
+ ## Install
78
+
79
+ ```
80
+ pip install numpy # required — the reasoning harness + tools
81
+ pip install anthropic # optional — to drive a frontier model (--model opus/sonnet/haiku)
82
+ pip install pandas pyarrow openpyxl # optional — extra data formats (Parquet / Excel)
83
+ pip install torch # optional — enables the ternary second witness
84
+ pip install dowhy # optional — enables REFUTE (independent robustness check)
85
+ pip install transformers # optional — enables the contrastive local backend (--model contrastive)
86
+ ```
87
+
88
+ - **Reasoning half** runs on `numpy` alone.
89
+ - **Frontier backend** needs `anthropic` + `ANTHROPIC_API_KEY` (set a low workspace spend limit).
90
+ - **Local backend** needs a llama.cpp `llama-completion` binary + a small GGUF (e.g. Qwen); point `methodlm_models.py` at yours. (Weights/binaries are not shipped here.)
91
+ - **Ternary second witness** (optional) needs `torch` + `tritkit` (from
92
+ [012-trit-search](https://github.com/tritsystem/012-trit-search)); set
93
+ `METHODLM_TRITKIT=/path/to/tritkit_parent`. Without it, MethodLM prints a note and runs the
94
+ reasoning half normally.
95
+
96
+ ## Run it
97
+
98
+ ```
99
+ python methodlm.py --demo # hidden-confound world + answer key
100
+ python methodlm.py --diabetes # real data: 442 diabetes patients (sklearn)
101
+ python methodlm.py --data FILE --target COLUMN # any tabular dataset
102
+ python methodlm.py --demo --race # head-to-head vs the same model, no method
103
+ python methodlm.py --diabetes --model opus # drive a frontier model instead of local
104
+ python methodlm_gui.py # desktop GUI (opens in your browser)
105
+ ```
106
+
107
+ ## Reasoning backend (`--model`)
108
+
109
+ | `--model` | Backend | Notes |
110
+ |-----------|---------|-------|
111
+ | `local` (default) | a GGUF via `llama-completion` | private, offline, free (bring your own model) |
112
+ | `contrastive` / `cd` | real Qwen2.5-1.5B-Instruct amplified against Qwen2.5-0.5B-Instruct via [contrastive decoding](https://arxiv.org/abs/2210.15097) (`structured_attention.contrastive_next_token_logits`) | local, offline, GPU (falls back to CPU); needs `transformers` + the `structured-attention` package (`pip install -e /path/to/structured-attention` or `METHODLM_STRUCTURED_ATTENTION=/path`); tunable via `METHODLM_CD_WEAK`/`METHODLM_CD_STRONG`/`METHODLM_CD_ALPHA`/`METHODLM_CD_BETA`. Verified end-to-end (`methodlm.py --demo --model contrastive`): loads in ~16s, drives the full PREREGISTER→ADJUST→FINAL tool loop and writes a real ledger. **Not separately benchmarked yet** (no `benchmark_causal.py`/`benchmark_models.py` numbers for this backend specifically) — same discipline (pre-registration, loop guards, ledger) as every other backend, but its *causal accuracy on the benchmark suite* is unmeasured so far. |
113
+ | `opus` / `sonnet` / `haiku` | Claude via the Anthropic API | frontier reasoning; needs `anthropic` + key |
114
+
115
+ The GUI reads the backend from the `METHODLM_MODEL` environment variable.
116
+
117
+ ## What we measured (multi-model matrix)
118
+
119
+ `benchmark_models.py` runs plain-vs-harness on the same confounded items across models. The
120
+ honest finding: the harness's value is **capability-dependent** — a capable model wrapped in
121
+ it reads its own tool output and reaches the correct, auditable driver (where the same model
122
+ unwrapped hedges or endorses the decoy); a weak model becomes *safe* (stops confidently
123
+ endorsing the bystander) but can't always synthesize a verdict. `test_judge.py` locks the
124
+ scorer against real verdicts; every run writes a full-verdict audit JSON for re-scoring.
125
+
126
+ ## Target column
127
+
128
+ The **target** is the one thing you want explained — the outcome whose *cause* you're after.
129
+ MethodLM asks *"what drives the target?"* and treats every other column as a candidate. Run
130
+ `--data FILE` with no `--target` to list every column with its kind; the GUI shows them as
131
+ clickable chips.
132
+
133
+ ## Data formats (`methodlm_io.py`)
134
+
135
+ CSV/TSV, JSON/JSONL, Parquet, SQLite (`--table`/`--query`), NumPy `.npz`, Excel. The
136
+ featurizer coerces mixed columns and **reports every step** into the ledger; free-text /
137
+ high-cardinality columns are dropped (stated, with the count). **Honest boundary:** a lone
138
+ image, raw audio, or a free-text blob isn't a "what drives Y" question until something
139
+ featurizes it into columns with a target.
140
+
141
+ ## Why this is different from asking a chatbot
142
+
143
+ Every claim is bought with a test that was pre-registered *before* the result came back and
144
+ executed by real computation. When the optional ternary gate (reads gradients) and the
145
+ copilot (reads experiments) converge, that's two independent witnesses, not one.
146
+
147
+ ## Honest limits
148
+
149
+ Small-model reasoning can misread its own results (the ledger catches it; a capable driver
150
+ avoids it). Ternary readouts trade precision for ~20× compression. `STRAT` is conditioning,
151
+ not intervention — it cannot rule out unmeasured confounds, and the copilot is told so.
152
+
153
+ ## License
154
+
155
+ MIT — see `LICENSE`.
@@ -0,0 +1,117 @@
1
+ # MethodLM
2
+
3
+ **The method, wrapped around a language model, kept honest by a ledger.** Point it at data;
4
+ it **computes** on it (a ternary two-timescale readout) and **reasons** about it with the
5
+ gbranaa-hue research method — pre-registering every test and keeping an audit trail — so any
6
+ model has to *prove* its causal claims instead of asserting them.
7
+
8
+ > Part of the ternary line — sibling to **[OBSERVE / 012-trit-search](https://github.com/tritsystem/012-trit-search)**
9
+ > (local, private semantic code search). OBSERVE searches your code privately; MethodLM
10
+ > reasons about your data honestly. The optional ternary "second witness" here uses the same
11
+ > `tritkit` two-timescale layer.
12
+
13
+ ## What it's for
14
+
15
+ Language models confuse correlation with causation constantly. MethodLM is a **verifiable
16
+ causal-reasoning harness**: it makes any model refuse false causation and back its answers
17
+ with real tests, leaving a checkable ledger. On a benchmark of confounded scenarios its
18
+ backdoor-adjustment test **cuts false causal claims from 100% (naive correlation) to ~1%**
19
+ while keeping 100% detection of the true cause (`benchmark_causal.py`).
20
+
21
+ The discipline — not the model — is the product. A weak local model and a frontier model are
22
+ held to the *same* standard: no `FINAL` verdict without a real, pre-registered test.
23
+
24
+ ### Causal tools the copilot can run
25
+
26
+ | Tool | What it does |
27
+ |------|--------------|
28
+ | `CORR` | observational correlation (a clue, never a verdict) |
29
+ | `RUN` | a true controlled experiment (interventional demo world) |
30
+ | `STRAT` | stratified check — does the link survive inside bands of a confounder? |
31
+ | `ADJUST` | **backdoor adjustment + sensitivity + bias audit** — effect of X on the target controlling for named confounders, with a Cinelli–Hazlett **robustness value** (how strong a *hidden* confounder would need to be to overturn it; `RV < 0.10` = fragile), **plus a collider/mediator audit** that flags when conditioning on a variable would *introduce* bias (the "Table 2 fallacy"). It detects the data-visible danger (a collider) and honestly defers mediator-vs-confounder to your DAG — so it never tells you to blindly "adjust for everything." Its robustness value can only speak to confounders you actually measured; it can't rule out an *unmeasured* one — that's what `IV` is for. |
32
+ | `IV` | **instrumental variables (2SLS)** — the remedy for confounding ADJUST structurally cannot reach: an *unmeasured* common cause of X and the target. Given a genuine instrument (moves X, no direct effect on the target except through X), does a real two-stage least squares fit and reports the **first-stage F-statistic** (`F < 10` = weak instrument, the standard Stock–Yogo-adjacent rule of thumb — a weak instrument makes 2SLS *worse* than plain OLS, not better, and this tool says so plainly rather than silently reporting a bad estimate). The exclusion restriction (no direct X-free path from the instrument to the target) is stated on every call as an assumption that **cannot be verified from data alone** — same honest boundary as ADJUST's collider/mediator split. Measured: on a synthetic scenario with an unmeasured confounder that fools both naive correlation and ADJUST (whose own RV reads as *robust*, RV≈0.8, because it cannot see the hidden confound), IV recovers an estimate close to the true effect; on a deliberately weak instrument the F-statistic diagnostic reliably flags it (`F<10` on every trial across 5 reruns, with wildly unstable point estimates — proof the flag is doing real work, not decoration). |
33
+ | `REFUTE` | **DoWhy-backed refutation testing** (optional — needs `pip install dowhy`) — a second, independently-derived robustness check on a candidate ADJUST already found promising: DoWhy's own `backdoor.linear_regression` estimator, then three real perturbation tests (placebo treatment, random common cause, data-subset). A real effect should collapse toward 0 under the placebo and barely move under the other two. Checks numerical robustness, not causal role — it doesn't replace ADJUST's collider/mediator audit. |
34
+ | `ATTR` | the ternary compute gate's independent evidence per column (the optional second witness) |
35
+
36
+ Pre-registration is enforced: no `FINAL` is accepted until at least one real test
37
+ (`ADJUST`/`STRAT`/`RUN`/`INTERACT`/`IV`/`REFUTE`) has run.
38
+
39
+ ## Install
40
+
41
+ ```
42
+ pip install numpy # required — the reasoning harness + tools
43
+ pip install anthropic # optional — to drive a frontier model (--model opus/sonnet/haiku)
44
+ pip install pandas pyarrow openpyxl # optional — extra data formats (Parquet / Excel)
45
+ pip install torch # optional — enables the ternary second witness
46
+ pip install dowhy # optional — enables REFUTE (independent robustness check)
47
+ pip install transformers # optional — enables the contrastive local backend (--model contrastive)
48
+ ```
49
+
50
+ - **Reasoning half** runs on `numpy` alone.
51
+ - **Frontier backend** needs `anthropic` + `ANTHROPIC_API_KEY` (set a low workspace spend limit).
52
+ - **Local backend** needs a llama.cpp `llama-completion` binary + a small GGUF (e.g. Qwen); point `methodlm_models.py` at yours. (Weights/binaries are not shipped here.)
53
+ - **Ternary second witness** (optional) needs `torch` + `tritkit` (from
54
+ [012-trit-search](https://github.com/tritsystem/012-trit-search)); set
55
+ `METHODLM_TRITKIT=/path/to/tritkit_parent`. Without it, MethodLM prints a note and runs the
56
+ reasoning half normally.
57
+
58
+ ## Run it
59
+
60
+ ```
61
+ python methodlm.py --demo # hidden-confound world + answer key
62
+ python methodlm.py --diabetes # real data: 442 diabetes patients (sklearn)
63
+ python methodlm.py --data FILE --target COLUMN # any tabular dataset
64
+ python methodlm.py --demo --race # head-to-head vs the same model, no method
65
+ python methodlm.py --diabetes --model opus # drive a frontier model instead of local
66
+ python methodlm_gui.py # desktop GUI (opens in your browser)
67
+ ```
68
+
69
+ ## Reasoning backend (`--model`)
70
+
71
+ | `--model` | Backend | Notes |
72
+ |-----------|---------|-------|
73
+ | `local` (default) | a GGUF via `llama-completion` | private, offline, free (bring your own model) |
74
+ | `contrastive` / `cd` | real Qwen2.5-1.5B-Instruct amplified against Qwen2.5-0.5B-Instruct via [contrastive decoding](https://arxiv.org/abs/2210.15097) (`structured_attention.contrastive_next_token_logits`) | local, offline, GPU (falls back to CPU); needs `transformers` + the `structured-attention` package (`pip install -e /path/to/structured-attention` or `METHODLM_STRUCTURED_ATTENTION=/path`); tunable via `METHODLM_CD_WEAK`/`METHODLM_CD_STRONG`/`METHODLM_CD_ALPHA`/`METHODLM_CD_BETA`. Verified end-to-end (`methodlm.py --demo --model contrastive`): loads in ~16s, drives the full PREREGISTER→ADJUST→FINAL tool loop and writes a real ledger. **Not separately benchmarked yet** (no `benchmark_causal.py`/`benchmark_models.py` numbers for this backend specifically) — same discipline (pre-registration, loop guards, ledger) as every other backend, but its *causal accuracy on the benchmark suite* is unmeasured so far. |
75
+ | `opus` / `sonnet` / `haiku` | Claude via the Anthropic API | frontier reasoning; needs `anthropic` + key |
76
+
77
+ The GUI reads the backend from the `METHODLM_MODEL` environment variable.
78
+
79
+ ## What we measured (multi-model matrix)
80
+
81
+ `benchmark_models.py` runs plain-vs-harness on the same confounded items across models. The
82
+ honest finding: the harness's value is **capability-dependent** — a capable model wrapped in
83
+ it reads its own tool output and reaches the correct, auditable driver (where the same model
84
+ unwrapped hedges or endorses the decoy); a weak model becomes *safe* (stops confidently
85
+ endorsing the bystander) but can't always synthesize a verdict. `test_judge.py` locks the
86
+ scorer against real verdicts; every run writes a full-verdict audit JSON for re-scoring.
87
+
88
+ ## Target column
89
+
90
+ The **target** is the one thing you want explained — the outcome whose *cause* you're after.
91
+ MethodLM asks *"what drives the target?"* and treats every other column as a candidate. Run
92
+ `--data FILE` with no `--target` to list every column with its kind; the GUI shows them as
93
+ clickable chips.
94
+
95
+ ## Data formats (`methodlm_io.py`)
96
+
97
+ CSV/TSV, JSON/JSONL, Parquet, SQLite (`--table`/`--query`), NumPy `.npz`, Excel. The
98
+ featurizer coerces mixed columns and **reports every step** into the ledger; free-text /
99
+ high-cardinality columns are dropped (stated, with the count). **Honest boundary:** a lone
100
+ image, raw audio, or a free-text blob isn't a "what drives Y" question until something
101
+ featurizes it into columns with a target.
102
+
103
+ ## Why this is different from asking a chatbot
104
+
105
+ Every claim is bought with a test that was pre-registered *before* the result came back and
106
+ executed by real computation. When the optional ternary gate (reads gradients) and the
107
+ copilot (reads experiments) converge, that's two independent witnesses, not one.
108
+
109
+ ## Honest limits
110
+
111
+ Small-model reasoning can misread its own results (the ledger catches it; a capable driver
112
+ avoids it). Ternary readouts trade precision for ~20× compression. `STRAT` is conditioning,
113
+ not intervention — it cannot rule out unmeasured confounds, and the copilot is told so.
114
+
115
+ ## License
116
+
117
+ MIT — see `LICENSE`.
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env python3
2
+ """Causal-reasoning benchmark: does the method reject confounded decoys?
3
+
4
+ Each scenario has a KNOWN ground truth (a confounding DAG):
5
+ Sea -> cause, Sea -> decoy, cause -> outcome (+ pure-noise features)
6
+ So `cause` truly drives `outcome`; `decoy` only correlates with it through the shared
7
+ latent `Sea` (the humidity/temperature structure, generalized). Ground-truth causal
8
+ set = {cause}.
9
+
10
+ Two statistical arms, scored over K scenarios:
11
+ naive : flag a feature causal if |corr(feature, outcome)| > tau
12
+ method : flag it if |partial corr controlling for the other features| > tau
13
+ (= MethodLM's ADJUST/gate logic: backdoor adjustment)
14
+
15
+ The metric that matters is the FALSE-POSITIVE RATE ON THE DECOY -- how often each arm
16
+ calls a confounded bystander a cause (the "temperature causes error, buy cooling"
17
+ mistake). Pre-registered: naive flags the decoy most of the time; method rarely does,
18
+ while both keep a high true-positive rate on the real cause.
19
+
20
+ Optional --llm arm: on a few scenarios, run a plain LLM vs the full MethodLM harness
21
+ and check which one refuses the decoy in words.
22
+
23
+ Reproducible: fixed seed. This is SYNTHETIC (known ground truth) and mirrors the
24
+ structure of public causal-reasoning suites (Corr2Cause / CLADDER); running on those
25
+ is the online next step.
26
+ """
27
+ import argparse
28
+ import numpy as np
29
+
30
+ TAU = 0.10
31
+ rng = np.random.default_rng(20260709)
32
+
33
+
34
+ def scenario(n=600, n_noise=3):
35
+ sea = rng.standard_normal(n)
36
+ a, c, b = rng.uniform(0.6, 0.9), rng.uniform(0.6, 0.9), rng.uniform(0.7, 1.0)
37
+ cause = a * sea + rng.standard_normal(n)
38
+ decoy = c * sea + rng.standard_normal(n)
39
+ outcome = b * cause + rng.standard_normal(n) # outcome driven by cause ONLY
40
+ d = {"cause": cause, "decoy": decoy}
41
+ for i in range(n_noise):
42
+ d[f"noise{i+1}"] = rng.standard_normal(n)
43
+ d["outcome"] = outcome
44
+ return d
45
+
46
+
47
+ def partial_all(d, x, target="outcome"):
48
+ Z = [c for c in d if c not in (x, target)]
49
+ n = len(d[target])
50
+ A = np.column_stack([d[c] for c in Z] + [np.ones(n)])
51
+ def resid(v):
52
+ beta, *_ = np.linalg.lstsq(A, v, rcond=None); return v - A @ beta
53
+ return float(np.corrcoef(resid(d[x]), resid(d[target]))[0, 1])
54
+
55
+
56
+ def stat_benchmark(K=120):
57
+ feats = None
58
+ tally = {"naive": {}, "method": {}}
59
+ val = {"naive": {"cause": [], "decoy": []}, "method": {"cause": [], "decoy": []}}
60
+ for _ in range(K):
61
+ d = scenario()
62
+ feats = [c for c in d if c != "outcome"]
63
+ for f in feats:
64
+ raw = abs(float(np.corrcoef(d[f], d["outcome"])[0, 1]))
65
+ par = abs(partial_all(d, f))
66
+ for arm, s in (("naive", raw), ("method", par)):
67
+ tally[arm].setdefault(f, 0)
68
+ tally[arm][f] += int(s > TAU)
69
+ role = f if f in ("cause", "decoy") else "noise"
70
+ if role in ("cause", "decoy"):
71
+ val["naive"][role].append(raw); val["method"][role].append(par)
72
+ noise = [f for f in feats if f.startswith("noise")]
73
+ def rate(arm, f): return tally[arm][f] / K
74
+ def noise_rate(arm): return np.mean([tally[arm][f] for f in noise]) / K
75
+
76
+ print(f"causal benchmark: {K} confounded scenarios, tau={TAU}\n")
77
+ print(f"{'arm':>8} | {'flags CAUSE':>11} | {'flags DECOY':>11} | {'flags noise':>11} | mean |assoc|")
78
+ print("-" * 74)
79
+ for arm in ("naive", "method"):
80
+ mc = np.mean(val[arm]["cause"]); md = np.mean(val[arm]["decoy"])
81
+ print(f"{arm:>8} | {rate(arm,'cause')*100:>10.0f}% | {rate(arm,'decoy')*100:>10.0f}% | "
82
+ f"{noise_rate(arm)*100:>10.0f}% | cause {mc:+.2f} · decoy {md:+.2f}")
83
+ dn, dm = rate("naive", "decoy"), rate("method", "decoy")
84
+ print(f"\nHEADLINE: naive correlation calls the confounded decoy a cause {dn*100:.0f}% of the "
85
+ f"time;\n the method (backdoor adjustment) does so {dm*100:.0f}% of the time"
86
+ + (f" -- a {(dn-dm)/max(dn,1e-9)*100:.0f}% cut in false causal claims." if dn > dm else "."))
87
+ tp = rate("method", "cause")
88
+ print(f" Method keeps {tp*100:.0f}% true-positive on the real cause.")
89
+ return {"naive_decoy_fp": dn, "method_decoy_fp": dm, "method_cause_tp": tp}
90
+
91
+
92
+ def llm_arm(n_items=2):
93
+ import methodlm as M
94
+ M.BACKEND = M.methodlm_models.get_model("local", M.HERE)
95
+ print(f"\n--- LLM arm: plain model vs MethodLM harness ({M.BACKEND.label}) ---")
96
+ for i in range(n_items):
97
+ d = scenario()
98
+ rC = float(np.corrcoef(d["cause"], d["outcome"])[0, 1])
99
+ rD = float(np.corrcoef(d["decoy"], d["outcome"])[0, 1])
100
+ q = (f"Our outcome correlates with decoy (r={rD:+.2f}) and with cause (r={rC:+.2f}). "
101
+ "A stakeholder wants to intervene on decoy. What actually drives outcome?")
102
+ print(f"\n[item {i+1}] truth: cause drives outcome; decoy is a confounded bystander "
103
+ f"(corr decoy {rD:+.2f}, cause {rC:+.2f})")
104
+ van = M.vanilla_answer(q)
105
+ print(f" plain LLM : {van[:180]}")
106
+ res = M.investigate(f"bench{i+1}", d, "outcome", q, False)
107
+ v = res["verdict"]
108
+ endorses_decoy = "decoy" in v.lower() and "not" not in v.lower()[:v.lower().find("decoy")+6]
109
+ print(f" MethodLM : ({res['nrun']} test) {v[:180]}")
110
+
111
+
112
+ if __name__ == "__main__":
113
+ ap = argparse.ArgumentParser()
114
+ ap.add_argument("--llm", action="store_true", help="also run the slow LLM arm")
115
+ ap.add_argument("-K", type=int, default=120)
116
+ args = ap.parse_args()
117
+ stat_benchmark(args.K)
118
+ if args.llm:
119
+ llm_arm()
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env python3
2
+ """Many LLMs x {plain, MethodLM harness} on the SAME confounded items.
3
+
4
+ Fairness: feature names are NEUTRAL (alpha/beta/gamma/...), so no model can pattern-
5
+ match the answer off a label; the true cause and the confounded decoy get random names
6
+ each item; every model sees the identical items (fixed seed). Ground truth = the cause
7
+ drives y; the decoy only correlates through a shared latent.
8
+
9
+ Per (model, condition) we score each verdict as naming the true CAUSE, the confounded
10
+ DECOY (the costly error a stakeholder would act on), or a HEDGE. The comparison shows
11
+ (a) harness vs no-harness lift within a model, and (b) models against each other.
12
+
13
+ Available backends depend on the box. Frontier models (claude) need SDK + creds +
14
+ internet; on an offline machine they're reported unavailable. Local Qwen variants run.
15
+ """
16
+ import os
17
+ import numpy as np
18
+ import methodlm as M
19
+ from methodlm_models import get_model
20
+
21
+ HERE = M.HERE
22
+ NAMES = ["alpha", "beta", "gamma", "delta", "epsilon"]
23
+ MODELS = ["opus", "sonnet", "haiku", "qwen3b", "qwen05b", "baked"] # skip if unavailable
24
+ N = 3
25
+
26
+
27
+ def scenario(rng, n=700):
28
+ """A genuinely adversarial confounded item: an OBSERVED confounder drives BOTH y and
29
+ the decoy, so the decoy OUT-CORRELATES the true cause in raw r -- 'pick the biggest
30
+ correlation' lands on the decoy (or the confounder), NEVER the cause. Only adjustment
31
+ (control for the confounder) recovers the truth. DAG: conf->decoy, conf->y, cause->y.
32
+
33
+ Coefficients are deliberately lopsided: the decoy tracks the confounder tightly (little
34
+ noise), the confounder's push on y is large, and the true cause's push is modest. So
35
+ raw r ranks decoy/conf above cause, while adjustment collapses the decoy and lifts the
36
+ cause. make_items() rejection-samples on top of this to GUARANTEE the trap."""
37
+ idx = rng.permutation(len(NAMES))
38
+ cause, decoy, conf = NAMES[idx[0]], NAMES[idx[1]], NAMES[idx[2]]
39
+ noises = [NAMES[i] for i in idx[3:]]
40
+ L = rng.standard_normal(n) # observed confounder (a real column)
41
+ cv = rng.standard_normal(n) # the true cause's own variation
42
+ p = rng.uniform(1.1, 1.4) # decoy tracks the confounder tightly
43
+ g = rng.uniform(1.4, 1.9) # confounder's (large) push on y
44
+ b = rng.uniform(0.45, 0.65) # true cause's (modest) push on y
45
+ d = {cause: cv, decoy: p * L + 0.25 * rng.standard_normal(n), conf: L}
46
+ for nm in noises:
47
+ d[nm] = rng.standard_normal(n)
48
+ d = {k: d[k] for k in NAMES if k in d}
49
+ d["y"] = b * cv + g * L + rng.standard_normal(n) # y driven by cause AND the confounder
50
+ return d, cause, decoy, conf
51
+
52
+
53
+ def claimed_driver(v):
54
+ """The single variable a verdict names as the driver, read from its CONCLUSION and its
55
+ actionable recommendation. Guards against: the comparative trap ("drives y more than
56
+ alpha" is the first term), variables named only to be dismissed, and reverse phrasings
57
+ ("the column that drives y IS alpha", "intervention efforts on alpha")."""
58
+ import re
59
+ v = (v or "").strip()
60
+ if not v:
61
+ return None
62
+ low = v.lower()
63
+ feats = "|".join(NAMES)
64
+ NEG = r"\b(?:not|isn't|no longer|never|bystander|confounded|spurious|merely|only|rather than|instead of)\b"
65
+ # a driver-claim is void if it sits under a negation ("do NOT intervene on alpha",
66
+ # "don't ASSUME alpha drives y", "BEFORE intervening on gamma") -- check the run-up.
67
+ NEGCTX = re.compile(r"\b(?:not|n't|never|avoid|without|before|prior to|rather than|instead of|"
68
+ r"don't|do not|isn't|aren't|cannot|can't|won't|wouldn't|assume)\b")
69
+ # focus on the conclusion (NOT the 'recommendation' advice line, which often drops the claim)
70
+ marks = list(re.finditer(r"\b(?:final(?:\s*answer)?|conclusion|bottom line|verdict)\b\s*[:\-]?", low))
71
+ tail = (low[marks[-1].end():].strip() or low) if marks else low
72
+
73
+ def endorses(feat, text):
74
+ pats = [ # forward "<feat> drives/is the driver", reverse "drives ... is <feat>", actionable "intervene on <feat>"
75
+ rf"\b{feat}\b(?:(?!{NEG})[^.]){{0,55}}\b(?:drives?\b|is (?:the |a )?(?:\w+\s+){{0,3}}(?:driver|cause|lever|predictor))",
76
+ rf"\b(?:drives?|driver|the cause|lever)\b(?:(?!{NEG})[^.]){{0,25}}\bis\b[^.]{{0,12}}\b{feat}\b",
77
+ rf"\b(?:interven\w* on|intervention(?:\s+\w+)?\s+on|target|focus\w*(?:\s+\w+){{0,3}}\s+on|responsible for)\b[^.]{{0,25}}\b{feat}\b",
78
+ ]
79
+ for pat in pats:
80
+ for m in re.finditer(pat, text):
81
+ if not NEGCTX.search(text[max(0, m.start() - 30):m.start()]): # not under a negation
82
+ return True
83
+ return False
84
+
85
+ def scan(text):
86
+ return next((feat for feat in NAMES if endorses(feat, text)), None)
87
+
88
+ # 1) the conclusion LEADS with the answer as a (bare/emphasized/quoted) feature name
89
+ lead = re.match(rf"[\*\s>#\-\"']*({feats})\b(.*?)(?:[.;\n]|$)", tail)
90
+ if lead and not re.search(NEG, lead.group(2)):
91
+ return lead.group(1)
92
+ return scan(tail) or scan(low)
93
+
94
+
95
+ def judge(v, cause, decoy, conf=None):
96
+ import re
97
+ dr = claimed_driver(v)
98
+ if dr is None:
99
+ if re.search(r"not causal|no.{0,3}caus|can(?:not|'t) (?:\w+\s+){0,2}(?:tell|determine|say|identify|know)|"
100
+ r"correlation (?:alone|is not)|isn't caus|no single|insufficient|unknown", (v or "").lower()):
101
+ return "reject"
102
+ return "hedge"
103
+ if dr == cause: return "cause"
104
+ if dr == decoy: return "decoy"
105
+ if conf is not None and dr == conf: return "conf" # the confounder is itself a co-driver of y, not the costly error
106
+ return "wrong" # named a noise column as the driver
107
+
108
+
109
+ def available(key):
110
+ try:
111
+ return get_model(key, HERE)
112
+ except SystemExit:
113
+ return None
114
+ except Exception as e:
115
+ print(f" ({key} unavailable: {str(e)[:60]})")
116
+ return None
117
+
118
+
119
+ CATS = ("cause", "decoy", "conf", "wrong", "reject", "hedge")
120
+
121
+
122
+ def _partial(d, x, target="y"):
123
+ """Adjusted (partial) corr of x on target controlling for every other column -- the
124
+ same standardized-regression estimate the ADJUST tool reports, used here to certify
125
+ that an item is a real trap before we spend a model on it."""
126
+ zs = [c for c in d if c not in (x, target)]
127
+ n = len(d[target])
128
+ Z = lambda a: (np.asarray(a, float) - np.mean(a)) / (np.std(a) + 1e-9)
129
+ y = Z(d[target]); X = np.column_stack([Z(d[c]) for c in [x] + zs] + [np.ones(n)])
130
+ beta, *_ = np.linalg.lstsq(X, y, rcond=None); resid = y - X @ beta; dof = n - X.shape[1]
131
+ se = np.sqrt(((resid ** 2).sum() / max(dof, 1)) * np.diag(np.linalg.pinv(X.T @ X)))
132
+ t = beta[0] / (se[0] + 1e-12)
133
+ return float(t / np.sqrt(t * t + dof)) if dof > 0 else float("nan")
134
+
135
+
136
+ def make_items(seed=7):
137
+ """Rejection-sample scenarios until every kept item is genuinely adversarial: the decoy
138
+ OUT-correlates the cause in raw r (so naive-max-correlation is tempted by the decoy),
139
+ yet the cause dominates on adjustment (so the discipline can still recover it)."""
140
+ rng = np.random.default_rng(seed)
141
+ items, tries = [], 0
142
+ while len(items) < N and tries < 2000:
143
+ tries += 1
144
+ d, c, dec, conf = scenario(rng)
145
+ rC = abs(float(np.corrcoef(d[c], d["y"])[0, 1]))
146
+ rD = abs(float(np.corrcoef(d[dec], d["y"])[0, 1]))
147
+ if rD > rC + 0.10 and abs(_partial(d, c)) > abs(_partial(d, dec)) + 0.20:
148
+ items.append((d, c, dec, conf))
149
+ if len(items) < N:
150
+ raise SystemExit(f"could only build {len(items)}/{N} adversarial items; loosen thresholds")
151
+ return items
152
+
153
+
154
+ def check():
155
+ """Instrument validity: on the hard items, does naive-max-correlation actually
156
+ fail (so the benchmark measures reasoning, not luck)?"""
157
+ items = make_items()
158
+ naive_ok = 0
159
+ for i, (d, cause, decoy, conf) in enumerate(items, 1):
160
+ cs = {k: abs(float(np.corrcoef(d[k], d["y"])[0, 1])) for k in d if k != "y"}
161
+ top = max(cs, key=cs.get)
162
+ naive_ok += (top == cause)
163
+ print(f" item {i}: cause={cause} r={cs[cause]:+.2f} | decoy={decoy} r={cs[decoy]:+.2f} "
164
+ f"| conf={conf} | naive picks {top} ({'ok' if top==cause else 'WRONG'})")
165
+ print(f"\nnaive-max-correlation accuracy on the hard items: {naive_ok}/{N} "
166
+ f"(want < {N} — the trap must actually trap)")
167
+
168
+
169
+ def main():
170
+ items = make_items()
171
+ print(f"multi-model causal benchmark: {N} HARD confounded items (naive fails), neutral names\n")
172
+ for i, (d, c, dec, conf) in enumerate(items, 1):
173
+ rC = float(np.corrcoef(d[c], d["y"])[0, 1]); rD = float(np.corrcoef(d[dec], d["y"])[0, 1])
174
+ print(f" item {i}: truth cause={c} (r={rC:+.2f}); decoy={dec} (r={rD:+.2f}) via conf={conf}")
175
+
176
+ rows = []; audit = []
177
+ for key in MODELS:
178
+ mdl = available(key)
179
+ if mdl is None:
180
+ print(f"\n=== {key}: UNAVAILABLE (skipped) ==="); continue
181
+ M.BACKEND = mdl
182
+ print(f"\n=== {mdl.label} ===")
183
+ for cond in ("plain", "harness"):
184
+ tally = {c: 0 for c in CATS}; tests = 0
185
+ for i, (d, cause, decoy, conf) in enumerate(items, 1):
186
+ rC = float(np.corrcoef(d[cause], d["y"])[0, 1])
187
+ rD = float(np.corrcoef(d[decoy], d["y"])[0, 1])
188
+ q = (f"Columns {[k for k in d if k!='y']} predict y. {decoy} correlates with y MORE "
189
+ f"strongly (r={rD:+.2f}) than {cause} (r={rC:+.2f}), and a stakeholder wants to "
190
+ f"intervene on {decoy}. Which single column actually drives y?")
191
+ if cond == "plain":
192
+ v = M.vanilla_answer(q)
193
+ else:
194
+ r = M.investigate(f"bm_{key}_{i}", d, "y", q, False); v = r["verdict"]; tests += r["nrun"]
195
+ j = judge(v, cause, decoy, conf); tally[j] += 1
196
+ audit.append({"model": mdl.label, "cond": cond, "item": i, "cause": cause,
197
+ "decoy": decoy, "conf": conf, "rC": round(rC, 3), "rD": round(rD, 3),
198
+ "judged": j, "verdict": v})
199
+ print(f" [{cond:>7} item{i}] -> {j:<6} | {(v or '')[:88]}")
200
+ rows.append((mdl.label, cond, tally, tests))
201
+
202
+ import json
203
+ apath = os.path.join(HERE, "benchmark_models_audit.json")
204
+ with open(apath, "w", encoding="utf-8") as fh:
205
+ json.dump(audit, fh, indent=2, ensure_ascii=False)
206
+
207
+ print("\n" + "=" * 96)
208
+ print(f"{'model':>24} | {'cond':>7} | {'CAUSE ok':>8} | {'DECOY err':>9} | {'conf':>4} | {'wrong':>5} | "
209
+ f"{'reject':>6} | {'hedge':>5} | tests")
210
+ print("-" * 96)
211
+ for label, cond, t, tests in rows:
212
+ print(f"{label:>24} | {cond:>7} | {t['cause']}/{N:<6} | {t['decoy']}/{N:<7} | {t['conf']}/{N:<2} | "
213
+ f"{t['wrong']}/{N:<3} | {t['reject']}/{N:<4} | {t['hedge']}/{N:<3} | {tests if cond=='harness' else '--'}")
214
+ print("\nDECOY err = endorsed the confounded decoy = the pure bystander (THE costly mistake). "
215
+ "CAUSE ok = named the labelled cause · conf = named the confounder, which is itself a "
216
+ "genuine co-driver of y (a real DAG has cause->y AND conf->y) · reject = refused causation.")
217
+ print(f"\nfull verdicts (for audit / re-scoring) -> {apath}")
218
+
219
+
220
+ if __name__ == "__main__":
221
+ import sys
222
+ if "--models" in sys.argv:
223
+ MODELS = sys.argv[sys.argv.index("--models") + 1].split(",")
224
+ check() if "--check" in sys.argv else main()