diff --git a/.github/workflows/csi-encoder-training.yml b/.github/workflows/csi-encoder-training.yml new file mode 100644 index 000000000..d8ac617fd --- /dev/null +++ b/.github/workflows/csi-encoder-training.yml @@ -0,0 +1,36 @@ +name: CSI encoder training harness + +on: + pull_request: + paths: + - 'scripts/csi_training/**' + - 'scripts/train-csi-encoder.py' + - 'scripts/requirements-csi-training.txt' + - 'scripts/tests/test_csi_training*.py' + - '.github/workflows/csi-encoder-training.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + synthetic-cpu-contract: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: '3.12' + - name: Install pinned CPU training dependencies + run: | + python -m pip install torch==2.11.0 --index-url https://download.pytorch.org/whl/cpu + python -m pip install -r scripts/requirements-csi-training.txt + - name: Validate real training code using temporary synthetic data + env: + PYTHONPATH: scripts + run: python -m unittest discover -s scripts/tests -p 'test_csi_training*.py' -v + - name: Enforce capture data policy + run: bash scripts/csi-data-policy-check.sh --tracked diff --git a/docs/adr/ADR-419-reproducible-csi-encoder-training.md b/docs/adr/ADR-419-reproducible-csi-encoder-training.md new file mode 100644 index 000000000..28aef46a7 --- /dev/null +++ b/docs/adr/ADR-419-reproducible-csi-encoder-training.md @@ -0,0 +1,76 @@ +# ADR-419: Reproducible CSI encoder training from authorized capture manifests + +Status: Proposed, implementation supplied for review + +Date: 2026-10-12 + +Related: ADR-024, ADR-299, ADR-371, ADR-414, ADR-415 + +## Problem + +The published `ruvnet/wifi-densepose-pretrained` v2 encoder points to a trainer +that is absent from the current repository. The existing JavaScript trainer +produces a separate artifact family. Neither an upload date nor a model card +score establishes that a newer capture is available or suitable for training. +The published metric describes temporal embeddings, not presence or pose. + +## Decision + +Add an isolated Python CLI at `scripts/train-csi-encoder.py`. It does not change +the npm operator surface, production sensing, or a deployed model. + +1. Admit local feature captures using a versioned manifest with complete file + SHA256, declared UTC capture date, room, session, explicit partition, + real/synthetic evidence class, and affirmative permission to use the data. + Permission is an operator assertion, not independently verified consent. +2. Require disjoint rooms by default, or explicitly choose disjoint sessions. + Session separation alone is not evidence of generalization across rooms. + Reject duplicate files, copied observations across partitions, nonfinite + features, malformed records, and oversized input. Raw CSI requires a + separately reviewed feature adapter; do not fabricate features or labels. +3. Fit all standardization statistics on training observations only. Train an + 8 to 64 to 128 encoder with temporal contrastive pairs. Positives are within + two seconds of the same session and node; negatives are more than thirty + seconds away. No arbitrary batch row becomes a negative. +4. Select the checkpoint using validation only. Evaluate the final frozen + checkpoint and raw, standardized, and random encoder controls using the same + test triplets. Reusing test results for another experiment invalidates an + untouched acceptance claim and requires an external holdout policy. +5. Export safetensors, preprocessing, file hashes, actual dependency versions, + configuration, data manifest digest, and honest metrics. Refuse output + directory reuse. Failed or interrupted work cannot represent completion. +6. All runs retain `promotionAuthorized=false`. Temporal proxy improvements + never automatically qualify occupancy, vitals, pose, or an edge deployment. + +## Execution and boundaries + +ruOS can validate manifests and execute the CPU trainer. A separately +authorized Vast machine can execute the same CLI with CUDA and a protected +local dataset mount. The harness neither rents machines nor transfers data, +reads vault credentials, publishes artifacts, or promotes candidates. A +cooperative runtime limit is not a provider billing cap or instance teardown; +operators must separately bound and end any GPU rental. + +Slack can carry private handoff metadata. Public federation messages must be +limited to code coordination and public evidence. A failed coordination tool +is not a successful claim, agent execution, or training result. + +## Alternatives + +Extending the existing pose gate would conflate pose and embedding metrics. +Recovering deleted CSI from Git history conflicts with ADR-299's incident +boundary. An unrestricted GPU sweep before resolving provenance would spend +money without producing a defensible comparison. These options are rejected. + +## Validation and acceptance + +The focused unittest suite exercises actual CPU gradients, deterministic replay, +artifact loading, current collector compatibility, partition leakage, malformed +input and failure paths. Fixtures are generated temporarily and explicitly +synthetic; they establish software behavior only. No raw captures enter Git or CI. + +Real model improvement remains unverified until an authorized manifest and +adequate independent capture groups are available. A separate deployment gate +must compare downstream task performance on untouched rooms, quantization +parity, and measured device RAM and latency. A 4-bit file size alone proves +none of those properties. Keep the existing deployed model for rollback. diff --git a/docs/csi-encoder-training.md b/docs/csi-encoder-training.md new file mode 100644 index 000000000..6d81112cb --- /dev/null +++ b/docs/csi-encoder-training.md @@ -0,0 +1,104 @@ +# Reproducible CSI encoder training + +This harness trains a small temporal embedding model from explicit eight-value +CSI features. It is not a pose, presence, or vital-sign classifier. It produces +local candidates only, with no automatic publishing or promotion. + +## Set up + +Use Python 3.11 or 3.12 and an isolated environment. For the tested CPU setup: + +```sh +python3 -m venv .venv-csi +. .venv-csi/bin/activate +python -m pip install torch==2.11.0 --index-url https://download.pytorch.org/whl/cpu +python -m pip install -r scripts/requirements-csi-training.txt +PYTHONPATH=scripts python -m unittest discover -s scripts/tests -p 'test_csi_training*.py' -v +``` + +Install an appropriate CUDA build of the same PyTorch version for an authorized +GPU host. Record the resolved environment with `python -m pip freeze` alongside +the private run. CPU and GPU results need not be bit-identical. A requested CUDA +run fails when CUDA is unavailable instead of silently falling back to CPU. + +## Prepare private data + +Create a private JSON manifest with `schema_version: 1` and a `files` array. +Each entry has the following fields: + +| Field | Contract | +| --- | --- | +| `path` | Local regular file, relative to the manifest or absolute | +| `sha256` | Exact lowercase SHA256 of the complete file | +| `captured_at` | Actual declared capture time in UTC ISO 8601, not file modification or model upload time | +| `room_id` | Stable pseudonymous room identifier | +| `session_id` | Stable capture session identifier | +| `split` | `train`, `validation`, or `test` | +| `evidence` | `real` or `synthetic` | +| `use_permitted` | Explicit boolean `true`, asserted by the authorized operator | + +Keep producer revision, sensor configuration, consent, permitted execution sites, +label provenance and schema documentation with the private source manifest. +The CLI validates structural evidence; it cannot prove those operator assertions. +Never mark a capture usable solely because someone mentioned it in a message. + +Canonical JSONL rows contain `timestamp` in seconds, `node_id`, and `features` +as eight finite numbers. The explicit legacy feature stream and the current +collector feature envelope are supported. Raw subcarrier amplitudes are not +silently repurposed as features. Missing labels are never synthesized. + +Room separation is the default and requires at least three independent rooms +across training, validation and test. `--group-by session_id` allows distinct +sessions in the same room, but cannot substantiate performance in unseen rooms. +Each partition needs same-node/session positive pairs within two seconds and +negative observations more than thirty seconds apart. The training set alone +determines preprocessing statistics. + +## Inspect and train + +```sh +python scripts/train-csi-encoder.py inspect --manifest /private/csi/manifest.json +python scripts/train-csi-encoder.py train \ + --manifest /private/csi/manifest.json \ + --output /private/runs/csi-candidate-001 \ + --device cpu --epochs 20 --seed 42 --max-seconds 600 +``` + +For synthetic software experiments, add `--allow-synthetic`; results retain +the `SYNTHETIC` evidence label. Do not use those results as sensor benchmarks. +The output directory must not already exist. No input is changed or uploaded. + +For Vast, first provision an authorized isolated host, check its hourly price, +bind only the approved private dataset and pin the code commit and environment. +Then use the same command with `--device cuda`. The CLI does not access provider +keys, create instances, copy private captures, or stop the rental. Its cooperative +runtime limit must be backed by the operator's process and instance deadline. + +## Review the candidate + +The output includes `model.safetensors`, `preprocessing.json`, `metrics.json`, +and `status.json`. Check completion, manifest digest, implementation source +digests and complete file hashes. +Load preprocessing together with the model; never feed unstandardized features +directly into the network. Validation selects a checkpoint; the final test +comparison uses identical triplets for the candidate and controls. + +The loader applies the saved training statistics and verifies completion and +artifact hashes. Hashes detect corruption; they are not a trusted signature. + +```python +# Run with PYTHONPATH=scripts. raw_features has shape [N, 8]. +from csi_training.model import load_bundle + +encoder = load_bundle('/private/runs/csi-candidate-001') +embeddings = encoder.encode(raw_features) +``` + +`promotionAuthorized` remains false even if the temporal metric improves. +Temporal proximity is a proxy, and shared frame structure can make it easy. +Repeated test-driven tuning also contaminates a test set. Qualify downstream +tasks on fresh independent rooms and sessions before deployment. Quantization, +device RAM, latency, and compatibility with the production runtime remain +separate measured gates. + +See [ADR-419](adr/ADR-419-reproducible-csi-encoder-training.md) for boundaries. diff --git a/scripts/csi_training/__init__.py b/scripts/csi_training/__init__.py new file mode 100644 index 000000000..5a332b55a --- /dev/null +++ b/scripts/csi_training/__init__.py @@ -0,0 +1 @@ +"""Reproducible CSI feature training; no implicit raw CSI conversion.""" diff --git a/scripts/csi_training/data.py b/scripts/csi_training/data.py new file mode 100644 index 000000000..3a6e500d2 --- /dev/null +++ b/scripts/csi_training/data.py @@ -0,0 +1,257 @@ +"""Bounded, hash verified CSI feature intake and explicit grouped partitions. + +Manifest v1 has a ``files`` list. Each entry supplies path, sha256, +captured_at (UTC ISO 8601), room_id, session_id, split, evidence and +use_permitted. Capture dates are declarations, not inferred from file mtimes. +""" +from dataclasses import dataclass +from datetime import datetime, timezone +import hashlib +import json +import math +from pathlib import Path +import re +import stat + +import numpy as np + +SPLITS = ("train", "validation", "test") +MAX_FILE_BYTES = 256 * 1024 * 1024 +MAX_TOTAL_BYTES = 512 * 1024 * 1024 +MAX_MANIFEST_BYTES = 1024 * 1024 +MAX_LINE_BYTES = 65536 +MAX_FRAMES = 500000 +MAX_INPUT_LINES = 1000000 + + +class DataError(ValueError): + """Input does not satisfy the training evidence contract.""" + + +@dataclass +class Partition: + features: np.ndarray + timestamps: np.ndarray + node_ids: list + groups: list + session_ids: list + + +@dataclass +class Dataset: + partitions: dict + inventory: list + manifest_sha256: str + group_by: str + + +@dataclass +class Standardizer: + mean: np.ndarray + scale: np.ndarray + + def transform(self, features): + values = np.asarray(features, dtype=np.float64) + if values.ndim != 2 or values.shape[1] != 8 or not np.isfinite(values).all(): + raise DataError("standardizer requires finite Nx8 features") + result = (values - self.mean) / self.scale + if not np.isfinite(result).all(): + raise DataError("standardization overflow") + return result.astype(np.float32) + + +def fit_standardizer(train_features): + values = np.asarray(train_features, dtype=np.float64) + if values.ndim != 2 or values.shape[1] != 8 or len(values) < 2 or not np.isfinite(values).all(): + raise DataError("fit requires at least two finite training feature rows") + mean, scale = values.mean(axis=0), values.std(axis=0) + if not np.isfinite(mean).all() or not np.isfinite(scale).all(): + raise DataError("training statistics overflow") + return Standardizer(mean, np.where(scale < 1e-8, 1.0, scale)) + + +def _json(text): + def pairs(items): + result = {} + for key, value in items: + if key in result: + raise DataError("duplicate JSON key") + result[key] = value + return result + try: + return json.loads(text, object_pairs_hook=pairs, + parse_constant=lambda _: (_ for _ in ()).throw(DataError("nonfinite JSON number"))) + except (ValueError, UnicodeError) as exc: + raise DataError("invalid JSON input") from exc + + +def _regular(path, maximum): + if path.is_symlink(): + raise DataError("symlink input is not permitted") + try: + info = path.stat() + except OSError as exc: + raise DataError("input file unavailable") from exc + if not stat.S_ISREG(info.st_mode) or info.st_size > maximum: + raise DataError("input must be a bounded regular file") + return info.st_size + + +def _text(value, name): + if not isinstance(value, str) or not value.strip() or len(value) > 256 or any(ord(c) < 32 for c in value): + raise DataError(f"invalid {name}") + return value + + +def _manifest(path): + path = Path(path).absolute() + _regular(path, MAX_MANIFEST_BYTES) + raw = path.read_bytes() + if len(raw) > MAX_MANIFEST_BYTES: + raise DataError("manifest exceeds limit") + obj = _json(raw) + if not isinstance(obj, dict) or type(obj.get("schema_version")) is not int or obj["schema_version"] != 1: + raise DataError("manifest schema_version must equal 1") + entries = obj.get("files") + if not isinstance(entries, list) or not entries or len(entries) > 1024: + raise DataError("manifest files must contain 1 to 1024 entries") + checked, digests, total = [], set(), 0 + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + raise DataError("manifest file entry must be an object") + for key in ("room_id", "session_id", "path"): + _text(entry.get(key), key) + digest = entry.get("sha256") + if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest): + raise DataError("sha256 must be a lowercase hex digest") + if digest in digests: + raise DataError("duplicate file hash") + digests.add(digest) + if entry.get("split") not in SPLITS or entry.get("evidence") not in ("real", "synthetic"): + raise DataError("invalid split or evidence") + if entry.get("use_permitted") is not True: + raise DataError("explicit use_permitted=true is required") + captured = _text(entry.get("captured_at"), "captured_at") + try: + date = datetime.fromisoformat(captured.replace("Z", "+00:00")) + except ValueError as exc: + raise DataError("invalid captured_at") from exc + if date.tzinfo is None or date.utcoffset() != timezone.utc.utcoffset(date): + raise DataError("captured_at must specify UTC") + source = path.parent / entry["path"] + total += _regular(source, MAX_FILE_BYTES) + if total > MAX_TOTAL_BYTES: + raise DataError("total dataset exceeds byte limit") + checked.append((dict(entry, captured_at=date.isoformat()), source, index)) + return checked, hashlib.sha256(raw).hexdigest() + + +def discover(manifest): + """Inventory declared capture dates; load_manifest verifies hashes and frames.""" + entries, _ = _manifest(manifest) + return sorted([dict(captured_at=e["captured_at"], sha256=e["sha256"], + split=e["split"], evidence=e["evidence"], + room_id=e["room_id"], session_id=e["session_id"]) + for e, _, _ in entries], key=lambda e: datetime.fromisoformat(e["captured_at"]), reverse=True) + + +def _number(value): + if type(value) not in (int, float) or abs(value) > 1e20 or not math.isfinite(value): + raise DataError("numeric values must be finite and bounded") + return 0.0 if value == 0 else float(value) + + +def load_manifest(manifest, group_by="room_id", allow_synthetic=False): + """Validate partitions without deriving labels or creating raw CSI features.""" + if group_by not in ("room_id", "session_id"): + raise DataError("group_by must be room_id or session_id") + entries, manifest_digest = _manifest(manifest) + owners, session_owners, fingerprints, rows = {}, {}, {}, {s: [] for s in SPLITS} + seen_observations = set() + frame_count = 0 + input_lines = 0 + for entry, source, index in entries: + split, group = entry["split"], entry[group_by] + if entry["evidence"] == "synthetic" and not allow_synthetic: + raise DataError("synthetic data requires explicit allow_synthetic") + for mapping, key in ((owners, group), (session_owners, entry["session_id"])): + if key in mapping and mapping[key] != split: + raise DataError("group or session appears across splits") + mapping[key] = split + digest, last, count, size = hashlib.sha256(), {}, 0, 0 + with source.open("rb") as stream: + while True: + line = stream.readline(MAX_LINE_BYTES + 1) + if not line: + break + input_lines += 1 + if input_lines > MAX_INPUT_LINES: + raise DataError("dataset exceeds input line limit") + size += len(line) + if len(line) > MAX_LINE_BYTES or size > MAX_FILE_BYTES: + raise DataError("capture line or file exceeds limit") + digest.update(line) + if not line.strip(): + continue + row = _json(line) + if not isinstance(row, dict): + raise DataError("capture row must be an object") + # Mixed legacy recordings are supported only for explicit types. + if row.get("type") in ("vitals", "raw_csi"): + continue + if row.get("type") not in (None, "feature", "features"): + raise DataError("unsupported capture row type") + features = row.get("features") + node = row.get("node_id") + if isinstance(features, dict): + # CsiRecorder.write_frame removes the packet type and places + # explicit packet features/node_id in this nested envelope. + # Outer timestamp is already seconds; timestamp_us is metadata. + if row.get("type") is not None or features.get("type") not in (None, "feature", "features"): + raise DataError("unsupported collector envelope") + node = features.get("node_id") + features = features.get("features") + if not isinstance(features, list) or len(features) != 8: + raise DataError("feature rows require eight explicit features") + features = [_number(v) for v in features] + timestamp = _number(row.get("timestamp")) + if type(node) is int: + if node < 0 or node > 2**32 - 1: + raise DataError("invalid node_id") + node = str(node) + else: + node = _text(node, "node_id") + observation = (entry["session_id"], node, timestamp) + if observation in seen_observations: + raise DataError("duplicate session/node/timestamp observation") + seen_observations.add(observation) + if node in last and timestamp < last[node]: + raise DataError("timestamps must be monotonic per node") + last[node] = timestamp + # Bounded copy heuristic, not proof of absence of leakage. + # Idle/quantized vectors can legitimately recur at distinct times. + signature = json.dumps([timestamp, node, features], separators=(",", ":")).encode() + fingerprint = hashlib.sha256(signature).digest() + if fingerprint in fingerprints and fingerprints[fingerprint] != split: + raise DataError("duplicate feature frame across splits") + fingerprints[fingerprint] = split + rows[split].append((features, timestamp, node, group, entry["session_id"])) + count += 1 + frame_count += 1 + if frame_count > MAX_FRAMES: + raise DataError("dataset exceeds frame limit") + if digest.hexdigest() != entry["sha256"]: + raise DataError(f"file hash mismatch at manifest entry {index}") + if count == 0: + raise DataError("capture has no explicit feature rows; raw CSI requires a reviewed adapter") + partitions = {} + for split, values in rows.items(): + if len(values) < 2: + raise DataError(f"partition {split} requires at least two feature rows") + features, timestamps, nodes, groups, sessions = zip(*values) + partitions[split] = Partition(np.asarray(features, dtype=np.float64), + np.asarray(timestamps, dtype=np.float64), + list(nodes), list(groups), list(sessions)) + inventory = sorted([{k: e[k] for k in ("captured_at", "sha256", "split", "evidence", "room_id", "session_id")} + for e, _, _ in entries], key=lambda e: datetime.fromisoformat(e["captured_at"]), reverse=True) + return Dataset(partitions, inventory, manifest_digest, group_by) diff --git a/scripts/csi_training/model.py b/scripts/csi_training/model.py new file mode 100644 index 000000000..6a9846c33 --- /dev/null +++ b/scripts/csi_training/model.py @@ -0,0 +1,83 @@ +"""Small temporal CSI encoder; not a pose or occupancy classifier.""" +import torch +from torch import nn +from torch.nn import functional as F + + +class CSIEncoder(nn.Module): + """8 -> 64 -> 128, batch normalization, GELU and unit embeddings.""" + def __init__(self): + super().__init__() + self.w1 = nn.Linear(8, 64) + self.bn1 = nn.BatchNorm1d(64) + self.w2 = nn.Linear(64, 128) + self.bn2 = nn.BatchNorm1d(128) + + def forward(self, x): + return F.normalize(self.bn2(self.w2(F.gelu(self.bn1(self.w1(x))))), dim=-1) + + +class PreprocessedEncoder: + """Verified local candidate loader; includes train-fitted normalization. + + Hashes detect accidental artifact corruption. They are not signatures and + do not authenticate an untrusted directory or authorize model promotion. + """ + def __init__(self, directory, device='cpu'): + import hashlib + import json + from pathlib import Path + import numpy as np + from safetensors.torch import load_file + + root = Path(directory) + paths = {name: root / name for name in ('status.json', 'metrics.json', 'preprocessing.json', 'model.safetensors')} + for name, path in paths.items(): + if path.is_symlink() or not path.is_file() or path.stat().st_size > 2 * 1024 * 1024: + raise ValueError(f'Invalid bounded artifact: {name}') + status = json.loads(paths['status.json'].read_text()) + if not isinstance(status, dict) or status.get('status') != 'complete' or status.get('promotionAuthorized') is not False: + raise ValueError('Candidate lifecycle is not complete and proposal-only') + report = json.loads(paths['metrics.json'].read_text()) + if not isinstance(report, dict): + raise ValueError('Invalid metrics schema') + if report.get('architecture') != 'Linear(8,64),BN64,GELU,Linear(64,128),BN128,L2': + raise ValueError('Unsupported encoder architecture') + if report.get('status') != 'complete' or report.get('promotionAuthorized') is not False: + raise ValueError('Candidate is not complete') + for filename, key in [('model.safetensors', 'model_sha256'), ('preprocessing.json', 'preprocessing_sha256')]: + if hashlib.sha256(paths[filename].read_bytes()).hexdigest() != report.get(key): + raise ValueError(f'Artifact hash mismatch: {filename}') + prep = json.loads(paths['preprocessing.json'].read_text()) + if not isinstance(prep, dict): + raise ValueError('Invalid preprocessing schema') + mean, scale = np.asarray(prep['mean'], dtype=np.float64), np.asarray(prep['scale'], dtype=np.float64) + if (mean.shape != (8,) or scale.shape != (8,) or not np.isfinite(mean).all() + or not np.isfinite(scale).all() or not (scale > 0).all() + or prep.get('fit_partition') != 'train' or prep.get('feature_count') != 8): + raise ValueError('Invalid preprocessing contract') + self.mean, self.scale, self.device = mean, scale, device + self.model = CSIEncoder().to(device).eval() + self.model.load_state_dict(load_file(str(paths['model.safetensors']), device=device), strict=True) + if any(not torch.isfinite(v).all() for v in self.model.state_dict().values()): + raise ValueError('Non-finite model weights') + + def encode(self, features): + import numpy as np + values = np.asarray(features, dtype=np.float64) + if values.ndim != 2 or values.shape[1] != 8 or not 0 < len(values) <= 4096 or not np.isfinite(values).all(): + raise ValueError('Expected 1 to 4096 finite eight-feature rows') + with np.errstate(over='ignore', invalid='ignore'): + standardized = ((values - self.mean) / self.scale).astype(np.float32) + if not np.isfinite(standardized).all(): + raise ValueError('Input normalization overflow') + with torch.no_grad(): + result = self.model(torch.from_numpy(standardized).to(self.device)).cpu().numpy() + if not np.isfinite(result).all(): + raise ValueError('Non-finite encoder output') + return result + + +def load_bundle(directory, device='cpu'): + """Load a complete local bundle with verified hashes and preprocessing.""" + return PreprocessedEncoder(directory, device=device) diff --git a/scripts/csi_training/train.py b/scripts/csi_training/train.py new file mode 100644 index 000000000..b04d6ba59 --- /dev/null +++ b/scripts/csi_training/train.py @@ -0,0 +1,258 @@ +"""Reproducible group-held-out temporal contrastive training. + +No remote code, model upload, provisioning, or automatic promotion. Temporal +triplet accuracy describes embeddings only, never occupancy or pose accuracy. +""" +import argparse +import hashlib +import json +import math +import os +from pathlib import Path +import random +import time + +import numpy as np + + +def temporal_triplets(partition, seed, limit=4096): + """One positive 0