#!/usr/bin/env python3 """Bounded local LSTM/GRU baseline; CSI prediction is not person identification. Input: prepare.py's numeric NPZ (frames, timestamps, sequence) and hashed JSON metadata. Pretraining predicts the next observed frame, not a fixed-time sample. Validation chooses the checkpoint; test scoring is a separate opt-in after that choice. A single session tests temporal continuation, not generalization to another person, room, device, or session. All datasets, checkpoints and metrics must remain outside Git worktrees. This script performs no capture, network access, package installation or promotion. """ from __future__ import annotations import argparse import hashlib import io import json import math import os from pathlib import Path import random import re import struct import sys import tempfile import time import zipfile import numpy as np MAX_DATA_BYTES = 1024 * 1024 * 1024 MAX_METRICS_BYTES = 8 * 1024 * 1024 MAX_ZIP_DIRECTORY_BYTES = 64 * 1024 MAX_FRAMES = 1_000_000 MAX_FEATURES = 1024 MAX_PARAMETERS = 1_000_000 # Source receipts produced before --encoder existed. These implementations used # one LSTM layer; a missing architecture never inherits the evaluation CLI value. LEGACY_LSTM_TRAINERS = frozenset({ "0b94c5f05b7004fcf54de0888ae1072af6148932e3198bbffa0188e0d16eca0e", "55cc2f93af41b00a7bac20dd13a7635e662ee78b22acca314e78afbd77ad25f5", }) LEGACY_RAW_ORDER_TRAINERS = LEGACY_LSTM_TRAINERS | { "f6a352e9dd91875edfb8ff61f0e323c2cfc4405fdffd02a37b452e3e3923c007", } SPLITS = ("train", "validation", "test") IDENTIFIER = re.compile(r"[A-Za-z0-9_.-]{1,80}\Z") SHA256 = re.compile(r"[a-f0-9]{64}\Z") def require(condition, message): if not condition: raise ValueError(message) def integer(value, low, high, name): require(type(value) is int and low <= value <= high, f"invalid {name}") return value def finite_number(value, low, high, name): require(type(value) in (int, float) and low <= value <= high and math.isfinite(value), f"invalid {name}") return value def sha256(data): return hashlib.sha256(data).hexdigest() def outside_git(path): resolved = Path(path).resolve() require(not any((p / ".git").exists() for p in (resolved, *resolved.parents)), "datasets and outputs must be outside Git worktrees") return resolved def read_bounded(path, limit): require(path.is_file() and path.stat().st_size <= limit, "file missing or too large") with path.open("rb") as handle: data = handle.read(limit + 1) require(len(data) <= limit, "file grew beyond limit") return data def preflight_npz_directory(raw, expected_names, expansion_limit): """Bound ZIP metadata before ZipFile allocates any ZipInfo objects. Supported NPZs have one disk and a classic, comment-free end record. Normal np.savez/np.savez_compressed force ZIP64 *local* headers even for small arrays; those remain supported. Archive-level ZIP64 directories/locators and ZIP64 central size/offset sentinels are unnecessary for our <=1 GiB, <=32-member inputs and fail closed. No central directory is copied here. """ names = {name.encode("ascii") for name in expected_names} require(1 <= len(names) <= 32 and len(raw) >= 22, "invalid NPZ directory bounds") end = len(raw) - 22 eocd = struct.unpack_from("<4s4H2IH", raw, end) signature, disk, directory_disk, on_disk, total, size, offset, comment = eocd require(signature == b"PK\x05\x06" and comment == 0, "NPZ needs a classic comment-free ZIP end record") require(not (end >= 20 and raw[end - 20:end - 16] == b"PK\x06\x07") and on_disk != 0xffff and total != 0xffff and size != 0xffffffff and offset != 0xffffffff, "archive-level ZIP64 directory unsupported") require(disk == 0 and directory_disk == 0 and on_disk == total == len(names), "unexpected ZIP disk or entry count") require(46 * total <= size <= min(MAX_ZIP_DIRECTORY_BYTES, total * (46 + 128 + 1024 + 1024)), "ZIP central directory byte budget exceeded") require(offset <= end and offset + size == end, "ZIP directory offset/size mismatch") cursor, expanded = offset, 0 seen, local_offsets = set(), set() for _ in range(total): require(cursor + 46 <= end, "truncated ZIP central header") header = struct.unpack_from("<4s6H3I5H2I", raw, cursor) (magic, _, _, flags, compression, _, _, _, compressed, uncompressed, name_size, extra_size, comment_size, member_disk, _, _, local_offset) = header require(magic == b"PK\x01\x02", "invalid ZIP central signature") require(member_disk == 0 and compressed != 0xffffffff and uncompressed != 0xffffffff and local_offset != 0xffffffff, "ZIP64 central entry or multidisk member unsupported") require(1 <= name_size <= 128 and extra_size <= 1024 and comment_size <= 1024, "ZIP entry metadata budget exceeded") record_end = cursor + 46 + name_size + extra_size + comment_size require(record_end <= end, "truncated ZIP central entry") name = raw[cursor + 46:cursor + 46 + name_size] require(name in names and name not in seen, "unexpected or duplicate ZIP member") require(not flags & 1 and compression in (0, 8), "unsupported ZIP encoding") require(local_offset not in local_offsets and local_offset + 30 + compressed <= offset and raw[local_offset:local_offset + 4] == b"PK\x03\x04", "invalid ZIP local member extent") expanded += uncompressed require(expanded <= expansion_limit, "NPZ expansion exceeds limit") seen.add(name) local_offsets.add(local_offset) cursor = record_end require(cursor == end and seen == names, "ZIP central count/size disagreement") def unique_object(pairs): result = {} for key, value in pairs: require(key not in result, "duplicate JSON key") result[key] = value return result def load_dataset(npz_path, metadata_path): """Check ZIP and NPY allocation bounds before NumPy allocates arrays.""" data = read_bounded(outside_git(npz_path), MAX_DATA_BYTES) metadata_bytes = read_bounded(outside_git(metadata_path), 1024 * 1024) meta = json.loads(metadata_bytes, object_pairs_hook=unique_object, parse_constant=lambda x: (_ for _ in ()).throw(ValueError("nonfinite JSON"))) require(isinstance(meta, dict) and type(meta.get("schema_version")) is int and meta["schema_version"] == 1, "unsupported metadata schema") require(meta.get("provenance") in ("real", "synthetic"), "unknown provenance") require(meta.get("source_kind") in ("rac1", "bfi", "ntu_fi_humanid"), "unsupported source kind") if meta["source_kind"] == "ntu_fi_humanid": require(meta.get("split_protocol") == "archive_directory_custom", "NTU inputs require explicit archive_directory_custom protocol; not author-split reproduction") require(meta.get("label_status") in ("unlabeled", "identity_labeled"), "invalid label status") require(meta.get("dataset_sha256") == sha256(data), "dataset hash mismatch") sources = meta.get("source_files") require(isinstance(sources, list) and 1 <= len(sources) <= 1024, "missing source provenance") for source in sources: require(isinstance(source, dict) and isinstance(source.get("sha256"), str) and SHA256.fullmatch(source["sha256"]), "invalid source hash") integer(source.get("size_bytes"), 1, 1 << 50, "source size") n = integer(meta.get("frame_count"), 32, MAX_FRAMES, "frame count") f = integer(meta.get("feature_count"), 1, MAX_FEATURES, "feature count") require(isinstance(meta.get("format"), dict), "missing format metadata") require(meta.get("time_unit", "seconds") in ("seconds", "sample_index"), "invalid time unit") expected = {"frames.npy": ((n, f), "f", 4), "timestamps.npy": ((n,), "f", 8), "sequence.npy": ((n,), "u", 4)} preflight_npz_directory(data, expected, MAX_DATA_BYTES) with zipfile.ZipFile(io.BytesIO(data)) as archive: infos = archive.infolist() require(len(infos) == 3 and {i.filename for i in infos} == set(expected), "unexpected NPZ members") require(sum(i.file_size for i in infos) <= MAX_DATA_BYTES, "NPZ expansion exceeds limit") for info in infos: require(not info.flag_bits & 1 and info.compress_type in (0, 8), "unsupported ZIP encoding") with archive.open(info) as handle: version = np.lib.format.read_magic(handle) require(version in ((1, 0), (2, 0)), "unsupported NPY version") reader = (np.lib.format.read_array_header_1_0 if version == (1, 0) else np.lib.format.read_array_header_2_0) shape, fortran, dtype = reader(handle, max_header_size=4096) shape_expected, kind, itemsize = expected[info.filename] require(shape == shape_expected and not fortran and not dtype.hasobject and dtype.kind == kind and dtype.itemsize == itemsize, "invalid tensor shape or numeric dtype") require(handle.tell() + math.prod(shape) * dtype.itemsize == info.file_size, "NPY byte count mismatch") with np.load(io.BytesIO(data), allow_pickle=False) as loaded: frames = np.asarray(loaded["frames"], dtype=np.float32) timestamps = np.asarray(loaded["timestamps"], dtype=np.float64) sequence = np.asarray(loaded["sequence"], dtype=np.uint32) require(np.isfinite(frames).all() and np.max(np.abs(frames)) <= 1e6, "invalid frame values") require(np.isfinite(timestamps).all() and (timestamps > 0).all() and (timestamps < 1e11).all(), "invalid timestamps") sessions = meta.get("sessions") require(isinstance(sessions, list) and 1 <= len(sessions) <= 1024, "invalid sessions") cursor, seen = 0, set() for session in sessions: require(isinstance(session, dict), "invalid session") sid = session.get("session_id") require(isinstance(sid, str) and IDENTIFIER.fullmatch(sid) and sid not in seen, "invalid or duplicate session id") seen.add(sid) start = integer(session.get("start"), 0, n - 1, "session start") end = integer(session.get("end"), 1, n, "session end") require(start == cursor and end > start, "sessions must partition all rows without overlap") require(np.all(np.diff(timestamps[start:end]) > 0), "timestamps must increase within each session") cursor = end require(cursor == n, "sessions do not cover every row") return {"frames": frames, "timestamps": timestamps, "sequence": sequence, "metadata": meta, "metadata_sha256": sha256(metadata_bytes)} def build_splits(dataset, window, purge, mode="pretrain", max_gap_seconds=1.0, identity_protocol="session", stride=1): """Return target indices; no input or target frame crosses a split boundary.""" integer(window, 2, 256, "window") integer(purge, window, 4096, "purge (must be at least window)") finite_number(max_gap_seconds, 0.000001, 60, "maximum gap") integer(stride, 1, 256, "stride") require(mode in ("pretrain", "identity"), "invalid mode") meta, times = dataset["metadata"], dataset["timestamps"] intervals = {k: [] for k in SPLITS} labels = np.full(len(times), -1, dtype=np.int64) classes = [] if mode == "identity": require(meta["provenance"] == "real" and meta["label_status"] == "identity_labeled", "identity mode requires real, explicitly labeled data") require(identity_protocol in ("session", "published_files"), "invalid identity protocol") if identity_protocol == "published_files": require(meta.get("split_unit") == "source_file" and meta.get("acquisition_session_disjointness") == "unknown" and meta.get("label_origin") == "published_dataset", "published-file protocol requires explicit source and session limitations") seen_files = set() source_hashes = {s["sha256"] for s in meta["source_files"]} for s in meta["sessions"]: digest = s.get("source_sha256") require(digest in source_hashes and digest not in seen_files, "recording hashes must be unique and in source provenance") seen_files.add(digest) require(s.get("source_split") in ("train", "test"), "missing published source split") require((s["source_split"] == "test") == (s.get("split") == "test"), "declared source test may only be test; validation must come from declared source train") else: require(meta.get("split_unit") == "acquisition_session" and meta.get("acquisition_session_disjointness") == "verified", "strict identity mode requires verified acquisition-session separation") sources = {s["sha256"] for s in meta["source_files"]} seen_recordings, acquisition_splits = set(), {} for s in meta["sessions"]: digest, acquisition = s.get("source_sha256"), s.get("acquisition_session_id") require(digest in sources and digest not in seen_recordings, "strict sessions need unique recording hashes linked to provenance") require(isinstance(acquisition, str) and IDENTIFIER.fullmatch(acquisition), "missing source-linked acquisition session id") seen_recordings.add(digest) require(acquisition not in acquisition_splits or acquisition_splits[acquisition] == s.get("split"), "an acquisition session crosses identity splits") acquisition_splits[acquisition] = s.get("split") for s in meta["sessions"]: identity = s.get("identity") require(isinstance(identity, str) and IDENTIFIER.fullmatch(identity), "missing identity label") require(s.get("split") in SPLITS, "identity sessions need explicit disjoint split assignments") classes = sorted({s["identity"] for s in meta["sessions"]}) require(2 <= len(classes) <= 64, "identity mode requires 2..64 real identities") for split in SPLITS: require({s["identity"] for s in meta["sessions"] if s["split"] == split} == set(classes), "every identity needs a separate session in each split") for s in meta["sessions"]: intervals[s["split"]].append((s["start"], s["end"], s["session_id"])) labels[s["start"]:s["end"]] = classes.index(s["identity"]) else: require(meta.get("source_kind") != "ntu_fi_humanid" and not any("source_split" in s or "split" in s for s in meta["sessions"]), "pretraining must not repartition declared or published holdouts") for s in meta["sessions"]: a, b = s["start"], s["end"] first, second = a + (b - a) * 6 // 10, a + (b - a) * 8 // 10 for split, lo, hi in (("train", a, first), ("validation", first + purge, second), ("test", second + purge, b)): require(hi - lo > window, "session too short for chronological purged splits") intervals[split].append((lo, hi, s["session_id"])) targets = {} for split, ranges in intervals.items(): arrays = [] for a, b, _ in ranges: candidates = np.arange(a + window, b, stride, dtype=np.int64) bad = np.concatenate(([0], np.cumsum(np.diff(times[a:b]) > max_gap_seconds))) local = candidates - a candidates = candidates[bad[local] == bad[local - window]] arrays.append(candidates) targets[split] = np.concatenate(arrays) if arrays else np.empty(0, dtype=np.int64) require(len(targets[split]) >= 4, f"too few continuous {split} windows") if mode == "identity": require(set(labels[targets[split]]) == set(range(len(classes))), f"gap filtering removed an identity from {split}") return {"targets": targets, "intervals": intervals, "labels": labels, "classes": classes} def fit_normalization(frames, train_intervals): """Fit exclusively on training rows; float64 accumulation uses bounded chunks.""" total = np.zeros(frames.shape[1], dtype=np.float64) squared = np.zeros_like(total) count = 0 for start, end, _ in train_intervals: for offset in range(start, end, 4096): chunk = frames[offset:min(offset + 4096, end)].astype(np.float64) total += chunk.sum(axis=0) squared += (chunk * chunk).sum(axis=0) count += len(chunk) require(count > 0, "no training rows") mean = total / count scale = np.sqrt(np.maximum(squared / count - mean * mean, 0)) scale = np.maximum(scale, 1e-5) return mean, scale def transform_recordings(dataset, representation, temporal_order, seed, mode): """Offline recording controls. They are never applied to a forecasting task. Demeaning uses only that complete recording's samples, without labels or other recordings. Shuffling permutes complete feature rows together; it does not permute subcarriers separately. Administrative sample coordinates stay fixed, so the shuffled input has no claim to chronological sampling. """ require(representation in ("raw", "demean"), "invalid representation") require(temporal_order in ("original", "shuffle"), "invalid temporal order") integer(seed, 0, (1 << 31) - 1, "transform seed") meta, frames = dataset["metadata"], dataset["frames"] changed = representation != "raw" or temporal_order != "original" if changed: require(mode == "identity" and meta.get("source_kind") == "ntu_fi_humanid" and meta.get("split_protocol") == "archive_directory_custom" and meta.get("split_unit") == "source_file" and meta.get("time_unit") == "sample_index", "recording controls require public NTU custom identity mode") result = frames.copy() if changed else frames permutations = hashlib.sha256() for session in meta["sessions"] if changed else (): start, end = session["start"], session["end"] block = result[start:end] if representation == "demean": center = block.mean(axis=0, dtype=np.float64) np.subtract(block, center, out=block) if temporal_order == "shuffle": # No labels, split names, global RNG state or other recording values # enter this seed. PCG64 is explicit and NumPy's version is recorded. source = session.get("source_sha256") sid = session.get("session_id") require(isinstance(source, str) and SHA256.fullmatch(source) and isinstance(sid, str) and IDENTIFIER.fullmatch(sid), "shuffle needs recording provenance") material = (b"ruview-recording-shuffle-v1\0" + seed.to_bytes(4, "little") + bytes.fromhex(source) + hashlib.sha256(sid.encode()).digest()) digest = hashlib.sha256(material).digest() generator = np.random.Generator(np.random.PCG64(int.from_bytes(digest[:16], "little"))) order = generator.permutation(end - start) block[:] = block[order] permutations.update(digest) permutations.update(np.asarray(order, dtype="