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.
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())