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.
- rosetta_eclab/__init__.py +0 -0
- rosetta_eclab/cli.py +113 -0
- rosetta_eclab/data/stats.json +1 -0
- rosetta_eclab/data/stats_features.npy +0 -0
- rosetta_eclab/debug.py +85 -0
- rosetta_eclab/engine.py +203 -0
- rosetta_eclab/evaluate.py +196 -0
- rosetta_eclab/fallback.py +130 -0
- rosetta_eclab/golden.py +74 -0
- rosetta_eclab/library.py +708 -0
- rosetta_eclab/misses.py +39 -0
- rosetta_eclab/mprfile.py +178 -0
- rosetta_eclab-0.1.1.dist-info/METADATA +136 -0
- rosetta_eclab-0.1.1.dist-info/RECORD +31 -0
- rosetta_eclab-0.1.1.dist-info/WHEEL +5 -0
- rosetta_eclab-0.1.1.dist-info/entry_points.txt +2 -0
- rosetta_eclab-0.1.1.dist-info/licenses/LICENSE +674 -0
- rosetta_eclab-0.1.1.dist-info/top_level.txt +2 -0
- spike2/__init__.py +0 -0
- spike2/agent.py +372 -0
- spike2/api_check.py +117 -0
- spike2/baselines.py +153 -0
- spike2/corpus.py +162 -0
- spike2/dsl.py +167 -0
- spike2/elim.py +52 -0
- spike2/features.py +137 -0
- spike2/lock.py +59 -0
- spike2/run.py +218 -0
- spike2/score.py +223 -0
- spike2/tasks.py +355 -0
- spike2/vocab.py +126 -0
spike2/corpus.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Reference decoding of every corpus .mpr into named, typed columns.
|
|
2
|
+
|
|
3
|
+
The v2 benchmark isolates the semantic step: which physical quantity is this
|
|
4
|
+
column? Byte structure is code's job (shown 6/6 by the v1 code baseline), so
|
|
5
|
+
here every file is decoded with yadg 7.0.1's own column map and offsets, and
|
|
6
|
+
the map's names and units are the answer key. Flags are dropped; they are bit
|
|
7
|
+
fields, not quantities.
|
|
8
|
+
|
|
9
|
+
Usage: python -m spike2.corpus # prints a summary per file
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import warnings
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
23
|
+
EXT = ROOT / "data" / "external"
|
|
24
|
+
|
|
25
|
+
# Real format drifts (galvani 0.5.0 cannot read them): kept out of the dev split.
|
|
26
|
+
DRIFT_FILES = {"gcpl.issue_230.mpr", "peis.issue_225.with_ece.mpr"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Column:
|
|
31
|
+
position: int # index in the file's record layout (flags excluded)
|
|
32
|
+
raw_id: int # full column ID (little-endian u2; for 11.50+ files at module offset 6, see docs/history.md)
|
|
33
|
+
ref_id: int # ID yadg uses for its map lookup (its own reading % 256); names come from this
|
|
34
|
+
name: str
|
|
35
|
+
unit: str | None
|
|
36
|
+
dtype: str
|
|
37
|
+
values: np.ndarray = field(repr=False)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class FileData:
|
|
42
|
+
file: str
|
|
43
|
+
technique: str | None
|
|
44
|
+
version: int
|
|
45
|
+
n_points: int
|
|
46
|
+
columns: list[Column]
|
|
47
|
+
galvani_unknown_ids: list[int]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _modules(contents: bytes):
|
|
51
|
+
"""Yield (name, version, module_data) like yadg's process_modules, without converting values."""
|
|
52
|
+
from yadg import dgutils
|
|
53
|
+
from yadg.extractors.eclab.mpr_columns import module_header_dtypes
|
|
54
|
+
|
|
55
|
+
for module in contents.split(b"MODULE")[1:]:
|
|
56
|
+
for mhd in module_header_dtypes:
|
|
57
|
+
try:
|
|
58
|
+
header = dgutils.read_value(module, 0x0000, mhd)
|
|
59
|
+
if len(module) == mhd.itemsize + header["length"]:
|
|
60
|
+
version = header.get("newver", 0) + header["oldver"]
|
|
61
|
+
break
|
|
62
|
+
except UnicodeDecodeError:
|
|
63
|
+
continue
|
|
64
|
+
else:
|
|
65
|
+
raise RuntimeError("unknown module header")
|
|
66
|
+
yield header["short_name"].strip(), version, module[mhd.itemsize:]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def decode(path: Path) -> FileData:
|
|
70
|
+
from yadg.extractors.eclab.mpr import parse_columns, process_settings
|
|
71
|
+
|
|
72
|
+
logging.getLogger("yadg").setLevel(logging.CRITICAL)
|
|
73
|
+
raw = path.read_bytes()
|
|
74
|
+
technique, data, data_version = None, None, None
|
|
75
|
+
for name, version, mdata in _modules(raw):
|
|
76
|
+
if name == "VMP Set":
|
|
77
|
+
try:
|
|
78
|
+
technique, _, _ = process_settings(mdata, "11.50" if version >= 10 else "10.40")
|
|
79
|
+
except Exception: # old galvani regression files: settings layout yadg does not read
|
|
80
|
+
technique = None
|
|
81
|
+
elif name == "VMP data":
|
|
82
|
+
data, data_version = mdata, version
|
|
83
|
+
if data is None:
|
|
84
|
+
raise ValueError("no data module")
|
|
85
|
+
n_points = int(np.frombuffer(data, offset=0, dtype="<u4", count=1)[0])
|
|
86
|
+
n_columns = int(np.frombuffer(data, offset=4, dtype="|u1", count=1)[0])
|
|
87
|
+
if data_version in (10, 11):
|
|
88
|
+
ids = np.frombuffer(data, offset=5, dtype=">u2", count=n_columns).tolist()
|
|
89
|
+
full_ids = np.frombuffer(data, offset=6, dtype="<u2", count=n_columns).tolist()
|
|
90
|
+
offset = 0x3EF
|
|
91
|
+
elif data_version in (2, 3):
|
|
92
|
+
ids = np.frombuffer(data, offset=5, dtype="<u2", count=n_columns).tolist()
|
|
93
|
+
full_ids = list(ids)
|
|
94
|
+
offset = 0x195 if data_version == 2 else 0x196
|
|
95
|
+
else:
|
|
96
|
+
raise NotImplementedError(f"data module version {data_version}")
|
|
97
|
+
names, dtypes, units, _flags = parse_columns(ids, technique)
|
|
98
|
+
arr = np.frombuffer(data, offset=offset, dtype=np.dtype(list(zip(names, dtypes))), count=n_points)
|
|
99
|
+
from galvani import BioLogic
|
|
100
|
+
|
|
101
|
+
km = {int(k): v for k, v in BioLogic.VMPdata_colID_dtype_map.items()} # galvani 0.5.0: full IDs it knows
|
|
102
|
+
cols, pos = [], 0
|
|
103
|
+
# yadg's own flag test (on its reading of the IDs) decides which entries are flags
|
|
104
|
+
data_ids = [(i, f) for i, f in zip(ids, full_ids) if i not in (1, 2, 3, 21, 31, 65)]
|
|
105
|
+
galvani_unknown = []
|
|
106
|
+
k = 0
|
|
107
|
+
for name, dt, unit in zip(names, dtypes, units):
|
|
108
|
+
if name == "flags":
|
|
109
|
+
continue
|
|
110
|
+
yid, cid = data_ids[k]
|
|
111
|
+
k += 1
|
|
112
|
+
if name.startswith("unknown_"):
|
|
113
|
+
continue
|
|
114
|
+
ref = yid % 256
|
|
115
|
+
if cid not in km: # galvani 0.5.0 looks up the full ID
|
|
116
|
+
galvani_unknown.append(cid)
|
|
117
|
+
cols.append(Column(position=pos, raw_id=cid, ref_id=ref, name=name, unit=unit, dtype=dt,
|
|
118
|
+
values=np.asarray(arr[name])))
|
|
119
|
+
pos += 1
|
|
120
|
+
return FileData(file=path.name, technique=technique, version=data_version, n_points=n_points,
|
|
121
|
+
columns=cols, galvani_unknown_ids=galvani_unknown)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# Files that come from the same experiment or contributor (same GitHub issue/PR, the VSP set,
|
|
125
|
+
# or sharing an identical non-constant column; checked 2026-10-14). Used for leave-one-group-out.
|
|
126
|
+
_GROUP_TAGS = ("issue_149", "issue_185", "issue_225", "issue_226", "pr_182", "vsp_", "bio_logic")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def source_group(name: str) -> str:
|
|
130
|
+
for tag in _GROUP_TAGS:
|
|
131
|
+
if tag in name:
|
|
132
|
+
return tag
|
|
133
|
+
return name
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def corpus_files() -> list[Path]:
|
|
137
|
+
return sorted(EXT.glob("*.mpr"))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def load_all() -> tuple[list[FileData], dict[str, str]]:
|
|
141
|
+
out, failed = [], {}
|
|
142
|
+
warnings.filterwarnings("ignore")
|
|
143
|
+
for p in corpus_files():
|
|
144
|
+
try:
|
|
145
|
+
out.append(decode(p))
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
failed[p.name] = f"{type(exc).__name__}: {exc}"
|
|
148
|
+
return out, failed
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def main() -> int:
|
|
152
|
+
files, failed = load_all()
|
|
153
|
+
for f in files:
|
|
154
|
+
print(f"{f.file:30s} {str(f.technique):8s} v{f.version:<3d} n={f.n_points:<6d} cols={len(f.columns)} "
|
|
155
|
+
f"galvani_unknown={f.galvani_unknown_ids}")
|
|
156
|
+
print(" " + "; ".join(f"{c.name} [{c.unit}] {c.dtype}" for c in f.columns))
|
|
157
|
+
print("\nfailed:", json.dumps(failed, indent=2))
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
raise SystemExit(main())
|
spike2/dsl.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Safe expression language for hypotheses about a column.
|
|
2
|
+
|
|
3
|
+
The model writes an expression over the file's known columns (aliases c0, c1,
|
|
4
|
+
...) and code evaluates it with numpy: nothing is exec'd, only a whitelisted
|
|
5
|
+
AST (numbers, column aliases, + - * / **, unary minus and the functions
|
|
6
|
+
below). Code then fits target ~ a * expr + b and reports how much of the
|
|
7
|
+
column the hypothesis explains, so the model never has to compute a number.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
MAX_LEN = 300
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _diff0(x):
|
|
20
|
+
"""First difference with a leading 0, same length as x."""
|
|
21
|
+
return np.concatenate([[0.0], np.diff(x)])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _cumsum_by(x, key):
|
|
25
|
+
"""Cumulative sum that restarts whenever key changes value."""
|
|
26
|
+
x = np.nan_to_num(np.asarray(x, dtype="f8"))
|
|
27
|
+
key = np.asarray(key)
|
|
28
|
+
out = np.empty_like(x)
|
|
29
|
+
acc = 0.0
|
|
30
|
+
for i in range(len(x)):
|
|
31
|
+
if i and key[i] != key[i - 1]:
|
|
32
|
+
acc = 0.0
|
|
33
|
+
acc += x[i]
|
|
34
|
+
out[i] = acc
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _since_change(t, key):
|
|
39
|
+
"""t minus the value of t at the most recent change of key (e.g. time since step start)."""
|
|
40
|
+
t = np.asarray(t, dtype="f8")
|
|
41
|
+
key = np.asarray(key)
|
|
42
|
+
out = np.empty_like(t)
|
|
43
|
+
start = t[0] if len(t) else 0.0
|
|
44
|
+
for i in range(len(t)):
|
|
45
|
+
if i and key[i] != key[i - 1]:
|
|
46
|
+
start = t[i]
|
|
47
|
+
out[i] = t[i] - start
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _shift(x, k=1):
|
|
52
|
+
k = int(k)
|
|
53
|
+
x = np.asarray(x, dtype="f8")
|
|
54
|
+
if k == 0:
|
|
55
|
+
return x.copy()
|
|
56
|
+
out = np.full_like(x, np.nan)
|
|
57
|
+
if k > 0:
|
|
58
|
+
out[k:] = x[:-k]
|
|
59
|
+
else:
|
|
60
|
+
out[:k] = x[-k:]
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
FUNCS = {
|
|
65
|
+
"abs": np.abs, "sqrt": np.sqrt, "log": np.log, "log10": np.log10, "exp": np.exp,
|
|
66
|
+
"sin": np.sin, "cos": np.cos, "tan": np.tan, "arctan": np.arctan, "arctan2": np.arctan2,
|
|
67
|
+
"degrees": np.degrees, "radians": np.radians, "sign": np.sign, "square": np.square,
|
|
68
|
+
"cumsum": lambda x: np.cumsum(np.nan_to_num(np.asarray(x, dtype="f8"))),
|
|
69
|
+
"diff0": _diff0, "cumsum_by": _cumsum_by, "since_change": _since_change, "shift": _shift,
|
|
70
|
+
"where_pos": lambda x, a, b: np.where(np.asarray(x) > 0, a, b),
|
|
71
|
+
"maximum": np.maximum, "minimum": np.minimum,
|
|
72
|
+
}
|
|
73
|
+
CONSTS = {"pi": np.pi}
|
|
74
|
+
|
|
75
|
+
DOC = (
|
|
76
|
+
"Expression language: column aliases (c0, c1, ...), numbers, + - * / **, unary minus, constant pi, "
|
|
77
|
+
"and functions abs sqrt log log10 exp sin cos tan arctan arctan2 degrees radians sign square maximum minimum, "
|
|
78
|
+
"cumsum(x), diff0(x) (first difference, leading 0), cumsum_by(x, key) (cumulative sum restarting when key "
|
|
79
|
+
"changes), since_change(t, key) (t minus t at the last change of key), shift(x, k), where_pos(x, a, b). "
|
|
80
|
+
"Code fits TARGET = a*expr + b, so constant factors and offsets need not be exact."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class DSLError(ValueError):
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def evaluate(expr: str, cols: dict[str, np.ndarray]) -> np.ndarray:
|
|
89
|
+
if not isinstance(expr, str) or not expr.strip():
|
|
90
|
+
raise DSLError("empty expression")
|
|
91
|
+
if len(expr) > MAX_LEN:
|
|
92
|
+
raise DSLError(f"expression longer than {MAX_LEN} characters")
|
|
93
|
+
try:
|
|
94
|
+
tree = ast.parse(expr.strip(), mode="eval")
|
|
95
|
+
except SyntaxError as exc:
|
|
96
|
+
raise DSLError(f"syntax error: {exc.msg}") from None
|
|
97
|
+
|
|
98
|
+
def ev(node):
|
|
99
|
+
if isinstance(node, ast.Expression):
|
|
100
|
+
return ev(node.body)
|
|
101
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
|
|
102
|
+
return float(node.value)
|
|
103
|
+
if isinstance(node, ast.Name):
|
|
104
|
+
if node.id in cols:
|
|
105
|
+
return np.asarray(cols[node.id], dtype="f8")
|
|
106
|
+
if node.id in CONSTS:
|
|
107
|
+
return CONSTS[node.id]
|
|
108
|
+
raise DSLError(f"unknown name {node.id!r}; use column aliases {sorted(cols)[:8]}... or pi")
|
|
109
|
+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
|
|
110
|
+
v = ev(node.operand)
|
|
111
|
+
return -v if isinstance(node.op, ast.USub) else v
|
|
112
|
+
if isinstance(node, ast.BinOp):
|
|
113
|
+
a, b = ev(node.left), ev(node.right)
|
|
114
|
+
ops = {ast.Add: np.add, ast.Sub: np.subtract, ast.Mult: np.multiply, ast.Div: np.divide,
|
|
115
|
+
ast.Pow: np.power}
|
|
116
|
+
for t, f in ops.items():
|
|
117
|
+
if isinstance(node.op, t):
|
|
118
|
+
return f(a, b)
|
|
119
|
+
raise DSLError(f"operator {type(node.op).__name__} not allowed")
|
|
120
|
+
if isinstance(node, ast.Call):
|
|
121
|
+
if not isinstance(node.func, ast.Name) or node.func.id not in FUNCS:
|
|
122
|
+
name = getattr(node.func, "id", "?")
|
|
123
|
+
raise DSLError(f"function {name!r} not allowed; allowed: {sorted(FUNCS)}")
|
|
124
|
+
if node.keywords:
|
|
125
|
+
raise DSLError("keyword arguments are not allowed")
|
|
126
|
+
return FUNCS[node.func.id](*[ev(a) for a in node.args])
|
|
127
|
+
raise DSLError(f"{type(node).__name__} not allowed")
|
|
128
|
+
|
|
129
|
+
with np.errstate(all="ignore"):
|
|
130
|
+
try:
|
|
131
|
+
out = ev(tree)
|
|
132
|
+
except DSLError:
|
|
133
|
+
raise
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
raise DSLError(f"{type(exc).__name__}: {exc}") from None
|
|
136
|
+
n = len(next(iter(cols.values())))
|
|
137
|
+
out = np.broadcast_to(np.asarray(out, dtype="f8"), (n,)).copy()
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def fit(target: np.ndarray, pred: np.ndarray) -> dict:
|
|
142
|
+
"""Least-squares target ~ a*pred + b over rows where both are finite."""
|
|
143
|
+
y = np.asarray(target, dtype="f8")
|
|
144
|
+
x = np.asarray(pred, dtype="f8")
|
|
145
|
+
m = np.isfinite(y) & np.isfinite(x)
|
|
146
|
+
n = int(m.sum())
|
|
147
|
+
if n < 3:
|
|
148
|
+
return {"rows_used": n, "error": "fewer than 3 finite rows"}
|
|
149
|
+
y, x = y[m], x[m]
|
|
150
|
+
if np.ptp(y) == 0:
|
|
151
|
+
return {"rows_used": n, "error": "TARGET is constant, so no hypothesis can be tested against it"}
|
|
152
|
+
if np.ptp(x) == 0:
|
|
153
|
+
return {"rows_used": n, "r2": 0.0, "error": "the expression is constant over these rows, so it explains nothing"}
|
|
154
|
+
rng = float(np.ptp(y))
|
|
155
|
+
a, b = np.polyfit(x, y, 1)
|
|
156
|
+
res = y - (a * x + b)
|
|
157
|
+
ss_tot = float(((y - y.mean()) ** 2).sum())
|
|
158
|
+
r2 = 1.0 - float((res ** 2).sum()) / ss_tot
|
|
159
|
+
return {
|
|
160
|
+
"rows_used": n,
|
|
161
|
+
"rows_total": int(len(target)),
|
|
162
|
+
"a": float(f"{a:.6g}"),
|
|
163
|
+
"b": float(f"{b:.6g}"),
|
|
164
|
+
"r2": round(r2, 5),
|
|
165
|
+
"max_abs_residual_over_range": round(float(np.abs(res).max()) / rng, 5),
|
|
166
|
+
"fraction_rows_within_1pct_of_range": round(float((np.abs(res) <= 0.01 * rng).mean()), 4),
|
|
167
|
+
}
|
spike2/elim.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Name elimination: the code-only trick that solves a column from its siblings' names.
|
|
2
|
+
|
|
3
|
+
If the known columns include |Ewe h2|, |Ewe h3|, |Ewe h5|, ... then the
|
|
4
|
+
vocabulary name one token away from at least two of them (|Ewe h4|) is the
|
|
5
|
+
gap. That needs no data and no model, so tasks it settles are not evidence
|
|
6
|
+
for the model (found by the harness review, 2026-10-14). The rule:
|
|
7
|
+
|
|
8
|
+
candidates = vocabulary names not already present among the known columns
|
|
9
|
+
score(v) = number of known column names exactly one token away from v
|
|
10
|
+
answer = the unique candidate with the highest score, if that score >= 2
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
from . import vocab
|
|
18
|
+
|
|
19
|
+
_TOKEN = re.compile(r"[A-Za-z]+|\d+|[^A-Za-z\d\s]")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def tokens(name: str) -> list[str]:
|
|
23
|
+
return _TOKEN.findall(name)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def one_token_apart(a: str, b: str) -> bool:
|
|
27
|
+
"""Siblings: same token count, exactly one token differs, and they share at least one word
|
|
28
|
+
(so "Ns"/"dQ" or "|C|"/"|I|", which share nothing or only punctuation, are not siblings)."""
|
|
29
|
+
ta, tb = tokens(a), tokens(b)
|
|
30
|
+
if len(ta) != len(tb) or len(ta) < 2:
|
|
31
|
+
return False
|
|
32
|
+
diff = sum(x != y for x, y in zip(ta, tb))
|
|
33
|
+
shared_word = any(x == y and x.isalpha() for x, y in zip(ta, tb))
|
|
34
|
+
return diff == 1 and shared_word
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def eliminate(known_names: list[str], vocabulary: list[str]) -> str | None:
|
|
38
|
+
present = {vocab.canonical(n) for n in known_names}
|
|
39
|
+
spellings = set(known_names) | present
|
|
40
|
+
best, best_score, tie = None, 0, False
|
|
41
|
+
for v in vocabulary:
|
|
42
|
+
if v in present:
|
|
43
|
+
continue
|
|
44
|
+
forms = [v] + [a for a, c in vocab.ALIAS.items() if c == v and a != v]
|
|
45
|
+
score = max(sum(one_token_apart(f, k) for k in spellings) for f in forms)
|
|
46
|
+
if score > best_score:
|
|
47
|
+
best, best_score, tie = v, score, False
|
|
48
|
+
elif score == best_score and score > 0:
|
|
49
|
+
tie = True
|
|
50
|
+
if best_score >= 2 and not tie:
|
|
51
|
+
return best
|
|
52
|
+
return None
|
spike2/features.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""What code knows about one column: statistics, generic relations, kNN features.
|
|
2
|
+
|
|
3
|
+
Everything the model is shown is computed here. The relation search is
|
|
4
|
+
deliberately generic (each known column alone, linearly and as a time
|
|
5
|
+
integral); products, ratios, resets and domain formulas are left to the
|
|
6
|
+
model's hypotheses (dsl.py).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from . import dsl
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _f8(v) -> np.ndarray:
|
|
17
|
+
return np.asarray(v, dtype="f8")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_constant(v) -> bool:
|
|
21
|
+
x = _f8(v)
|
|
22
|
+
x = x[np.isfinite(x)]
|
|
23
|
+
return x.size == 0 or float(np.max(x)) == float(np.min(x))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def identical(a, b) -> bool:
|
|
27
|
+
a, b = _f8(a), _f8(b)
|
|
28
|
+
return a.shape == b.shape and np.allclose(a, b, rtol=1e-5, atol=1e-9, equal_nan=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def stats(v, dtype: str) -> dict:
|
|
32
|
+
x = _f8(v)
|
|
33
|
+
fin = np.isfinite(x)
|
|
34
|
+
y = x[fin]
|
|
35
|
+
n = len(x)
|
|
36
|
+
if y.size == 0:
|
|
37
|
+
return {"rows": n, "finite_fraction": 0.0}
|
|
38
|
+
d = np.diff(y)
|
|
39
|
+
rng = float(np.ptp(y))
|
|
40
|
+
drops = int((d < -0.05 * rng).sum()) if rng > 0 else 0
|
|
41
|
+
rises = int((d > 0.05 * rng).sum()) if rng > 0 else 0
|
|
42
|
+
s = np.sign(y)
|
|
43
|
+
out = {
|
|
44
|
+
"rows": n,
|
|
45
|
+
"dtype": dtype,
|
|
46
|
+
"finite_fraction": round(float(fin.mean()), 4),
|
|
47
|
+
"min": float(f"{y.min():.6g}"),
|
|
48
|
+
"max": float(f"{y.max():.6g}"),
|
|
49
|
+
"mean": float(f"{y.mean():.6g}"),
|
|
50
|
+
"std": float(f"{y.std():.6g}"),
|
|
51
|
+
"distinct_values": int(np.unique(y).size),
|
|
52
|
+
"integer_valued_fraction": round(float(np.mean(np.isclose(y, np.round(y)))), 4),
|
|
53
|
+
"fraction_positive": round(float((y > 0).mean()), 4),
|
|
54
|
+
"fraction_negative": round(float((y < 0).mean()), 4),
|
|
55
|
+
"fraction_zero": round(float((y == 0).mean()), 4),
|
|
56
|
+
"non_decreasing_step_fraction": round(float((d >= 0).mean()), 4) if d.size else None,
|
|
57
|
+
"non_increasing_step_fraction": round(float((d <= 0).mean()), 4) if d.size else None,
|
|
58
|
+
"large_drops (>5% of range)": drops,
|
|
59
|
+
"large_rises (>5% of range)": rises,
|
|
60
|
+
"sign_changes": int((s[1:] * s[:-1] < 0).sum()),
|
|
61
|
+
}
|
|
62
|
+
pos = y[y > 0]
|
|
63
|
+
if pos.size > 3 and (pos.size == y.size):
|
|
64
|
+
r = pos[1:] / pos[:-1]
|
|
65
|
+
med = float(np.median(r))
|
|
66
|
+
if med != 1.0:
|
|
67
|
+
out["geometric_spacing_fraction"] = round(float(np.mean(np.abs(r / med - 1) < 0.05)), 4)
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def relations(x, known: dict[str, np.ndarray], time_alias: str | None, top: int = 6) -> list[dict]:
|
|
72
|
+
"""Generic search: x ~ a*c + b and x ~ a*cumsum(c*dt) + b for every known column c."""
|
|
73
|
+
out = []
|
|
74
|
+
dt = dsl._diff0(_f8(known[time_alias])) if time_alias else None
|
|
75
|
+
for alias, c in known.items():
|
|
76
|
+
c = _f8(c)
|
|
77
|
+
if is_constant(c):
|
|
78
|
+
continue
|
|
79
|
+
f = dsl.fit(x, c)
|
|
80
|
+
if "r2" in f:
|
|
81
|
+
out.append({"hypothesis": f"{alias}", **f})
|
|
82
|
+
if dt is not None and alias != time_alias:
|
|
83
|
+
g = dsl.fit(x, np.cumsum(np.nan_to_num(c * dt)))
|
|
84
|
+
if "r2" in g:
|
|
85
|
+
out.append({"hypothesis": f"cumsum({alias}*diff0({time_alias}))", **g})
|
|
86
|
+
out.sort(key=lambda r: -r["r2"])
|
|
87
|
+
return [r for r in out[:top] if r["r2"] >= 0.5]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --- kNN features (scale-free) -------------------------------------------------
|
|
91
|
+
|
|
92
|
+
DTYPES = ["<f8", "<f4", "<u2", "<u4"]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def knn_features(v, dtype: str, time_values=None, known_values: list | None = None) -> np.ndarray:
|
|
96
|
+
x = _f8(v)
|
|
97
|
+
y = x[np.isfinite(x)]
|
|
98
|
+
if y.size < 2:
|
|
99
|
+
y = np.zeros(2)
|
|
100
|
+
d = np.diff(y)
|
|
101
|
+
rng = float(np.ptp(y))
|
|
102
|
+
amax = float(np.abs(y).max())
|
|
103
|
+
feats = [1.0 if dtype == t else 0.0 for t in DTYPES]
|
|
104
|
+
feats += [
|
|
105
|
+
float(np.mean(np.isclose(y, np.round(y)))),
|
|
106
|
+
float((y > 0).mean()), float((y < 0).mean()), float((y == 0).mean()),
|
|
107
|
+
float((d >= 0).mean()), float((d <= 0).mean()),
|
|
108
|
+
float(np.unique(y).size / y.size),
|
|
109
|
+
float(np.log10(amax + 1e-12)),
|
|
110
|
+
float(np.log10(rng + 1e-12)),
|
|
111
|
+
float((d < -0.05 * rng).sum() / y.size) if rng > 0 else 0.0,
|
|
112
|
+
]
|
|
113
|
+
corr_t = 0.0
|
|
114
|
+
if time_values is not None and not is_constant(time_values) and rng > 0:
|
|
115
|
+
t = _f8(time_values)
|
|
116
|
+
m = np.isfinite(t) & np.isfinite(x)
|
|
117
|
+
if m.sum() > 2:
|
|
118
|
+
corr_t = float(np.corrcoef(t[m], x[m])[0, 1])
|
|
119
|
+
feats.append(corr_t if np.isfinite(corr_t) else 0.0)
|
|
120
|
+
best = 0.0
|
|
121
|
+
for k in known_values or []:
|
|
122
|
+
k = _f8(k)
|
|
123
|
+
if is_constant(k) or rng == 0:
|
|
124
|
+
continue
|
|
125
|
+
m = np.isfinite(k) & np.isfinite(x)
|
|
126
|
+
if m.sum() > 2:
|
|
127
|
+
r = np.corrcoef(k[m], x[m])[0, 1]
|
|
128
|
+
if np.isfinite(r):
|
|
129
|
+
best = max(best, abs(float(r)))
|
|
130
|
+
feats.append(best)
|
|
131
|
+
return np.asarray(feats, dtype="f8")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def fmt(v: float) -> str:
|
|
135
|
+
if v is None or not np.isfinite(v):
|
|
136
|
+
return "nan"
|
|
137
|
+
return f"{v:.6g}"
|
spike2/lock.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Freeze the pre-registration: hash everything that decides the v2 spike.
|
|
2
|
+
|
|
3
|
+
Writes prereg2/LOCK.json with SHA-256 of every file in prereg2/ and spike2/,
|
|
4
|
+
the corpus manifest, the task file
|
|
5
|
+
(data/v2/tasks.jsonl, git-ignored, so its hash is the record), the dossier set,
|
|
6
|
+
the vocabulary, package versions and the response mode per arm. Commit
|
|
7
|
+
prereg2/ and spike2/ right after; run.py refuses the test split until the
|
|
8
|
+
hashes match and both folders are committed and clean.
|
|
9
|
+
|
|
10
|
+
Usage: python -m spike2.lock --by "<name>" --response-mode json_schema
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
from importlib.metadata import version
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sha(p: Path) -> str:
|
|
26
|
+
"""SHA-256 with CRLF normalised to LF, so a fresh clone gives the same hash (.gitattributes eol=lf)."""
|
|
27
|
+
return hashlib.sha256(p.read_bytes().replace(b"\r\n", b"\n")).hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main() -> int:
|
|
31
|
+
ap = argparse.ArgumentParser()
|
|
32
|
+
ap.add_argument("--by", required=True)
|
|
33
|
+
ap.add_argument("--response-mode", required=True, choices=["json_schema", "json_object", "none"])
|
|
34
|
+
a = ap.parse_args()
|
|
35
|
+
files = sorted([*(ROOT / "prereg2").glob("*"), *(ROOT / "spike2").glob("*.py"),
|
|
36
|
+
ROOT / "data" / "external" / "manifest.json"])
|
|
37
|
+
files = [f for f in files if f.is_file() and f.name != "LOCK.json"]
|
|
38
|
+
manifest = json.loads((ROOT / "prereg2" / "tasks_manifest.json").read_text(encoding="utf-8"))
|
|
39
|
+
lock = {
|
|
40
|
+
"locked_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
41
|
+
"locked_by": a.by,
|
|
42
|
+
"response_mode": a.response_mode,
|
|
43
|
+
"all_dossiers_sha256": manifest["all_dossiers_sha256"],
|
|
44
|
+
"vocabulary_sha256": manifest["vocabulary_sha256"],
|
|
45
|
+
"tasks_jsonl_sha256": sha(ROOT / "data" / "v2" / "tasks.jsonl"),
|
|
46
|
+
"packages": {p: version(p) for p in ("yadg", "galvani", "numpy", "openai")},
|
|
47
|
+
"files": {str(f.relative_to(ROOT)).replace("\\", "/"): sha(f) for f in files},
|
|
48
|
+
}
|
|
49
|
+
if lock["tasks_jsonl_sha256"] != manifest.get("tasks_jsonl_sha256"):
|
|
50
|
+
raise SystemExit("data/v2/tasks.jsonl does not match prereg2/tasks_manifest.json; rebuild with python -m spike2.tasks")
|
|
51
|
+
lock["combined_sha256"] = hashlib.sha256(json.dumps(lock["files"], sort_keys=True).encode()).hexdigest()
|
|
52
|
+
(ROOT / "prereg2" / "LOCK.json").write_text(json.dumps(lock, indent=1), encoding="utf-8", newline="\n")
|
|
53
|
+
print(f"prereg2/LOCK.json written; combined sha256 {lock['combined_sha256']}")
|
|
54
|
+
print('Now commit: git add prereg2 spike2 && git commit -m "prereg v2: freeze before the first test-split call"')
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
raise SystemExit(main())
|