rosetta-eclab 0.1.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.
@@ -0,0 +1,196 @@
1
+ """Evaluate the standalone namer.
2
+
3
+ python -m namer.evaluate --pool # development pool: every corpus column NOT used by the v2 test split
4
+ python -m namer.evaluate --split dev # v2 dev tasks -> runs3/rules_dev.jsonl (score with bench.claude_cli --score)
5
+ python -m namer.evaluate --split test # v2 test tasks -> runs3/rules_test.jsonl (run rarely; record in results/open_runs.md)
6
+
7
+ Pipeline per column: constant -> ABSTAIN; identical to a known column -> DUPLICATE;
8
+ name elimination (spike2/elim.py) -> NAME; otherwise the formula library (namer/library.py).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import sys
16
+ from collections import Counter, defaultdict
17
+ from pathlib import Path
18
+
19
+ import numpy as np
20
+
21
+ from spike2 import corpus, elim, features, tasks, vocab
22
+
23
+ from .engine import Context, Known, name_column
24
+ from .fallback import Stats, lag1_autocorr
25
+ from .library import LIBRARY
26
+
27
+ ROOT = Path(__file__).resolve().parents[1]
28
+ RUNS = ROOT / "runs3"
29
+
30
+
31
+ _STATS: Stats | None = None
32
+
33
+
34
+ def consistent(name: str, ctx: Context) -> bool:
35
+ """A statistics guess must not contradict what the library knows about the quantity's form."""
36
+ from .library import DTYPES
37
+
38
+ exp = DTYPES.get(name)
39
+ if exp and ctx.target_dtype not in exp:
40
+ return False
41
+ spec = LIBRARY.get(name)
42
+ from .library import is_clock, piecewise_constant
43
+
44
+ if name in ("Analog IN 1", "Analog IN 2", "Analog OUT"):
45
+ return False # external inputs have no physics to check; only the sibling rule may name them
46
+ if is_clock(ctx) and name not in ("time", "step time", "(Q-Qo)", "Energy we charge", "Energy we discharge", "|Energy|"):
47
+ return False # a monotonic clock is a time (or a running total), nothing else
48
+ from .engine import has_ce_channel, needs_ce_channel
49
+
50
+ if needs_ce_channel(name) and not has_ce_channel(set(ctx.known)):
51
+ return False
52
+ if name == "control" and piecewise_constant(ctx) < 0.5:
53
+ return False # a set point holds still between changes
54
+ form = [sh for sh in (spec.shapes if spec is not None else []) if sh.intrinsic]
55
+ if form and max(float(sh.test(ctx)) for sh in form) <= 0:
56
+ return False
57
+ if spec is not None:
58
+ f = ctx.col("freq")
59
+ impedance_record = f is not None and np.mean(f > 0) > 0.5
60
+ from .library import EIS_FAMILY as _EIS
61
+ if not (impedance_record and name not in _EIS):
62
+ # In impedance records the instrument integrates charge and current over measurement windows that the
63
+ # time stamps do not show, so DC relations cannot rule a name out there.
64
+ ev = spec.best_reliable_relation(ctx)
65
+ if ev is not None and ev.score < 0.3:
66
+ return False # its defining relation was testable here and does not hold with the right units
67
+ from .library import EIS_FAMILY
68
+ if name in EIS_FAMILY and ctx.col("freq") is None:
69
+ return False
70
+ return True
71
+
72
+
73
+ def stats() -> Stats:
74
+ global _STATS
75
+ if _STATS is None:
76
+ _STATS = Stats()
77
+ return _STATS
78
+
79
+
80
+ def answer(target, dtype, known_cols: list, technique, vocabulary: list[str], abstain_below: float = 0.6,
81
+ exclude_group: str | None = None, noise_autocorr: float = 0.3, fallback_min_share: float = 0.5) -> dict:
82
+ if features.is_constant(target):
83
+ return {"verdict": "ABSTAIN", "name": "NONE", "confidence": 1.0, "evidence": "constant column"}
84
+ same = [c.name for c in known_cols if features.identical(target, c.values)]
85
+ if same:
86
+ return {"verdict": "DUPLICATE", "name": "NONE", "duplicate_of": same[0], "confidence": 1.0,
87
+ "evidence": f"identical to {same[0]}"}
88
+ e = elim.eliminate([c.name for c in known_cols], vocabulary)
89
+ if e:
90
+ return {"verdict": "NAME", "name": e, "confidence": 0.9, "evidence": "name elimination (sibling gap)"}
91
+ known = {}
92
+ for c in known_cols:
93
+ known.setdefault(vocab.canonical(c.name), Known(vocab.canonical(c.name), c.unit, np.asarray(c.values), c.dtype))
94
+ ctx = Context(technique, known, np.asarray(target), dtype)
95
+ pr = stats().prior(technique, exclude_group)
96
+ gl = stats().prior(None, exclude_group, all_techniques=True)
97
+ top, gtop = (max(pr.values()) if pr else 0), (max(gl.values()) if gl else 0)
98
+ prior = {k: (pr.get(k, 0) / top if top else 0) + 0.3 * (gl.get(k, 0) / gtop if gtop else 0) for k in set(pr) | set(gl)}
99
+ a = name_column(ctx, LIBRARY, vocabulary, abstain_below, prior,
100
+ cooccur=lambda cand, sib: stats().cooccur(cand, sib, exclude_group))
101
+ if a["verdict"] == "NAME":
102
+ return a
103
+ # (Tried 2026-10-15: naming "the column that similar files always record and this file lacks". It names
104
+ # decoys whenever a file naturally lacks a common column: 10/19 decoys on hold-out 4, still 6/19 when limited
105
+ # to structured columns. Trustworthiness wins: not used.)
106
+ # No formula or shape identifies the column: statistics fallback, unless it looks like noise.
107
+ ac = lag1_autocorr(target)
108
+ if ac < noise_autocorr:
109
+ a["evidence"] = f"no formula fits and the column looks like noise (lag-1 autocorrelation {ac:.2f})"
110
+ return a
111
+ name, share, alts = stats().predict(target, dtype, known_cols, technique, set(vocabulary), set(known),
112
+ exclude_group)
113
+ if name and not consistent(name, ctx):
114
+ a["evidence"] = f"statistics suggest {name}, but the column does not have its form (shape or storage type)"
115
+ return a
116
+ if name and share >= fallback_min_share:
117
+ return {"verdict": "NAME", "name": name, "confidence": round(0.5 * share, 3), "alternatives": alts,
118
+ "evidence": f"no formula fits; statistics of similar columns in other files (vote share {share:.2f})"}
119
+ return a
120
+
121
+
122
+ def test_keys() -> set[tuple[str, int]]:
123
+ m = json.loads((ROOT / "prereg2" / "tasks_manifest.json").read_text(encoding="utf-8"))
124
+ return {(t["file"], t["position"]) for t in m["tasks"] if t["split"] == "test" and t["position"] is not None}
125
+
126
+
127
+ def pool(verbose: int) -> None:
128
+ excluded = test_keys()
129
+ V = vocab.names()
130
+ good, by_name, confusions = Counter(), defaultdict(list), Counter()
131
+ for p in corpus.corpus_files():
132
+ try:
133
+ fd = tasks.file_data(p.name)
134
+ except Exception:
135
+ continue
136
+ if fd.n_points < tasks.MIN_ROWS:
137
+ continue
138
+ cols = tasks.usable_columns(fd)
139
+ for c in cols:
140
+ if (p.name, c.position) in excluded or features.is_constant(c.values):
141
+ continue
142
+ others = [o for o in cols if o is not c]
143
+ if any(features.identical(c.values, o.values) for o in others):
144
+ continue
145
+ truth = vocab.canonical(c.name)
146
+ a = answer(c.values, c.dtype, others, fd.technique, V, exclude_group=corpus.source_group(p.name))
147
+ ok = a["verdict"] == "NAME" and a["name"] == truth
148
+ by_name[truth].append(ok)
149
+ if not ok:
150
+ confusions[(truth, a["name"] if a["verdict"] == "NAME" else a["verdict"])] += 1
151
+ macro = np.mean([np.mean(v) for v in by_name.values()])
152
+ micro = np.mean([x for v in by_name.values() for x in v])
153
+ print(f"pool: {sum(len(v) for v in by_name.values())} columns, {len(by_name)} names, macro {macro:.3f}, micro {micro:.3f}")
154
+ for n, v in sorted(by_name.items(), key=lambda kv: np.mean(kv[1])):
155
+ if np.mean(v) < 1.0 or verbose > 1:
156
+ print(f" {np.mean(v):.2f} ({sum(v)}/{len(v)}) {n}")
157
+ if verbose:
158
+ print("top confusions:")
159
+ for (t, a), k in confusions.most_common(25):
160
+ print(f" {k:3d} {t} -> {a}")
161
+
162
+
163
+ def bench(split: str, abstain_below: float) -> None:
164
+ RUNS.mkdir(exist_ok=True)
165
+ out = []
166
+ for t in tasks.load(split):
167
+ x, dtype, kv, kc = tasks.hidden_and_known(t)
168
+ a = answer(x, dtype, list(kc.values()), t["technique"], tasks.task_vocabulary(t), abstain_below,
169
+ exclude_group=corpus.source_group(t["file"]))
170
+ out.append({"task_id": t["task_id"], "split": split, "status": "ok", "arm": "rules", **a})
171
+ path = RUNS / f"rules_{split}.jsonl"
172
+ path.write_text("\n".join(json.dumps(r, ensure_ascii=False, default=str) for r in out) + "\n",
173
+ encoding="utf-8", newline="\n")
174
+ print(f"{len(out)} answers -> {path}")
175
+
176
+
177
+ def main() -> int:
178
+ import warnings
179
+
180
+ warnings.filterwarnings("ignore")
181
+ sys.stdout.reconfigure(encoding="utf-8")
182
+ ap = argparse.ArgumentParser()
183
+ ap.add_argument("--pool", action="store_true")
184
+ ap.add_argument("--split", choices=["dev", "test", "holdout", "holdout2", "holdout3", "holdout4", "holdout5"])
185
+ ap.add_argument("--abstain-below", type=float, default=0.6)
186
+ ap.add_argument("-v", "--verbose", action="count", default=0)
187
+ a = ap.parse_args()
188
+ if a.pool:
189
+ pool(a.verbose)
190
+ if a.split:
191
+ bench(a.split, a.abstain_below)
192
+ return 0
193
+
194
+
195
+ if __name__ == "__main__":
196
+ raise SystemExit(main())
@@ -0,0 +1,130 @@
1
+ """Statistics fallback for columns no formula can identify (e.g. Ewe in a file without power or current).
2
+
3
+ A k-nearest-neighbour vote over scale-free column features, learned from the
4
+ public EC-Lab corpus, plus a technique prior used as a tie-breaker. It can only
5
+ name quantities it has seen, so its answers carry lower confidence than a
6
+ formula match. A column that looks like noise (no autocorrelation) is never
7
+ named by the fallback.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from collections import Counter, defaultdict
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+
18
+ from spike2 import corpus, features, tasks, vocab
19
+
20
+
21
+ def lag1_autocorr(x) -> float:
22
+ y = np.asarray(x, dtype="f8")
23
+ y = y[np.isfinite(y)]
24
+ if y.size < 4 or np.ptp(y) == 0:
25
+ return 0.0
26
+ a, b = y[:-1] - y[:-1].mean(), y[1:] - y[1:].mean()
27
+ den = np.sqrt((a * a).sum() * (b * b).sum())
28
+ return float((a * b).sum() / den) if den else 0.0
29
+
30
+
31
+ DATA = Path(__file__).resolve().parent / "data"
32
+
33
+
34
+ class Stats:
35
+ """Column statistics of the public corpus. Built from the downloaded files once, then read from
36
+ namer/data/stats.* so the command-line tool works without the corpus on disk."""
37
+
38
+ def __init__(self, use_cache: bool = True):
39
+ meta, feats = DATA / "stats.json", DATA / "stats_features.npy"
40
+ if use_cache and meta.exists() and feats.exists():
41
+ m = json.loads(meta.read_text(encoding="utf-8"))
42
+ X = np.load(feats)
43
+ self.rows = [(g, t, n, X[i]) for i, (g, t, n) in enumerate(m["rows"])]
44
+ techs = defaultdict(dict)
45
+ for tech, g, f, names in m["files"]:
46
+ techs[tech][(g, f)] = set(names)
47
+ self._finish(techs)
48
+ return
49
+ self._build()
50
+ if use_cache:
51
+ self.save()
52
+
53
+ def save(self):
54
+ DATA.mkdir(exist_ok=True)
55
+ rows = [[g, t, n] for g, t, n, _ in self.rows]
56
+ files = [[tech, g, f, sorted(names)] for tech, d in self.techs.items() for (g, f), names in d.items()]
57
+ (DATA / "stats.json").write_text(json.dumps({"rows": rows, "files": files}, ensure_ascii=False),
58
+ encoding="utf-8", newline="\n")
59
+ np.save(DATA / "stats_features.npy", np.stack([r[3] for r in self.rows]))
60
+
61
+ def _finish(self, techs):
62
+ X = np.stack([r[3] for r in self.rows])
63
+ self.mu, self.sd = X.mean(0), X.std(0) + 1e-9
64
+ self.Z = (X - self.mu) / self.sd
65
+ self.techs = techs
66
+
67
+ def _build(self):
68
+ rows, techs = [], defaultdict(Counter)
69
+ for p in corpus.corpus_files():
70
+ try:
71
+ fd = tasks.file_data(p.name)
72
+ except Exception:
73
+ continue
74
+ if fd.n_points < tasks.MIN_ROWS:
75
+ continue
76
+ group = corpus.source_group(p.name)
77
+ cols = tasks.usable_columns(fd)
78
+ tcol = next((c for c in cols if c.name == "time"), None)
79
+ names = {vocab.canonical(c.name) for c in cols}
80
+ techs[fd.technique][(group, p.name)] = names # type: ignore[index]
81
+ for c in cols:
82
+ if features.is_constant(c.values):
83
+ continue
84
+ f = features.knn_features(c.values, c.dtype, None if tcol is None or tcol is c else tcol.values,
85
+ [o.values for o in cols if o is not c])
86
+ rows.append((group, fd.technique, vocab.canonical(c.name), f))
87
+ self.rows = rows
88
+ self._finish(techs)
89
+
90
+ def prior(self, technique, exclude_group: str | None, all_techniques: bool = False) -> Counter:
91
+ cnt = Counter()
92
+ groups = self.techs.values() if all_techniques else [self.techs.get(technique, {})]
93
+ for files in groups:
94
+ for (g, _f), names in files.items():
95
+ if g != exclude_group:
96
+ cnt.update(names)
97
+ return cnt
98
+
99
+ def expected_missing(self, present: set[str], exclude_group: str | None = None, k: int = 5,
100
+ share: float = 0.8) -> list[str]:
101
+ """Names recorded by at least `share` of the k public files whose column sets are most like this one,
102
+ but missing here."""
103
+ files = [names for t in self.techs.values() for (g, _f), names in t.items() if g != exclude_group]
104
+ if not files or not present:
105
+ return []
106
+ sim = sorted(files, key=lambda n: -len(n & present) / len(n | present))[:k]
107
+ counts = Counter(x for n in sim for x in n)
108
+ return sorted(n for n, c in counts.items() if c >= share * len(sim) and n not in present)
109
+
110
+ def cooccur(self, cand: str, sib: str, exclude_group: str | None = None) -> float:
111
+ """Share of public files containing sib that also contain cand."""
112
+ with_sib = [names for files in self.techs.values() for (g, _f), names in files.items()
113
+ if g != exclude_group and sib in names]
114
+ return sum(cand in n for n in with_sib) / len(with_sib) if with_sib else 0.0
115
+
116
+ def predict(self, target, dtype, known_cols, technique, allowed: set[str], present: set[str],
117
+ exclude_group: str | None = None, k: int = 5):
118
+ tcol = next((c.values for c in known_cols if c.name == "time"), None)
119
+ f = (features.knn_features(target, dtype, tcol, [c.values for c in known_cols]) - self.mu) / self.sd
120
+ idx = [i for i, r in enumerate(self.rows) if r[0] != exclude_group and r[2] in allowed and r[2] not in present]
121
+ if not idx:
122
+ return None, 0.0, []
123
+ d = np.linalg.norm(self.Z[idx] - f, axis=1)
124
+ votes = defaultdict(float)
125
+ for j in np.argsort(d)[:k]:
126
+ votes[self.rows[idx[j]][2]] += 1.0 / (d[j] + 1e-6)
127
+ pr = self.prior(technique, exclude_group)
128
+ total = sum(votes.values())
129
+ ranked = sorted(votes, key=lambda n: (-votes[n], -pr.get(n, 0), n))
130
+ return ranked[0], votes[ranked[0]] / total, ranked[1:3]
@@ -0,0 +1,74 @@
1
+ """Regression safety net: snapshot every answer the namer gives, and compare later.
2
+
3
+ python -m namer.golden save # writes tests/golden/answers.jsonl (pool + every split)
4
+ python -m namer.golden check # recomputes and lists every difference (exit 1 if any)
5
+
6
+ A refactoring (e.g. replacing where the vocabulary or storage types come from) must
7
+ leave every answer identical: verdict, name, confidence and alternatives.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sys
14
+ import warnings
15
+ from pathlib import Path
16
+
17
+ from spike2 import corpus, features, tasks, vocab
18
+
19
+ from .evaluate import answer, test_keys
20
+
21
+ ROOT = Path(__file__).resolve().parents[1]
22
+ GOLDEN = ROOT / "tests" / "golden" / "answers.jsonl"
23
+ SPLITS = ["dev", "test", "holdout", "holdout2", "holdout3", "holdout4", "holdout5"]
24
+ KEYS = ("verdict", "name", "confidence", "alternatives", "duplicate_of")
25
+
26
+
27
+ def compute() -> list[dict]:
28
+ warnings.filterwarnings("ignore")
29
+ out = []
30
+ excluded = test_keys()
31
+ V = vocab.names()
32
+ for p in corpus.corpus_files():
33
+ try:
34
+ fd = tasks.file_data(p.name)
35
+ except Exception:
36
+ continue
37
+ if fd.n_points < tasks.MIN_ROWS:
38
+ continue
39
+ cols = tasks.usable_columns(fd)
40
+ for c in cols:
41
+ if (p.name, c.position) in excluded or features.is_constant(c.values):
42
+ continue
43
+ others = [o for o in cols if o is not c]
44
+ a = answer(c.values, c.dtype, others, fd.technique, V, exclude_group=corpus.source_group(p.name))
45
+ out.append({"id": f"pool:{p.name}:{c.position}", **{k: a.get(k) for k in KEYS}})
46
+ for split in SPLITS:
47
+ for t in tasks.load(split):
48
+ x, dtype, kv, kc = tasks.hidden_and_known(t)
49
+ a = answer(x, dtype, list(kc.values()), t["technique"], tasks.task_vocabulary(t),
50
+ exclude_group=corpus.source_group(t["file"]))
51
+ out.append({"id": f"{split}:{t['task_id']}", **{k: a.get(k) for k in KEYS}})
52
+ return out
53
+
54
+
55
+ def main() -> int:
56
+ sys.stdout.reconfigure(encoding="utf-8")
57
+ cmd = sys.argv[1] if len(sys.argv) > 1 else "check"
58
+ rows = compute()
59
+ if cmd == "save":
60
+ GOLDEN.parent.mkdir(parents=True, exist_ok=True)
61
+ GOLDEN.write_text("\n".join(json.dumps(r, ensure_ascii=False) for r in rows) + "\n", encoding="utf-8", newline="\n")
62
+ print(f"saved {len(rows)} answers -> {GOLDEN}")
63
+ return 0
64
+ old = {r["id"]: r for r in (json.loads(x) for x in GOLDEN.read_text(encoding="utf-8").splitlines() if x.strip())}
65
+ new = {r["id"]: r for r in rows}
66
+ diffs = [(k, old.get(k), new.get(k)) for k in sorted(set(old) | set(new)) if old.get(k) != new.get(k)]
67
+ for k, a, b in diffs[:50]:
68
+ print(f"DIFF {k}\n before: {a}\n after: {b}")
69
+ print(f"{len(new)} answers compared, {len(diffs)} differences")
70
+ return 1 if diffs else 0
71
+
72
+
73
+ if __name__ == "__main__":
74
+ raise SystemExit(main())