diff --git a/.github/workflows/csi-encoder-training.yml b/.github/workflows/csi-encoder-training.yml index 3106fb3642..1493babab4 100644 --- a/.github/workflows/csi-encoder-training.yml +++ b/.github/workflows/csi-encoder-training.yml @@ -5,6 +5,12 @@ on: paths: - 'scripts/csi_training/**' - 'scripts/field-context-probe/**' + - 'scripts/active-kge/**' + - 'scripts/pretrain-csi-predictive.py' + - 'scripts/pretrain-csi-transfer.py' + - 'scripts/benchmark-csi-intake.py' + - 'scripts/evaluate-active-evidence.py' + - 'scripts/verify-active-replay.py' - 'scripts/train-csi-encoder.py' - 'scripts/train-espfi.py' - 'scripts/improve-espfi.py' @@ -13,6 +19,7 @@ on: - 'scripts/requirements-espfi-training.txt' - 'scripts/requirements-espfi-inference.txt' - 'scripts/requirements-csi-training.txt' + - 'scripts/requirements-zenodo-intake.txt' - 'scripts/tests/test_csi_training*.py' - 'scripts/tests/test_espfi*.py' - '.github/workflows/csi-encoder-training.yml' @@ -37,6 +44,7 @@ jobs: 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 python -m pip install -r scripts/requirements-espfi-training.txt + python -m pip install -r scripts/requirements-zenodo-intake.txt - name: Validate real training code using temporary synthetic data env: PYTHONPATH: scripts @@ -76,3 +84,30 @@ jobs: run: cargo +1.90.0 test --locked - name: Exercise synthetic evidence pipeline run: cargo +1.90.0 run --locked --quiet --bin ruview-field-context-probe + - name: Exercise active evidence lifecycle + run: cargo +1.90.0 run --locked --quiet --bin ruview-field-context-probe -- --active-loop + + active-kge-contract: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: '22' + - name: Install exact KGE experiment dependency + working-directory: scripts/active-kge + run: npm ci --ignore-scripts --no-audit --no-fund + - name: Validate actual trained KGE and bounded artifact contract + working-directory: scripts/active-kge + run: npm test + - name: Install fixed Rust toolchain + run: rustup toolchain install 1.90.0 --profile minimal + - name: Feed actual KGE proposals through Rust authority boundary + run: | + node scripts/active-kge/cli.mjs --train "$RUNNER_TEMP/kge" --context context_1_8 > "$RUNNER_TEMP/ranking.json" + cargo +1.90.0 run --locked --quiet --manifest-path scripts/field-context-probe/Cargo.toml -- --active-loop > "$RUNNER_TEMP/reference.json" + cargo +1.90.0 run --locked --quiet --manifest-path scripts/field-context-probe/Cargo.toml -- --active-loop --ranking "$RUNNER_TEMP/ranking.json" > "$RUNNER_TEMP/candidate.json" + python scripts/verify-active-replay.py "$RUNNER_TEMP/reference.json" "$RUNNER_TEMP/candidate.json" diff --git a/.github/workflows/rust-cross-target.yml b/.github/workflows/rust-cross-target.yml new file mode 100644 index 0000000000..6ed9fbeff6 --- /dev/null +++ b/.github/workflows/rust-cross-target.yml @@ -0,0 +1,73 @@ +name: Rust cross-target dependency check + +on: + push: + branches: [main, develop, 'feature/*', 'feat/*', 'hotfix/*'] + paths: + - 'v2/Cargo.lock' + - 'v2/Cargo.toml' + - 'v2/rust-toolchain.toml' + - 'v2/crates/wifi-densepose-hardware/**' + - '.github/workflows/rust-cross-target.yml' + pull_request: + branches: [main, develop] + paths: + - 'v2/Cargo.lock' + - 'v2/Cargo.toml' + - 'v2/rust-toolchain.toml' + - 'v2/crates/wifi-densepose-hardware/**' + - '.github/workflows/rust-cross-target.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + dependency-path: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - name: Windows MSVC dependency path + os: windows-2025 + target: x86_64-pc-windows-msvc + - name: Android ARM64 dependency path + os: ubuntu-24.04 + target: aarch64-linux-android + steps: + - name: Checkout code and path dependencies + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + submodules: recursive + + - name: Install fixed Rust toolchain and target + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 + with: + toolchain: 1.89.0 + targets: ${{ matrix.target }} + + - name: Configure the preinstalled Android NDK linker + if: matrix.target == 'aarch64-linux-android' + shell: bash + run: | + ndk_bin="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin" + linker="$ndk_bin/aarch64-linux-android24-clang" + archiver="$ndk_bin/llvm-ar" + ranlib="$ndk_bin/llvm-ranlib" + test -x "$linker" + test -x "$archiver" + test -x "$ranlib" + echo "$ndk_bin" >> "$GITHUB_PATH" + echo "CC_aarch64_linux_android=$linker" >> "$GITHUB_ENV" + echo "AR_aarch64_linux_android=$archiver" >> "$GITHUB_ENV" + echo "RANLIB_aarch64_linux_android=$ranlib" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=$linker" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_LINUX_ANDROID_AR=$archiver" >> "$GITHUB_ENV" + + - name: Check hardware transport dependency path + working-directory: v2 + run: cargo +1.89.0 check --locked -p wifi-densepose-hardware --lib --target ${{ matrix.target }} diff --git a/docs/active-evidence-experiment.md b/docs/active-evidence-experiment.md new file mode 100644 index 0000000000..1e52214e9e --- /dev/null +++ b/docs/active-evidence-experiment.md @@ -0,0 +1,176 @@ +# Active evidence, KGE proposals and predictive CSI experiment + +Research implementation, measured on October 13, 2026 UTC. No field accuracy or +production readiness claim. Keep the existing sensing runtime and model defaults. + +## Implemented outcome + +The actual pinned RuField and WorldGraph libraries now run an authored sequence +that transitions through empty, occupied, unknown, occupied and unknown. A +conflicting observation creates uncertainty. Expired observations cannot keep an +occupancy belief alive. Historical graph nodes remain provenance, not a current +occupancy API. Repeated, derived, invalid, stale or future evidence is rejected. + +The separately trained actual `@ruvector/kge` package emits bounded ranking JSON. +Rust accepts only three registered logical sensor names and finite scores. It +reserves an authored packet, airtime and energy budget for proposals, never +actuates. An integrated verifier compares the default and KGE replays and requires +identical evidence and belief fields. Only the proposal can differ. + +The logical radar and door sources currently use signed WiFi feature fixtures to +exercise the pinned rule. They are not implemented physical radar or door +drivers. All replay observations, costs, relevance labels and thresholds are +SYNTHETIC. KGE scores and fusion heuristics are not calibrated probabilities. + +## Decisions + +* [ADR 421: evidence lifecycle](adr/ADR-421-active-evidence-loop.md) +* [ADR 422: KGE proposal authority](adr/ADR-422-kge-measurement-proposals.md) +* [ADR 423: predictive representation experiment](adr/ADR-423-predictive-csi-representation-experiment.md) + +## Measured results + +| Experiment | Result | Decision | +| --- | --- | --- | +| Rust active loop, three rounds of 1,000 alternating pairs | Median 0.0771 to 0.0853 ms; p95 0.1187 to 0.1377 ms | Retain bounded research implementation | +| Added Rust cost over signed single root fusion | Paired extra p95 0.0412 to 0.0576 ms | Small measured software overhead, no sensing benefit claim | +| Actual KGE, six authored held out target links | Hits@1 6/6; frequency and shuffled graph controls 2/6 each | Retain synthetic integration test, not real utility evidence | +| KGE reference ranking, three rounds of 1,000 calls | Median 0.0345 to 0.0349 ms; p95 0.0506 to 0.0577 ms | Retain original default | +| Cached KGE validation experiment | Fails lower median and p95 in every round gate | Reject optimization; preserve initial failure receipt | +| Predictive CSI, fixed five epochs on original training data only | Latent MSE 0.9740 versus global constant 0.9413 | Reject utility/promotion claim | +| Predictive frozen encoder timing | Median 0.919 to 1.175 ms; p95 1.326 to 1.478 ms | Timing only; not a sensing model improvement | + +Rust and Python ran on the existing ruOS Linux x86_64 AMD EPYC environment. +Rust 1.90.0 used the release profile and exact existing lockfile. Python used +Torch 2.11.0+cpu and NumPy 2.4.4 with one compute thread. Node KGE ran separately +on local Linux x64 Node 24.19.0, native backend. Do not add these timings to claim +a measured end to end latency across machines. Rust excludes signing, acquisition, +ranking transport and hardware. KGE excludes training and loading. Encoder timing +includes input validation, log normalization, token encoding and pooling but +excludes acquisition and file loading. This is not ESP32 timing or peak RAM. + +The predictive experiment fit 280 recordings and evaluated 139 inner development +recordings from the original training partition. The remaining 418 training +recordings were unused. No original validation or final test arrays were decoded. +These development groups have been reused in previous research and are not a new +independent confirmation set. Training took 7.34 seconds in the recorded scope. +The same final teacher targets and masks were used for every loss comparator: +untrained 1.1399, global constant 0.9413, per position constant 1.0487, trained +0.9740. Effective recording rank was 1.4055 and triggered the collapse warning. +Reloaded embeddings were bit identical. A loss improvement against an untrained +network is insufficient when a constant predictor performs better. + +No candidate from this experiment replaces the activity classifier, the published +eight feature encoder or any production model. No paid compute or hardware +operation was performed. Existing ruOS CPU resources were used. + +## Reproduce the software acceptance test + +From the repository root, with Node, Python and Rust 1.90.0 installed: + +```sh +cd scripts/active-kge +npm ci --ignore-scripts --no-audit --no-fund +npm test +cd ../.. +cargo +1.90.0 test --locked --manifest-path scripts/field-context-probe/Cargo.toml +node scripts/active-kge/cli.mjs --train /tmp/ruview-kge-new --context context_1_8 > /tmp/ruview-ranking.json +cargo +1.90.0 run --release --locked --manifest-path scripts/field-context-probe/Cargo.toml -- --active-loop > /tmp/ruview-reference.json +cargo +1.90.0 run --release --locked --manifest-path scripts/field-context-probe/Cargo.toml -- --active-loop --ranking /tmp/ruview-ranking.json > /tmp/ruview-candidate.json +python scripts/verify-active-replay.py /tmp/ruview-reference.json /tmp/ruview-candidate.json +``` + +Use a new output directory for each KGE training run. The Linux file reader +supports x86_64 and AArch64; other platforms must explicitly supply ranking JSON +on stdin using `--ranking -`. Built replay executables perform no network or +hardware activity. Initial npm/Cargo installation requires package access. + +Python training requires `scripts/requirements-csi-training.txt`. Run: + +```sh +PYTHONPATH=scripts OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 python -m unittest discover -s scripts/tests -p 'test_csi_training*.py' -q +python scripts/pretrain-csi-predictive.py --synthetic --output /tmp/ruview-predictive-new --epochs 5 --max-seconds 120 +``` + +For the public run, replace `--synthetic` with `--manifest` pointing to the +existing ADR 420 verified prepared manifest, with five sample temporal pooling. +Do not reinterpret different input axes or change splits to obtain a favorable +result. See [predictive harness contract](../scripts/csi_training/PREDICTIVE.md). + +## Validation and security + +The completed focused suites passed 41 Python tests, 14 Rust tests and 5 KGE +tests. The integrated replay verifier passed. An independent review found no +remaining source blocker after fixes. The first remote Python run lacked the +unchanged collector helper; restoring that test dependency produced the passing +run. A KGE test initially depended on the current directory; its CLI path now +resolves relative to the test file and passes from the repository root. + +| Boundary | Check and residual limit | +| --- | --- | +| Observation admission | Signature, unique root/hash, monotonic source and application clocks, explicit expiry, derived root rejection; public fixture keys are not production enrollment | +| Rejected observation | Does not advance the accepted clock or mutate the graph; exhausted capacity intentionally sets a sticky safety state | +| Ranking file | Open once with nofollow and nonblocking flags, validate the opened regular descriptor, bound bytes, reject duplicate JSON keys, unknown fields and nonfinite scores | +| KGE artifact | Bound file reads and native dimensions; exact authored vocabulary, relations, indexed triples, training splits and training receipt binding; checksums are not signer authentication | +| Predictive artifact | Bound descriptor reads, strict JSON, exact finite tensor shape/dtype checks, safetensors, source hash before/after and reload parity; local artifact trust still required | +| Authority | No hardware, network endpoint or executable supplied by ranking; proposals cannot modify roots or promote models | +| Evaluation | Unknown counts as an occupied miss; separate unknown coverage limits prevent abstention from manufacturing gains; bootstrap by room | + +Scoped npm audit of the exact KGE lockfile returned zero reported vulnerabilities +on October 13, 2026 UTC. This is not a security audit of all RuView dependencies, +the native binary internals or external device firmware. Rust pins and its lockfile +were unchanged. No fresh Rust advisory database audit was performed. + +## Field measurement contract + +`python scripts/evaluate-active-evidence.py /path/to/aligned-intervals.json` +computes aggregate duration metrics and seeded room clustered percentile +intervals. It accepts schema version 1, declared evidence, and sessions with +room/session IDs, matching baseline/candidate packets, airtime, energy and sensor +availability, and contiguous aligned intervals with independently supplied empty +or occupied truth plus empty/occupied/unknown predictions. Input is a bounded +regular JSON file. See `test_csi_training_active_evaluation.py` for the minimal +synthetic input generator. + +The default CLI remains report-only for interactive analysis. Automation can add +`--require-pass`: a valid report still prints in full, then exits 3 when +`overallGate` is `fail` or `inconclusive`; a passing report exits 0 and malformed +input exits 2. This process status enforces only the frozen software gate. It does +not change `fieldAcceptance`, authorize promotion or authenticate supplied labels. + +The numerical gate requires at least 20% lower false occupied duration, a 95% +room clustered interval supporting reduction, recall delta lower bound at least +minus one percentage point, and unknown duration delta upper bound at most one +percentage point separately for empty and occupied intervals. Zero baseline +false alarms or inadequate bootstrap exposure produce inconclusive results. +Two rooms are a computational minimum, not a claim of sufficient statistical +power. Pilot data must size a separate confirmation study. + +Labels, actual costs, fresh splits and capture completeness require independent +verification. Consequently even a numerical pass always returns +`fieldAcceptance: not_established` and `promotionAuthorized: false`. The paired +transition gate requires empty-to-occupied and occupied-to-empty events in at +least two rooms. Room-clustered upper confidence bounds must show no increase in +interval-aligned delay, unresolved transitions or false-occupied episodes per +empty hour. Unknown predictions extend delay; a transition that never resolves +within its truth run is retained with that run's full duration. `overallGate` +passes only when both duration and transition gates pass. Stationary occupant +sensitivity and sub-interval acquisition latency still require field analysis. +Replay cannot compare an action for which no eligible measurement was captured. + +## Receipts and rollback + +Receipts contain aggregate results and hashes, not captures or learned tensors: + +* `validation/active-loop-benchmark-20261013.json` +* `validation/active-kge-field-replay-20261013.json` +* `validation/active-kge-20261013.json` +* `validation/active-kge-cache-rejected-20261013.json` +* `validation/active-predictive-public-20261013.json` +* `validation/active-evidence-verification-20261013.json` + +Rollback is removal of the isolated experiment and its CI additions. Existing +production paths and published weights are unchanged. The next useful investment +is independently annotated continuous capture, followed by frozen room/session +confirmation and an inductive sensor utility experiment, not promotion of the +rejected predictive checkpoint. diff --git a/docs/adr/ADR-421-active-evidence-loop.md b/docs/adr/ADR-421-active-evidence-loop.md new file mode 100644 index 0000000000..0d27b369a1 --- /dev/null +++ b/docs/adr/ADR-421-active-evidence-loop.md @@ -0,0 +1,92 @@ +# ADR 421: Bounded active evidence loop + +Status: Research implementation. Physical qualification and production integration pending. + +Date: 2026 10 13 UTC + +## Context + +Fast activity inference does not establish continuous occupancy. The available +ESP-Fi recordings do not supply synchronized room topology or independently +annotated occupancy transitions. Motion evidence fading cannot prove an empty +room. ADR 346 protects existing firmware semantics; this experiment does not +replace its deployed presence gate. ADR 382 remains the separate geometry export +contract. No geometry, person identity or pose is inferred by this loop. + +## Decision and boundaries + +Extend the standalone field context probe with an explicit research mode using +the actual pinned RuField and WorldGraph dependencies. Keep production binaries, +firmware, published model weights and existing inference defaults unchanged. + +RuView observations carry source, capture time, quality, calibration and root +provenance. RuField verifies the event and provides fusion primitives. A bounded +application policy owns clock monotonicity, expiry, source dependence and +contradiction handling. WorldGraph stores supporting roots and semantic states. +KGE may rank measurement proposals through ADR 422. It has no evidence or device +authority. The predictive encoder in ADR 423 is an independent experiment until +its downstream utility is established. + +The executable fixture uses public test keys and authored observations. It is +always labelled SYNTHETIC. Its score is not a calibrated probability. Actual +production admission still requires independently enrolled sensor keys and +durable, integrity protected replay watermarks. Serializing WorldGraph alone +does not serialize an entire trust verifier or make a restart safe. + +## State transition specification + +1. Validate bounded input, source identity, root uniqueness, signature, declared + ancestry, capture time and monotonic application time before accepting evidence. +2. Keep at most one current observation per source. Conservatively reject derived + evidence as a new root. Multiple graph paths do not constitute multiple sensors. +3. Expire roots according to an explicit lifetime. Preserve historical provenance + without allowing historical graph nodes to sustain a current decision. +4. Compute empty, occupied or unknown. Conflicting eligible evidence and missing + eligible evidence produce unknown. Absence of motion alone is not emptiness. +5. If additional evidence is useful, filter ranked candidates through the sensor + allowlist, availability and packet, airtime and energy budgets. +6. Emit a proposal and its supporting context. Do not perform hardware actions, + autonomously promote models, publish personal data or silently expand budgets. + +An observation rejection must not mutate evidence or graph state. Budget +rejection must not consume budget. Clock rollback must not revive evidence. +Bounded capacity fails closed instead of evicting replay protection silently. + +## Alternatives + +Keeping the activity classifier alone is simplest but cannot establish the +requested temporal evidence contract. Letting a language model infer room state +has poorly bounded cost and no adequate sensor evidence boundary. A full +production service would add enrollment, durable replay state and device control +before there is field evidence of value. The isolated deterministic loop permits +rollback by removing the experiment and minimizes those additional authorities. + +## Acceptance and evidence + +Unit tests must cover duplicates, source dependence, contradiction, expiry, +derived roots, invalid ranking, clock rollback, capacity and budget exhaustion. +An integrated run must consume real KGE output through the bounded Rust reader. +Release benchmarks record warmup, repeated samples, baseline scope and p50/p95; +software timing on synthetic observations is not sensing accuracy. + +Field acceptance is separate: a frozen occupancy baseline versus memory and +adaptive selection on fresh rooms and sessions, at equal packets, airtime, +energy and available links. Target at least 20% lower false occupied duration per +verified empty room hour, a room clustered interval supporting improvement and +recall noninferiority within one percentage point. Unknown counts as a miss when +occupied. Require the upper confidence bound on additional unknown duration to +be at most one percentage point separately in empty and occupied intervals; +otherwise selective abstention could manufacture the false alarm improvement. +Recall intervals must not be conditioned on nonzero false alarm counts. +Report coverage and acquisition/window/detection delay separately. +Replay may compare only actions for which measurements were actually recorded. + +## Sources and implementation + +* `scripts/field-context-probe/README.md` +* `docs/adr/ADR-346-fail-closed-edge-occupancy-evidence.md` +* `docs/adr/ADR-382-spatial-evidence-rf-export.md` +* https://github.com/ruvnet/rufield +* https://github.com/ruvnet/worldgraph + +See `docs/active-evidence-experiment.md` for exact measured receipts and limits. diff --git a/docs/adr/ADR-422-kge-measurement-proposals.md b/docs/adr/ADR-422-kge-measurement-proposals.md new file mode 100644 index 0000000000..d11b2cca24 --- /dev/null +++ b/docs/adr/ADR-422-kge-measurement-proposals.md @@ -0,0 +1,58 @@ +# ADR 422: KGE ranks proposals without evidence authority + +Status: Research implementation. Predictive and field utility unproven. + +Date: 2026 10 13 UTC + +## Decision + +Use exact pinned `@ruvector/kge` 0.2.0 in a separate optional Node experiment. +Call the real backend and expose failures rather than silently substituting a +mock. Keep installation and model training out of the Rust decision path. + +The initial graph has authored context, sensor and relevance relationships. +Training excludes the held out target links. Compare trained ranking with a +simple frequency baseline and a permuted relevance control. A completed training +run, high training score or saved artifact is not evidence of unseen room utility. +The fixture is SYNTHETIC and its context entities are known to the model through +side facts. This is not an inductive unseen entity benchmark. + +Use HolE for supported link ranking. Do not assert that HolE implements the +adapter's RotatE composition operation. Native and WASM timing must be labelled +separately. Model hashes identify bytes; they do not authenticate a trusted author. + +## Interface + +The output is bounded JSON with exactly `schemaVersion: 1`, `evidence: +"SYNTHETIC"` and `candidates`. Each candidate has an allowlisted `sensorId` and a +finite numeric `score`. No device address, command, executable path, threshold, +occupancy state or evidence root is accepted from the ranker. Candidate scores +are ordering values, not calibrated probabilities or expected information gain. +Any tie uses deterministic ordering. Invalid output fails closed. + +Rust independently checks available sensors and all sensing budget dimensions. +A suggestion can be refused and cannot add independent evidence. It cannot +silently increase sensing or replace a stale observation with a plausible edge. + +## Risk and alternatives + +Frequency ranking is the mandatory simpler comparator. KGE costs training, +memory and inference time and may memorize familiar room IDs. Real deployment +requires an explicit new entity initialization/adaptation strategy and validation +on fresh rooms with frozen thresholds. A ranking model cannot identify causal +sensor utility from observational correlation alone. Prospective randomized +policies or complete eligible measurement replay are required. + +## Acceptance + +Contract tests check real training, finite allowed candidates, deterministic +ordering, persistence where supported, bounded inputs and missing dependency +failure. Record package and backend versions and training/fixture hashes. +Benchmark ranking without retraining on every request. Report all fixed controls, +including negative results. No automatic model promotion or hardware calls. + +Source: https://gist.github.com/ruvnet/3148ac8628c76d098fda7293fdc4a222 + +The cited core SDK wrapper was documented as a source integration, not proof +that every published core SDK contains those exports. This experiment calls +the published KGE package directly. diff --git a/docs/adr/ADR-423-predictive-csi-representation-experiment.md b/docs/adr/ADR-423-predictive-csi-representation-experiment.md new file mode 100644 index 0000000000..84039232ba --- /dev/null +++ b/docs/adr/ADR-423-predictive-csi-representation-experiment.md @@ -0,0 +1,60 @@ +# ADR 423: Predictive CSI representation experiment + +Status: Experimental research harness. Not a paper reproduction or replacement model. + +Date: 2026 10 13 UTC + +## Context and decision + +CSI-JEPA and AM-FM motivate representation learning from unlabeled CSI. Their +paper results have not been reproduced here. Implement a small masked latent +prediction experiment with an exponential moving average teacher, stop gradient +targets, a visible context encoder and a masked target predictor. Record collapse +diagnostics and compare validation loss against an untrained and constant +prediction baseline using the same final teacher targets. + +This is an amplitude representation with explicit time and subcarrier axes. It +is not the original eight engineered feature encoder, occupancy classifier, +dense pose model or a physical world simulator. Preserve the published model. +Train normalization only on the training partition. Hold out complete groups +before any fitting. The optional existing ESP-Fi path admits only the original +training partition, with its own inner development split; it does not reopen +the original validation or final test arrays. Its activity labels do not become +occupancy ground truth. + +## Training and artifact boundaries + +Bound shapes, samples, epochs and resource settings. Use deterministic seeds +and record exact dependency versions and source/artifact hashes. Save tensors +through safetensors, not executable pickle. Reject nonfinite inputs and outputs, +unsafe paths, incompatible normalization or tensor shapes, and malformed metadata. +An exported frozen encoder can feed a small downstream adapter; implementing an +adapter interface does not demonstrate downstream accuracy. + +Mask the target information before student encoding. Targets receive no gradient; +the teacher updates only by the declared moving average. Normalization must not +use per example hidden targets to leak target information into visible context. +Loss decline alone can mean representation collapse. Report variance diagnostics +and do not automatically promote a checkpoint for lower pretraining loss. + +## Alternatives and acceptance + +A reconstruction model is simpler but tests a different objective. Scaling +training first would spend resources before establishing valid data and objective +contracts. This small CPU experiment establishes the software path and failure +diagnostics before authorized larger training. + +Tests require real gradients, teacher isolation, masking behavior, train only +normalization, group separation, bounded safe reload and frozen encoder parity. +Timing must disclose preprocessing and observation window exclusions. Synthetic +smoke results remain labelled synthetic. Real transfer must be evaluated with +independent downstream labels and simple baselines on fresh groups. No training +run in this ADR authorizes model replacement or production deployment. + +## Research sources + +* CSI-JEPA: https://arxiv.org/abs/2605.14171 +* AM-FM: https://arxiv.org/abs/2602.11200 + +Source checkpoint availability and licensing have not been verified. No upstream +weights or datasets are implicitly authorized by citing these papers. diff --git a/docs/adr/ADR-424-predictive-transfer-gate.md b/docs/adr/ADR-424-predictive-transfer-gate.md new file mode 100644 index 0000000000..a8efc6c7cc --- /dev/null +++ b/docs/adr/ADR-424-predictive-transfer-gate.md @@ -0,0 +1,88 @@ +# ADR 424: Correct pooled representation collapse and test downstream utility + +Status: Frozen research protocol before measurement. No promotion authority. + +Date: 2026 10 13 UTC + +## Observed failure + +ADR 423's fixed experiment produced effective recording rank 1.4055 and a collapse +warning. Its masked latent MSE 0.9740 lost to a global constant baseline at 0.9413. +The penalty on token features did not establish diverse pooled representations. +Loss reduction alone is not a usable sensing improvement. + +## Hypothesis and one bounded intervention + +Keep the existing encoder, EMA teacher, masked objective and token variance +penalty. Add a penalty on the exact representation exported for downstream use: +the unmasked online encoder averaged over tokens for each recording. Use only +the current fitting minibatch. Penalize low standard deviation in each pooled +dimension and off diagonal covariance. This is inspired by VICReg, not a +reproduction of its complete method or a new algorithm claim. + +Freeze the candidate before execution: + +* Original token variance weight 0.1, as in ADR 423. +* Additional recording variance weight 1.0, target standard deviation 1.0, + numerical epsilon 0.0001. +* Additional covariance weight 0.1, squared off diagonal sum divided by 32. +* Same initialization seed 42, architecture, normalization, batch size 8, five + epochs, mask order, optimizer and EMA configuration for baseline and candidate. +* Only original training data: fit room 1 participants 1 through 4; evaluate room + 2 participants 5 and 6. These are reused development groups, not confirmation. + +The baseline must reproduce the prior encoder's tensors or frozen embeddings +before candidate utility is interpreted. Extra deterministic forwards must not +change baseline training. Different EMA teachers define different latent target +spaces; cross model latent MSE is not the utility comparison. + +## Downstream measurement + +Freeze all encoders before fitting a linear activity probe. Evaluate baseline, +candidate, untrained encoder, raw statistical features and a majority baseline. +Fit scaling, ridge weights and intercept on fitting records only, with fixed +ridge regularization 1.0 and seven numeric classes. No validation alpha search, +checkpoint selection or adaptation. Room, participant and trial IDs cannot enter +features. Activity class names remain unresolved; no occupancy labels are implied. + +The raw statistics comparator has 156 features versus 32 encoder features. +Report this asymmetry, per class F1, confusion matrices, accuracy and macro F1. +Deterministic fitted state hashes must remain unchanged when development labels +or development embeddings are changed. Label changes may change evaluation only. + +## Predeclared development gate + +The candidate must satisfy all conditions: + +1. Macro F1 at least 0.02 above each of baseline, untrained and statistics probes. +2. Accuracy no more than 0.01 below the highest comparator accuracy. +3. No newly zero F1 class relative to the baseline. +4. Effective recording rank at least 2.0 on development embeddings. +5. Batch one encoder p95 at most 1.2 times baseline in every declared timing round. + +Use repeated timings on identical inputs and the same CPU/thread settings. +Disclose normalization, loading and acquisition inclusions/exclusions. Compare +training duration and artifact sizes as costs. A numerical gate pass remains +development evidence and never authorizes promotion. A failure is preserved; +do not retune coefficients on these results or retry until the gate passes. + +## Boundaries and rollback + +Add isolated experiment modules; preserve ADR 423 runtime and receipts. Bound +input shapes, labels, fitting dimensions, resource settings and artifact reads. +Use safetensors with explicit versioned metadata, exact tensor checks and hashes. +No hardware control, autonomous installation, paid compute or model replacement. +Existing ruOS CPU may execute the bounded experiment. Remove the new modules to +roll back; production paths are not changed. + +## Acceptance and source + +Tests cover gradient direction and teacher isolation, pooled representation +definition, baseline reproduction, group isolation, frozen probe fitting, +malformed artifacts and finite outputs. A reproducible report must include every +comparator and every gate, even when the candidate fails. + +VICReg primary source: https://arxiv.org/abs/2105.04906 + +The cited paper demonstrates the approach in its own setting. Applying recording +variance and covariance penalties to this CSI pipeline remains an experiment. diff --git a/docs/adr/ADR-425-independent-confirmation-preflight.md b/docs/adr/ADR-425-independent-confirmation-preflight.md new file mode 100644 index 0000000000..5526b2fb29 --- /dev/null +++ b/docs/adr/ADR-425-independent-confirmation-preflight.md @@ -0,0 +1,41 @@ +# ADR 425: Independent confirmation preflight + +Status: accepted for experimental metadata validation only. + +## Decision and rationale + +ADR 424 improved representation rank while failing useful activity prediction. Further tuning on reused development records cannot establish independent generalization. Add a small standard library preflight before a future independent experiment. It does not modify current training, decode captures, invoke sensors, evaluate models, or authorize promotion. + +Inputs are a versioned manifest of opaque record identifiers, canonical signal digests, source revisions, split assignments, room, participant, session, device and numeric activity; plus a versioned ledger of previously exposed sources. Output is a deterministic aggregate report with input digests, class and group counts, reasons, and `promotionAuthorized=false`. No record identifiers appear in output. Direct API callers must supply bounded Python data; CLI intake reads bounded regular JSON files with duplicate key and nonfinite constant rejection. Symlinks and FIFOs are rejected. Input files remain private and untracked. + +All three partitions must have disjoint rooms, participants and sessions. Device separation is required only for the explicit `new_room_person_device` claim. Group identifiers must refer to globally stable physical groups across datasets; adding dataset prefixes to the same physical room would evade the check and is prohibited by the intake contract. Canonical signal digests must be generated from the same documented signal representation, before window augmentation, by trusted intake. The gate rejects duplicate digests even when record IDs differ. It cannot identify near duplicates or malicious relabeling. + +All seven numeric activity classes must occur in training and development. Confirmation requires at least ten recordings per class and at least two rooms and two participants overall. This is a minimum coverage screen, not a statistical power calculation. It does not prove each class covers every room or participant. Correlated trials are not independent participants. A future frozen protocol must set scientifically appropriate group coverage and power before collection. + +Any source revision in the supplied exposure ledger is barred from confirmation. The previously inspected ESP-Fi HAR revision is also embedded in the validator so an empty ledger cannot erase that known exposure. Exclusion is deliberately conservative at source revision granularity. A renamed source, changed revision, omitted history or fabricated metadata can evade this unsigned local check. The experiment owner must review source aliases and the complete exposure history. Passing metadata is never a confirmation result. + +The manifest binds a protocol SHA256 but does not verify that a corresponding protocol was registered before observation. A reviewer must verify protocol timing, capture provenance, consent and license suitability, model and baseline freezing, and independence before an actual confirmation run. This is a preflight advisory CLI, not an enforced gate in every training entrypoint. + +## Control flow and alternatives + +Validate exact schemas and bounds; collect global record and content sets; aggregate groups and class counts by partition; compare every partition pair; reject exposed confirmation sources and insufficient coverage; emit only aggregate evidence. Malformed or unsafe input exits 1, a valid but blocked plan exits 2, and a metadata pass exits 0. Every path keeps promotion disabled. No state or ledger is modified, so reruns are idempotent. + +Automatic retraining was rejected because it would consume compute without fixing the evidence limitation. A signed acquisition registry would authenticate more of the chain but requires real capture infrastructure and key ownership that this change does not supply. The standard library preflight is reversible and inexpensive; adding its module has no production inference cost. + +## Acceptance trace + +The synthetic tests cover a valid plan, all leakage axes, renamed duplicate recordings, known and caller declared source exposure, missing classes, insufficient room diversity, device claim scoping, malformed schema, bounded reads, duplicate JSON keys, nonfinite JSON, symlinks, FIFOs, and CLI exit codes. A pass means those software invariants hold on supplied metadata only. Tests are included by the existing `test_csi_training*.py` CI glob. + +## Use and rollback + +```sh +PYTHONPATH=scripts python -m csi_training.confirmation \ + --manifest /private/confirmation-plan.json \ + --ledger /private/exposure-ledger.json +PYTHONPATH=scripts python -m unittest discover -s scripts/tests \ + -p test_csi_training_confirmation.py -v +``` + +The exact schema and a generated synthetic example are in `scripts/tests/test_csi_training_confirmation.py:fixture`. Never substitute that fixture for public or physical evidence. Remove the module and test to roll back; no model, production service or database changes exist. + +Next action: assemble a reviewed inventory of fresh rooms, participants and device captures, then freeze the actual scientific comparison. Main risk: an incomplete exposure ledger or inconsistent physical identifiers. Acceptance: a fresh plan passes, and injecting one known source, shared room, participant, session, device under its applicable claim, or duplicate signal causes a nonzero exit before any training starts. diff --git a/docs/adr/ADR-426-selective-public-csi-intake.md b/docs/adr/ADR-426-selective-public-csi-intake.md new file mode 100644 index 0000000000..554a6bbaa6 --- /dev/null +++ b/docs/adr/ADR-426-selective-public-csi-intake.md @@ -0,0 +1,39 @@ +# ADR 426: Selective public CSI intake and native format diagnostics + +Status: accepted for draft research implementation, no model promotion. + +## Context + +ADR 424 rejected the predictive encoder. ADR 425 checks supplied metadata but does +not authenticate captures or enforce a training gate. A concrete next gap is +access to fresh public complex CSI without downloading a multi-gigabyte archive +or silently forcing its axes into the existing amplitude model. + +## Decision + +Add a source-specific bounded HTTP Range ZIP reader for Zenodo 18924941. Select +only three authored beacon capture1 members and their configuration files. Use +flat local names, exact range validation, byte/request budgets, ZIP CRC and local +SHA256. A missing immutable server validator remains an explicit limitation; +partial reads never claim verification of the full publisher archive digest. + +Keep the MATLAB v7.3 HDF diagnostic optional and separate from training. Check +native metadata and hash before inspecting the first 32 frames. Preserve antenna, +subcarrier and frame axes; report finite/zero amplitude diagnostics without +predicting occupancy. Disable all promotion in receipts. Synthetic injected +inputs remain labeled synthetic. + +## Consequences and alternatives + +The verified transfer is about 5.67 MB instead of downloading the 16.1 GB archive. +This is measured transfer volume, not an inference speedup. Native HDF introduces +an optional dependency and parser trust boundary; enforce outer process limits +and reject external storage, links, virtual datasets and unexpected filters. +Full downloading would improve whole-archive digest verification at a much +higher transfer cost and is deferred. Blind conversion to the existing 52 channel +model is rejected because the source has 256 subcarriers and 4 antennas. + +The catalog/README license discrepancy remains unresolved. Selected samples stay +private and are exposed exploratory data, never independent confirmation. +Identity aliases and physical room/participant/device provenance require review +before scientific splitting. See [protocol, tests and rollback](../zenodo-csi-intake.md). diff --git a/docs/adr/ADR-427-enforced-confirmation-training-mode.md b/docs/adr/ADR-427-enforced-confirmation-training-mode.md new file mode 100644 index 0000000000..cba885c5d1 --- /dev/null +++ b/docs/adr/ADR-427-enforced-confirmation-training-mode.md @@ -0,0 +1,65 @@ +# ADR 427: Enforced independent-confirmation training mode + +Status: accepted for draft research implementation; no model promotion. + +## Context + +ADR 425 added a strict metadata preflight, but it remained advisory and was not +bound to the dataset manifest consumed by a trainer. A passing metadata document +could therefore be paired with different dataset bytes. The predictive paths +were rejected by ADR 424, while the temporal encoder measures only a proxy +embedding objective; neither is an appropriate confirmation entrypoint. + +## Decision + +Add an explicit, opt-in confirmation mode only to the supervised amplitude +trainer. The mode requires a complete metadata plan, exposure ledger, frozen +protocol SHA-256 and frozen dataset-manifest SHA-256. Before importing the +dataset loader, decoding data, creating output, or training, the gate validates +bounded regular files, leakage declarations, exposure history, coverage, and +both frozen hashes. It also requires the plan and dataset manifest to have the +same retained signal-digest set, canonical dataset/revision, partition mapping +(`train`, `validation`, `test` to `train`, `development`, `confirmation`) and +numeric activity IDs. Room and participant declarations bind to the loader's +numeric source identities using canonical `room:N` and `participant:N` tokens. +Session and, when claimed, device identifiers must also exist in the dataset +manifest and match; missing provenance blocks the gate. Trial number is not +treated as a physical session. This binds supplied declarations to the exact +records the supervised loader will consume without assigning activity semantics. Invalid +input exits 1; valid but blocked evidence exits 2. +The dataset loader parses and hashes the manifest through one checked descriptor, +then compares that digest before resolving or decoding the referenced dataset +bundle. In confirmation mode, the NPZ final component is also opened once as a +bounded regular file without following symlinks. Its bytes are copied while +hashed into a private temporary snapshot; only a matching, exact-length snapshot +is inspected and decoded. The snapshot is removed when loading ends. Its +returned digest is checked again before training. +Confirmation mode cannot optimize an existing bundle and every receipt remains +`promotionAuthorized: false`. + +Legacy research operation remains available without implying confirmation. +Current ESP-Fi is in the known-exposure set, so it cannot pass as independent +confirmation. Rollback removes the four gate flags and helper without changing +the legacy training path. + +## Consequences + +This closes accidental gate bypass within the selected mode and binds the +declared review to exact manifest bytes and retained record identities. It does not authenticate captures, +consent, licenses, aliases, stable physical identities, or exposure-ledger +completeness. The inputs are unsigned. The manifest hash binds declarations and +referenced payload hashes, not provenance truth. Parent directories remain a +trusted deployment boundary. Snapshot creation requires temporary storage up to +200 MB and can fail closed when that storage is unavailable. This cap covers the +largest fixed supervised schema at temporal pool one plus conservative DEFLATE, +NPY and ZIP overhead; native-resolution intake retains the unchanged 600 MB +shared bound. +Path replacement or same-inode mutation cannot change the verified snapshot +after it is copied; changes observed during copying fail the pinned digest or +exact-length checks. Passing software tests makes no sensing-accuracy claim. + +The current ESP-Fi adapter pins the canonical dataset name and source revision, +but its upstream records do not establish physical session or device identity. +Together with its prior exposure, this makes it correctly ineligible for an +independent-confirmation run even when its file hashes are intact. + diff --git a/docs/benchmarks/confirmation-preflight-20261013.json b/docs/benchmarks/confirmation-preflight-20261013.json new file mode 100644 index 0000000000..75a4267980 --- /dev/null +++ b/docs/benchmarks/confirmation-preflight-20261013.json @@ -0,0 +1,11 @@ +{ + "evidence": "SYNTHETIC", + "samples": 1000, + "records": 84, + "p50_ms": 0.368936, + "p95_ms": 0.455557, + "scope": "In-memory schema, leakage checks and canonical hashing; excludes file IO. Local shared CPU, not production inference.", + "python": "3.12.14", + "source_sha256": "12de9b3e07138dfeb18e6089d3e84d83dd67ac67e6a16bfaa3cdae9ba1759345", + "promotionAuthorized": false +} diff --git a/docs/benchmarks/intake-descriptor-20261013.json b/docs/benchmarks/intake-descriptor-20261013.json new file mode 100644 index 0000000000..bf200125d0 --- /dev/null +++ b/docs/benchmarks/intake-descriptor-20261013.json @@ -0,0 +1,28 @@ +{ + "evidence": "SYNTHETIC", + "baseline_commit": "721a094f09b97422e32555fc8444cdaa17c5fd3a", + "baseline_source_sha256": "e50e5305b8678e7dc9039d99faa8cc71c20132af786bdd1b3538def73ef49055", + "candidate_source_sha256": "8ee3eae346f31256c88b64c45b20541686ce8b5ff74846efedab2275aed7b687", + "valid_partition_parity": true, + "samples_per_arm": 1000, + "fixture_records": 9, + "fixture_files": 3, + "warmups_per_arm": 20, + "timing": { + "baseline": { + "p50_ms": 0.180943, + "p95_ms": 0.218539 + }, + "candidate": { + "p50_ms": 0.203307, + "p95_ms": 0.241545 + } + }, + "python_tracemalloc_peak_bytes": { + "baseline": 15118, + "candidate": 16670 + }, + "python": "3.12.14", + "scope": "Paired alternating full intake from warm filesystem cache. Shared CPU; tracemalloc excludes native allocations. No inference or accuracy claim.", + "promotionAuthorized": false +} diff --git a/docs/benchmarks/predictive-transfer-20261013.json b/docs/benchmarks/predictive-transfer-20261013.json new file mode 100644 index 0000000000..cb05bfa43b --- /dev/null +++ b/docs/benchmarks/predictive-transfer-20261013.json @@ -0,0 +1,946 @@ +{ + "checks": { + "candidate_effective_rank_at_least2": true, + "p95_within20percent_all_rounds": true, + "probe_gate": false + }, + "collapse": { + "baseline": { + "collapse_warning": true, + "recording_effective_rank": 1.405533790588379, + "sample_std": 0.5267161726951599 + }, + "candidate": { + "collapse_warning": false, + "recording_effective_rank": 4.015519618988037, + "sample_std": 0.49952468276023865 + }, + "untrained": { + "collapse_warning": false, + "recording_effective_rank": 1.5905420780181885, + "sample_std": 0.45848506689071655 + } + }, + "elapsed_seconds": 20.202091322, + "embedding_shapes": { + "baseline": { + "dev": [ + 139, + 32 + ], + "train": [ + 280, + 32 + ] + }, + "candidate": { + "dev": [ + 139, + 32 + ], + "train": [ + 280, + 32 + ] + }, + "statistics": { + "dev": [ + 139, + 156 + ], + "train": [ + 280, + 156 + ] + }, + "untrained": { + "dev": [ + 139, + 32 + ], + "train": [ + 280, + 32 + ] + } + }, + "evidence": "PUBLIC_TRAIN_ONLY", + "histories": { + "baseline": [ + { + "epoch": 1, + "objective": 0.7868377583367484 + }, + { + "epoch": 2, + "objective": 0.519914128950664 + }, + { + "epoch": 3, + "objective": 0.44930804542132785 + }, + { + "epoch": 4, + "objective": 0.421546328493527 + }, + { + "epoch": 5, + "objective": 0.4112824141979218 + } + ], + "candidate": [ + { + "epoch": 1, + "objective": 1.5465391635894776 + }, + { + "epoch": 2, + "objective": 1.1923631020954677 + }, + { + "epoch": 3, + "objective": 1.1136802298682076 + }, + { + "epoch": 4, + "objective": 1.1046021018709455 + }, + { + "epoch": 5, + "objective": 1.1007795163563319 + } + ] + }, + "inference_latency": { + "baseline": [ + { + "p50_ms": 1.0815045, + "p95_ms": 1.7290779999999997, + "round": 1 + }, + { + "p50_ms": 1.035225, + "p95_ms": 1.6527890499999998, + "round": 2 + }, + { + "p50_ms": 1.0412949999999999, + "p95_ms": 1.4845154999999999, + "round": 3 + } + ], + "candidate": [ + { + "p50_ms": 1.143, + "p95_ms": 1.6401495, + "round": 1 + }, + { + "p50_ms": 1.09904, + "p95_ms": 1.4046714999999999, + "round": 2 + }, + { + "p50_ms": 1.054725, + "p95_ms": 1.4393695, + "round": 3 + } + ], + "untrained": [ + { + "p50_ms": 1.0794000000000001, + "p95_ms": 1.5152235, + "round": 1 + }, + { + "p50_ms": 1.0112, + "p95_ms": 1.3845589999999999, + "round": 2 + }, + { + "p50_ms": 1.0636649999999999, + "p95_ms": 1.4637734999999998, + "round": 3 + } + ] + }, + "initial_state_sha256": "5dac31762dd381f19f28e967a26c8acc93e4c6156e64a168813ba612ee787047", + "lineage": { + "evidence": "PUBLIC_TRAIN_ONLY", + "inner_fit": "room1subjects1..4", + "inner_heldout": "room2subjects5,6", + "loaded_partitions": [ + "train" + ], + "manifest_sha256": "74e0fe039acbed1b287bd11ee6b779becbb8f9b66c3c249d260ed66f5583f02a", + "source_revision": "c8bf6aa0d680fa02695c85a82b05e79b3ebb10c4" + }, + "mask_order_sha256": { + "baseline": "184194ee5dcdf8903b3ed6e909b2f31cf9228586e5e86fd543fded40c1c8e0ec", + "candidate": "184194ee5dcdf8903b3ed6e909b2f31cf9228586e5e86fd543fded40c1c8e0ec" + }, + "models": { + "baseline": { + "arm": "baseline", + "lineage": { + "evidence": "PUBLIC_TRAIN_ONLY", + "inner_fit": "room1subjects1..4", + "inner_heldout": "room2subjects5,6", + "loaded_partitions": [ + "train" + ], + "manifest_sha256": "74e0fe039acbed1b287bd11ee6b779becbb8f9b66c3c249d260ed66f5583f02a", + "source_revision": "c8bf6aa0d680fa02695c85a82b05e79b3ebb10c4" + }, + "model_sha256": "be5cbf9a740fa73e46bdb4ba3cdd25e2c41b5c47d8a629d906658816f7207f7c", + "promotionAuthorized": false, + "source_sha256": { + "espfi.py": "1ea4f72b3bbb0fe68e9903e6f32663a887bd9c8eac3a659932ff97b7e3d87124", + "predictive.py": "1bc9c98cba03095eb09ceda1aaca5207edf7e63603de434fdd6ca8a15871d5d4", + "predictive_probe.py": "a06e25cfd3c0f8ba38a3ad8cdf810193b0b15edf6b121837dd6f574df0b43b5f", + "predictive_transfer.py": "febba288b2a17ad951f26607a456319bcc8c2b009b2c54c90b801cb27738199e" + }, + "spec": { + "arms": { + "baseline": { + "record_covariance": 0.0, + "record_variance": 0.0 + }, + "candidate": { + "record_covariance": 0.1, + "record_variance": 1.0 + } + }, + "base": { + "axes": [ + "subcarrier", + "sample_index" + ], + "claim": "experimental masked latent prediction, not reproduction or occupancy model", + "ema": 0.99, + "heads": 4, + "input": [ + 52, + 190 + ], + "latent": 32, + "layers": 1, + "mask_fraction": 0.4, + "normalization": "train-only per-carrier log-amplitude mean/std", + "patch": [ + 4, + 19 + ], + "seed": 42, + "variance_floor": 1.0, + "variance_weight": 0.1 + }, + "batch_size": 8, + "epochs": 5, + "promotionAuthorized": false, + "record_regularizer": "unmasked pooled32 embeddings; mean relu(1-sqrt(population_variance+1e-4)); squared offdiagonal unbiased covariance sum/32", + "schema": "predictive_transfer_v1", + "scope": "fixed research comparison inspired by VICReg, not reproduction; no confirmation or occupancy claim", + "seed": 42 + }, + "status": "complete" + }, + "candidate": { + "arm": "candidate", + "lineage": { + "evidence": "PUBLIC_TRAIN_ONLY", + "inner_fit": "room1subjects1..4", + "inner_heldout": "room2subjects5,6", + "loaded_partitions": [ + "train" + ], + "manifest_sha256": "74e0fe039acbed1b287bd11ee6b779becbb8f9b66c3c249d260ed66f5583f02a", + "source_revision": "c8bf6aa0d680fa02695c85a82b05e79b3ebb10c4" + }, + "model_sha256": "45401aa2edd4f5ee9c866b161a35176d89feede308dc48f77d4a99a092193cb8", + "promotionAuthorized": false, + "source_sha256": { + "espfi.py": "1ea4f72b3bbb0fe68e9903e6f32663a887bd9c8eac3a659932ff97b7e3d87124", + "predictive.py": "1bc9c98cba03095eb09ceda1aaca5207edf7e63603de434fdd6ca8a15871d5d4", + "predictive_probe.py": "a06e25cfd3c0f8ba38a3ad8cdf810193b0b15edf6b121837dd6f574df0b43b5f", + "predictive_transfer.py": "febba288b2a17ad951f26607a456319bcc8c2b009b2c54c90b801cb27738199e" + }, + "spec": { + "arms": { + "baseline": { + "record_covariance": 0.0, + "record_variance": 0.0 + }, + "candidate": { + "record_covariance": 0.1, + "record_variance": 1.0 + } + }, + "base": { + "axes": [ + "subcarrier", + "sample_index" + ], + "claim": "experimental masked latent prediction, not reproduction or occupancy model", + "ema": 0.99, + "heads": 4, + "input": [ + 52, + 190 + ], + "latent": 32, + "layers": 1, + "mask_fraction": 0.4, + "normalization": "train-only per-carrier log-amplitude mean/std", + "patch": [ + 4, + 19 + ], + "seed": 42, + "variance_floor": 1.0, + "variance_weight": 0.1 + }, + "batch_size": 8, + "epochs": 5, + "promotionAuthorized": false, + "record_regularizer": "unmasked pooled32 embeddings; mean relu(1-sqrt(population_variance+1e-4)); squared offdiagonal unbiased covariance sum/32", + "schema": "predictive_transfer_v1", + "scope": "fixed research comparison inspired by VICReg, not reproduction; no confirmation or occupancy claim", + "seed": 42 + }, + "status": "complete" + }, + "untrained": { + "arm": "untrained", + "lineage": { + "evidence": "PUBLIC_TRAIN_ONLY", + "inner_fit": "room1subjects1..4", + "inner_heldout": "room2subjects5,6", + "loaded_partitions": [ + "train" + ], + "manifest_sha256": "74e0fe039acbed1b287bd11ee6b779becbb8f9b66c3c249d260ed66f5583f02a", + "source_revision": "c8bf6aa0d680fa02695c85a82b05e79b3ebb10c4" + }, + "model_sha256": "0f3bbc4cbfd8eacf6923eb5f4f032889b5c4293fdea2f62280c5deebd7cd171d", + "promotionAuthorized": false, + "source_sha256": { + "espfi.py": "1ea4f72b3bbb0fe68e9903e6f32663a887bd9c8eac3a659932ff97b7e3d87124", + "predictive.py": "1bc9c98cba03095eb09ceda1aaca5207edf7e63603de434fdd6ca8a15871d5d4", + "predictive_probe.py": "a06e25cfd3c0f8ba38a3ad8cdf810193b0b15edf6b121837dd6f574df0b43b5f", + "predictive_transfer.py": "febba288b2a17ad951f26607a456319bcc8c2b009b2c54c90b801cb27738199e" + }, + "spec": { + "arms": { + "baseline": { + "record_covariance": 0.0, + "record_variance": 0.0 + }, + "candidate": { + "record_covariance": 0.1, + "record_variance": 1.0 + } + }, + "base": { + "axes": [ + "subcarrier", + "sample_index" + ], + "claim": "experimental masked latent prediction, not reproduction or occupancy model", + "ema": 0.99, + "heads": 4, + "input": [ + 52, + 190 + ], + "latent": 32, + "layers": 1, + "mask_fraction": 0.4, + "normalization": "train-only per-carrier log-amplitude mean/std", + "patch": [ + 4, + 19 + ], + "seed": 42, + "variance_floor": 1.0, + "variance_weight": 0.1 + }, + "batch_size": 8, + "epochs": 5, + "promotionAuthorized": false, + "record_regularizer": "unmasked pooled32 embeddings; mean relu(1-sqrt(population_variance+1e-4)); squared offdiagonal unbiased covariance sum/32", + "schema": "predictive_transfer_v1", + "scope": "fixed research comparison inspired by VICReg, not reproduction; no confirmation or occupancy claim", + "seed": 42 + }, + "status": "complete" + } + }, + "numpy_version": "2.4.4", + "original_baseline_exact": true, + "original_baseline_sha256": "be5cbf9a740fa73e46bdb4ba3cdd25e2c41b5c47d8a629d906658816f7207f7c", + "probes": { + "classes": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6 + ], + "gate": { + "accuracy_tolerance": 0.01, + "best_comparator_accuracy": 0.2517985611510791, + "best_comparator_macro_f1": 0.19939313207078144, + "checks": { + "accuracy_within_001_of_best": false, + "macro_f1_at_least_best_plus_002": false, + "no_new_zero_f1_class": true + }, + "macro_f1_margin": 0.02, + "passed": false + }, + "majority": { + "accuracy": 0.1366906474820144, + "confusion": [ + [ + 19, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 20, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 20, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 20, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 20, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 20, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 20, + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "confusion_axes": "rows=true numeric activity, columns=predicted numeric activity", + "macro_f1": 0.034358047016274866, + "per_class_f1": [ + 0.24050632911392406, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "predicted_activity": 0, + "tie_break": "lowest numeric activity" + }, + "probes": { + "baseline": { + "accuracy": 0.1366906474820144, + "artifact_state_sha256": "e8762a9157faa8f43cad8082485eb6cdbe91cd9f28460885589ea989c33537a5", + "confusion": [ + [ + 0, + 0, + 0, + 0, + 0, + 1, + 18 + ], + [ + 0, + 0, + 1, + 1, + 0, + 0, + 18 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 20 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 20 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 20 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 20 + ], + [ + 0, + 0, + 0, + 0, + 0, + 1, + 19 + ] + ], + "confusion_axes": "rows=true numeric activity, columns=predicted numeric activity", + "feature_count": 32, + "fitted_scalar_count": 295, + "macro_f1": 0.035023041474654376, + "per_class_f1": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.24516129032258063 + ], + "training_parameters": { + "alpha": 1.0, + "classes": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6 + ], + "intercept": true, + "macro_f1_classes": "all_seven_including_absent", + "scaling": "train_mean_population_std", + "selection": "none", + "zero_variance_scale": 1.0 + }, + "training_samples": 280 + }, + "candidate": { + "accuracy": 0.1510791366906475, + "artifact_state_sha256": "cabf10c9a4b3b4737e61c5d7ff52ce1d5621727dae42d0cbd517bdffa9b42f18", + "confusion": [ + [ + 1, + 2, + 0, + 0, + 0, + 2, + 14 + ], + [ + 0, + 0, + 0, + 0, + 1, + 0, + 19 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 20 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 20 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 20 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 20 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 20 + ] + ], + "confusion_axes": "rows=true numeric activity, columns=predicted numeric activity", + "feature_count": 32, + "fitted_scalar_count": 295, + "macro_f1": 0.05163398692810458, + "per_class_f1": [ + 0.1, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.26143790849673204 + ], + "training_parameters": { + "alpha": 1.0, + "classes": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6 + ], + "intercept": true, + "macro_f1_classes": "all_seven_including_absent", + "scaling": "train_mean_population_std", + "selection": "none", + "zero_variance_scale": 1.0 + }, + "training_samples": 280 + }, + "statistics": { + "accuracy": 0.2517985611510791, + "artifact_state_sha256": "0d0ba484a12b6046475067771202403d93c44ac24b3b6371fdafb79008945463", + "confusion": [ + [ + 2, + 9, + 0, + 0, + 7, + 0, + 1 + ], + [ + 0, + 20, + 0, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 0, + 4, + 5, + 0, + 10 + ], + [ + 0, + 0, + 0, + 2, + 8, + 0, + 10 + ], + [ + 1, + 0, + 0, + 1, + 1, + 0, + 17 + ], + [ + 0, + 0, + 0, + 3, + 2, + 0, + 15 + ], + [ + 1, + 0, + 0, + 0, + 9, + 0, + 10 + ] + ], + "confusion_axes": "rows=true numeric activity, columns=predicted numeric activity", + "feature_count": 156, + "fitted_scalar_count": 1411, + "macro_f1": 0.19939313207078144, + "per_class_f1": [ + 0.16666666666666666, + 0.8163265306122449, + 0.0, + 0.13333333333333333, + 0.038461538461538464, + 0.0, + 0.24096385542168675 + ], + "training_parameters": { + "alpha": 1.0, + "classes": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6 + ], + "intercept": true, + "macro_f1_classes": "all_seven_including_absent", + "scaling": "train_mean_population_std", + "selection": "none", + "zero_variance_scale": 1.0 + }, + "training_samples": 280 + }, + "untrained": { + "accuracy": 0.15827338129496402, + "artifact_state_sha256": "1d7cd093de706424139003cf89ac7de9f81de3a3c71e056ccd4ed55d0b2a44a6", + "confusion": [ + [ + 3, + 1, + 1, + 1, + 0, + 0, + 13 + ], + [ + 1, + 0, + 1, + 1, + 0, + 0, + 17 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 20 + ], + [ + 1, + 0, + 1, + 0, + 0, + 0, + 18 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 20 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 20 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 19 + ] + ], + "confusion_axes": "rows=true numeric activity, columns=predicted numeric activity", + "feature_count": 32, + "fitted_scalar_count": 295, + "macro_f1": 0.0712147716229349, + "per_class_f1": [ + 0.24, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.2585034013605442 + ], + "training_parameters": { + "alpha": 1.0, + "classes": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6 + ], + "intercept": true, + "macro_f1_classes": "all_seven_including_absent", + "scaling": "train_mean_population_std", + "selection": "none", + "zero_variance_scale": 1.0 + }, + "training_samples": 280 + } + }, + "promotionAuthorized": false, + "schema_version": 1, + "scope": "Fixed alpha=1; train-only scaling and label fit. Development labels score only. No hyperparameter search or representation selection. Caller supplies provenance and separately evaluates representation collapse and latency.", + "task": "frozen_numeric_activity_linear_probe" + }, + "promotionAuthorized": false, + "research_gate_passed": false, + "scope": "Fixed two-arm adaptive research on reused original training groups. No original validation/test payload, no heldout updates or selection. Encoder timings3x100 alternating paired batch1 include checks/normalization/encode, exclude acquisition/IO. No occupancy or confirmation claim.", + "source_sha256": { + "espfi.py": "1ea4f72b3bbb0fe68e9903e6f32663a887bd9c8eac3a659932ff97b7e3d87124", + "predictive.py": "1bc9c98cba03095eb09ceda1aaca5207edf7e63603de434fdd6ca8a15871d5d4", + "predictive_probe.py": "a06e25cfd3c0f8ba38a3ad8cdf810193b0b15edf6b121837dd6f574df0b43b5f", + "predictive_transfer.py": "febba288b2a17ad951f26607a456319bcc8c2b009b2c54c90b801cb27738199e" + }, + "spec": { + "arms": { + "baseline": { + "record_covariance": 0.0, + "record_variance": 0.0 + }, + "candidate": { + "record_covariance": 0.1, + "record_variance": 1.0 + } + }, + "base": { + "axes": [ + "subcarrier", + "sample_index" + ], + "claim": "experimental masked latent prediction, not reproduction or occupancy model", + "ema": 0.99, + "heads": 4, + "input": [ + 52, + 190 + ], + "latent": 32, + "layers": 1, + "mask_fraction": 0.4, + "normalization": "train-only per-carrier log-amplitude mean/std", + "patch": [ + 4, + 19 + ], + "seed": 42, + "variance_floor": 1.0, + "variance_weight": 0.1 + }, + "batch_size": 8, + "epochs": 5, + "promotionAuthorized": false, + "record_regularizer": "unmasked pooled32 embeddings; mean relu(1-sqrt(population_variance+1e-4)); squared offdiagonal unbiased covariance sum/32", + "schema": "predictive_transfer_v1", + "scope": "fixed research comparison inspired by VICReg, not reproduction; no confirmation or occupancy claim", + "seed": 42 + }, + "status": "complete", + "torch_version": "2.11.0+cpu" +} diff --git a/docs/csi-intake-descriptor-fix.md b/docs/csi-intake-descriptor-fix.md new file mode 100644 index 0000000000..f2a5273b1c --- /dev/null +++ b/docs/csi-intake-descriptor-fix.md @@ -0,0 +1,27 @@ +# Capture intake descriptor repair + +Decision: ACCEPT the bounded intake repair for draft review. No model or production deployment changes. + +The existing loader checked path metadata and later reopened the path. Two synthetic regressions reproduce on commit `721a094f09b97422e32555fc8444cdaa17c5fd3a`: replacing a capture with a symlink after inventory was accepted, and files growing after inventory could exceed the aggregate byte budget while satisfying their expected hashes. Hash verification alone does not enforce file type or resource bounds. A substituted FIFO could also block the former normal open. + +The loader now opens with `O_NOFOLLOW | O_NONBLOCK`, checks the opened descriptor with `fstat`, and consumes that same descriptor. It rejects nonregular files and enforces cumulative actual bytes before parsing each line. Manifest reads are bounded to the observed size plus one and reject length changes. Every descriptor closes on success or failure. No new dependency or model behavior is introduced. + +Seven synthetic regressions cover symlink and FIFO substitution, actual aggregate growth, descriptor binding after pathname replacement, manifest growth, manifest bounds, and valid reload. The prior loader failed the first two reproductions. All 65 training tests passed on ruOS in 9.780 seconds. The preexisting probe magnitude warning remains unrelated. Valid baseline and candidate partitions have identical features, timestamps, nodes, groups, sessions, inventory and manifest digest. + +[Benchmark receipt](benchmarks/intake-descriptor-20261013.json): 1,000 alternating paired loads after 20 warmups, three synthetic files and nine records. Full intake p50 is 0.180943 ms baseline and 0.203307 ms candidate; p95 is 0.218539 ms and 0.241545 ms. The measured security cost is about 0.023 ms at p95 (10.5%) on this tiny warm cache workload. Python traced allocation peaks are 15,118 and 16,670 bytes. These are local shared CPU measurements, not inference latency, large capture scaling or field accuracy. No training, final test decoding or paid compute occurred. Incremental infrastructure cost was not metered. + +An intermediate implementation eagerly allocated about 1 MB when reading a tiny manifest. The observed size bound removed that avoidable allocation before final measurement. This was a resource correction, not a tuning of acceptance thresholds. + +Reproduce the regression tests and trusted baseline comparison: + +```sh +PYTHONPATH=scripts python -m unittest discover -s scripts/tests -p test_csi_training_intake_races.py -v +git show 721a094f09b97422e32555fc8444cdaa17c5fd3a:scripts/csi_training/data.py > /tmp/ruview-intake-baseline.py +PYTHONPATH=scripts python scripts/benchmark-csi-intake.py --baseline /tmp/ruview-intake-baseline.py +``` + +The benchmark executes only the exact hash pinned baseline source after checking it; it rejects arbitrary supplied Python. The required historical commit must be present in the checkout. Synthetic files are generated in a temporary directory and removed afterward. + +Residual boundaries: parent directories remain trusted, with no filesystem confinement claim. Concurrent writes to an already open capture inode rely on the pinned content hash and read budgets; the loader does not create an immutable filesystem snapshot. Metadata inventory now requires read permission because it opens descriptors. Platforms without `O_NOFOLLOW` and `O_NONBLOCK` fail closed, matching the experimental artifact readers. This is a scoped source and regression review, not a repository wide security certification. + +Rollback is reverting this scoped patch; no state migration is needed. Next priority remains useful capture/data integration. Acceptance: the seven regression tests pass and a valid pinned dataset retains exact partition parity. Missing physical sensing evidence is unchanged. diff --git a/docs/espfi-motion-compact.md b/docs/espfi-motion-compact.md index c25310cf94..8bd328c1cb 100644 --- a/docs/espfi-motion-compact.md +++ b/docs/espfi-motion-compact.md @@ -55,7 +55,10 @@ hardware remain necessary for deployment claims. The compact research artifact passes the declared retention gate. Its weights file is 16,088 bytes versus 496,488 bytes, a 96.76% reduction. The three inference -artifact files total 17,245 bytes: weights 16,088, metadata 1,098 and status 59. +artifact files had a pre-generation-binding measurement of 17,245 bytes: weights +16,088, metadata 1,098 and status 59. Current exports add `metadata_sha256` to the +status receipt; record the exact total from each export rather than inferring it +from this historical measurement. This total excludes code, dependencies and the optional measurement receipt. The runtime reconstructs the large projection; no proportional RAM reduction is claimed. diff --git a/docs/espfi-motion-runtime.md b/docs/espfi-motion-runtime.md index 9227459cf8..6c4d976c1a 100644 --- a/docs/espfi-motion-runtime.md +++ b/docs/espfi-motion-runtime.md @@ -151,3 +151,50 @@ explicit reference fallback. Next action is the same benchmark on the intended target. Acceptance requires identical predictions, logit error at most 1e-10, and at least 10% lower median and p95 in every round. Production promotion remains unauthorized, and the existing zero-F1 activity class remains unresolved. + +## Artifact-directory pinning + +The runtime now opens the final artifact directory once with +`O_DIRECTORY|O_NOFOLLOW` and resolves status, metadata and model leaves relative +to that descriptor. A rename-and-replacement regression verifies that one load +continues from the opened directory instead of mixing in the replacement path. +A final-component symlink root and platforms without `dir_fd` support fail +closed. Existing per-leaf regular-file, size, no-follow and mutation checks stay +in force, and success/error paths close the directory descriptor. + +This pins only the final opened directory. It does not authenticate artifacts, +make the three reads an atomic snapshot, prevent a writer from replacing leaves +inside the pinned directory between opens, or protect ancestor components before +the directory open. Model bytes remain hash-bound to the metadata read. The +same-inode metadata-evasion residual also remains. + +A frozen 500-pair load check after 20 warmups measured baseline/candidate p95 +2.376450/2.451570 ms and traced peaks 1,067,282/1,067,330 bytes on the shared +ruOS CPU. Both passed the predeclared p95 `max(1.2x, +0.1 ms)` and `+4096` byte +limits. This is load-path overhead only and makes no sensing or prediction-speed +claim. The sanitized [receipt](validation/espfi-runtime-dirfd-20261013.json) +records exact hashes and rollback. + +## Export-generation chain + +A completed compact export now records the SHA-256 of the exact metadata bytes +in `status.json`; metadata already records the model SHA-256. Both the compact +reference reader and standalone buffered runtime therefore require the chain +`status.json -> metadata.json -> model.safetensors`. The completion marker is +written last. Mixing a valid completion marker from another export, changing +metadata, or supplying an older unbound artifact fails before safetensors +parsing. Older research bundles must be re-exported with the current exporter. + +This protects accidental or untrusted cross-generation mixing only when the +completion marker is trusted as part of the same local artifact set. The hashes +are not signatures and do not authenticate their producer, source data, +provenance or permissions. A writer that can replace and consistently rehash all +files can create a new self-consistent bundle. The chain does not bind +`metrics.json`, which is not consumed by inference. + +A frozen 500-pair load check after 20 warmups measured baseline/candidate p95 +2.473300/2.545180 ms and traced peaks 1,067,491/1,068,498 bytes on the shared +ruOS CPU. Both passed the predeclared p95 `max(1.2x, +0.1 ms)` and `+4096` byte +limits. The model, data, features and prediction math are unchanged. The +sanitized [receipt](validation/espfi-runtime-generation-chain-20261013.json) +binds the exact sources, workload, gates and rollback. diff --git a/docs/predictive-transfer-experiment.md b/docs/predictive-transfer-experiment.md new file mode 100644 index 0000000000..ea3cf61cfc --- /dev/null +++ b/docs/predictive-transfer-experiment.md @@ -0,0 +1,36 @@ +# Predictive transfer experiment, 2026-10-13 + +Decision: reject the candidate for promotion. Recording diversity improved, but useful activity prediction did not beat the controls. A reduction in collapse is not evidence of sensing accuracy. + +ADR 424 froze the protocol before the single public campaign. Its SHA256 is `609430cefbfdea4d8c5c70a7a016987475e9751ad418f730acd0db0f1b3acee2`. The aggregate receipt is [predictive-transfer-20261013.json](benchmarks/predictive-transfer-20261013.json). It binds source hashes, data revision, manifest, initialization, mask order, artifacts, metrics, and all acceptance checks. No captures, record embeddings, or weights are committed. + +The public campaign used 280 fitting recordings from room 1 participants 1 through 4 and 139 development recordings from room 2 participants 5 and 6, drawn exclusively from the original training partition. This development split has been reused: results are exploratory, not independent confirmation. Original validation and final test payloads were not decoded by this campaign. Both arms used five epochs, seed 42, batch eight, and identical masks and ordering. The original baseline encoder and normalization reproduced exactly against its pinned prior artifact before candidate evaluation. + +| Frozen representation | Macro F1 | Accuracy | Dimensions | +|---|---:|---:|---:| +| Original encoder | 0.03502 | 0.13669 | 32 | +| Candidate | 0.05163 | 0.15108 | 32 | +| Untrained encoder | 0.07121 | 0.15827 | 32 | +| Raw statistics | 0.19939 | 0.25180 | 156 | +| Training majority | 0.03436 | 0.13669 | 0 | + +All learned probes used training only scaling, an intercept, fixed ridge alpha 1, and seven numeric activity classes. Raw statistics use more features and fitted scalars (1,411 versus 295). These are activity metrics, not occupancy or motionless person detection metrics. + +The candidate's recording effective rank rose from 1.40553 to 4.01552, passing the rank threshold of two. It introduced no new zero F1 class, but five classes still had zero F1. Its macro F1 failed the required two percentage point margin over the strongest comparator; accuracy also failed the one percentage point tolerance. The combined research gate is false. + +Three paired CPU timing rounds alternated model calls on identical inputs, with 100 measurements per round. Candidate p95 was 1.64015, 1.40467, and 1.43937 ms; baseline p95 was 1.72908, 1.65279, and 1.48452 ms. All passed the 20% regression budget. These timings include input checks, normalization, and encoding, but exclude acquisition and IO. Shared host noise prevents a speedup claim. The campaign took 20.2021 seconds on ruOS, Torch 2.11.0+cpu and NumPy 2.4.4, one compute thread. + +Validation: all 50 `test_csi_training*.py` tests passed in 11.848 seconds on ruOS. The new tests check exact baseline reproduction, development mutation independence, frozen probe math, bounds, safe artifact reload, and failure states. Independent source review found no blocker. A NumPy warning about casting the float32 magnitude bound occurred in validation; finite float32 values remain below that bound and nonfinite values are rejected. It does not change the reported results. + +Security: artifacts require bounded regular files, strict schema and tensor validation, safe tensor decoding, finite values, and complete status. The public runner requires the pinned baseline artifact. Source identity is checked before and after the campaign. No artifact authorizes promotion. This review is scoped to this experimental surface, not a repository wide security certification. + +Reproduction on the prepared public manifest: + +```sh +PYTHONPATH=scripts OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 python scripts/pretrain-csi-transfer.py \ + --manifest /path/to/prepared/manifest.json \ + --baseline-artifact /path/to/prior/model.safetensors \ + --output /path/to/new-run --max-seconds 120 +``` + +Next decision: prioritize room and device generalization, collection diversity, and comparison against a strong statistics model before another large encoder campaign. Do not tune repeatedly on these 139 recordings. A future frozen candidate must beat all controls on independent rooms and participants, retain calibration and unknown behavior, and meet the inference budget. Physical motionless occupancy still requires suitable independently labeled measurements. WorldGraph, RuField and KGE cannot manufacture missing evidence. diff --git a/docs/validation/active-evaluation-enforcement-20261013.json b/docs/validation/active-evaluation-enforcement-20261013.json new file mode 100644 index 0000000000..9ed1019522 --- /dev/null +++ b/docs/validation/active-evaluation-enforcement-20261013.json @@ -0,0 +1,71 @@ +{ + "schemaVersion": 1, + "candidate": "active-evaluation-require-pass-v1", + "decision": "INCONCLUSIVE", + "decisionReason": "Frozen local acceptance passed; hosted checks at the proposed commit were not available when this receipt was created.", + "acceptedBaseline": { + "commit": "9fe3207bb7724e34ea249626c2422975f5972ae0", + "tree": "a83ad0be42b2bd06cf6bcd9a08516f128620ea18", + "hostedWorkflows": { + "dataPolicy": 38452296960, + "training": 38452297035, + "fixMarker": 38452296731, + "bench": 38452296956 + } + }, + "baselineFiles": { + "scripts/evaluate-active-evidence.py": "33ccee146fdb90701c16de84e367534bdd2c280bed8bc56a8e5198812949332e", + "scripts/csi_training/active_evaluation.py": "ddafb74b32c14ba23e9a4091329eecb3550c040c9b62879126451471a5124018", + "scripts/tests/test_csi_training_active_evaluation.py": "6927c7368fb23b85b80467eb7d1eecd95cb0c0beb68aa1f54aacab78a8f5bd70" + }, + "candidateFiles": { + "scripts/evaluate-active-evidence.py": "3fc8b36ac7034ce64342014f48a0829dc0a9e1e67ac3a24e1c51ba0fc6e4ca0e", + "scripts/tests/test_csi_training_active_evaluation_cli.py": "79bdb7a826dd6ad3234b83b581ab2e62ab958fba0d7a74aef5fa873a9cbc5479" + }, + "hypothesis": "An explicit fail-closed CLI mode prevents automation from accepting a valid fail or inconclusive overallGate while preserving report-only compatibility.", + "data": { + "evidence": "SYNTHETIC", + "splitPolicy": "Not applicable; deterministic CLI fixtures only. No model fitting, capture access, or final-test payload access occurred." + }, + "acceptance": { + "overallPassExitCode": 0, + "overallFailOrInconclusiveExitCode": 3, + "invalidInputExitCode": 2, + "fullJsonBeforeGateExit": true, + "defaultRemainsReportOnly": true, + "promotionAuthorized": false + }, + "tests": [ + { + "command": "PYTHONPATH=scripts /home/ruv/projects/ruview-training-env/bin/python -m unittest scripts.tests.test_csi_training_active_evaluation scripts.tests.test_csi_training_active_evaluation_cli -v", + "result": "PASS", + "count": 17, + "seconds": 0.786 + }, + { + "command": "PYTHONPATH=scripts:/home/ruv/projects/zenodo-intake-deps /home/ruv/projects/ruview-training-env/bin/python -m unittest discover -s scripts/tests -p 'test_csi_training*.py'", + "result": "PASS", + "count": 91, + "seconds": 11.204 + } + ], + "performance": { + "applicable": false, + "reason": "The candidate changes only explicit process-exit behavior after the unchanged evaluator returns; it is not an inference or evaluator-compute optimization." + }, + "safety": { + "fieldAcceptance": "not_established", + "promotionAuthorized": false, + "labelProvenanceVerified": false, + "gateModeIsExplicit": true, + "defaultCompatibilityPreserved": true + }, + "cost": "Existing authorized CPU only; infrastructure cost unmetered; no paid compute or training.", + "rollback": "Revert the candidate commit to 9fe3207; no input or output schema, data, model, or inference path changed.", + "missingEvidence": [ + "Hosted checks at the exact proposed commit", + "Authenticated field labels and capture provenance", + "Fresh multi-room confirmation" + ], + "nextAction": "Publish with an expected-head lease, verify the exact published tree and hosted checks, and keep promotion disabled." +} diff --git a/docs/validation/active-evidence-verification-20261013.json b/docs/validation/active-evidence-verification-20261013.json new file mode 100644 index 0000000000..0b678a7433 --- /dev/null +++ b/docs/validation/active-evidence-verification-20261013.json @@ -0,0 +1,64 @@ +{ + "baselineTree": "921dfa969636c2270bce02e95c6c183f6337f951", + "dateUTC": "2026-10-13", + "decisions": { + "activeLoop": "retain as isolated synthetic software research", + "cachedKgeValidation": "reject default change: repeated p95 gate failed", + "predictiveEncoder": "reject utility and promotion: loses to global constant; collapse warning" + }, + "environment": { + "kge": "local Linux x64 Node24.19.0 native @ruvector/kge0.2.0", + "numpy": "2.4.4", + "ruOS": "Linux x86_64 AMD EPYC; existing CPU", + "rust": "1.90.0", + "torch": "2.11.0+cpu" + }, + "fieldAcceptance": "not_established", + "limitations": [ + "Software lifecycle correctness is not physical occupancy accuracy", + "Logical source fixtures all use signed WiFi modality, not real radar/door adapters", + "Durable authenticated replay protection and independent sensor enrollment remain deployment requirements", + "KGE synthetic known-entity relevance is not prospective sensor utility", + "Public latent evaluation uses reused inner development groups within original training only", + "No original validation/test decoding in predictive experiment", + "No hardware actions, paid compute or production promotion" + ], + "promotionAuthorized": false, + "schemaVersion": 1, + "security": { + "fullRepositoryAdvisoryAudit": false, + "kgeLockSHA256": "2822058884668c26b1be8cc455eafd298084d46c27ff28979f307232356d51d1", + "npmKgeAudit": "zero reported vulnerabilities 2026-10-13 UTC", + "rustPinsChanged": false + }, + "sourceSHA256": { + ".github/workflows/csi-encoder-training.yml": "8869939e5eb57bd678593ed84c795f51cdeb31a35c555037515a178073aaa499", + "scripts/active-kge/.gitignore": "7bf4b7949505be7ffdfad68d17d95f47b77fd52ea42d89de6ec027ee1db15fb1", + "scripts/active-kge/README.md": "d0202f0297af8fba508826383a47631614ef4d07794c46c1e31add7f71336926", + "scripts/active-kge/cli.mjs": "8d8fb86801083b7519f1ced8a1c616e0ca5f246659e0ae197e598c1dda0ef56c", + "scripts/active-kge/package-lock.json": "2822058884668c26b1be8cc455eafd298084d46c27ff28979f307232356d51d1", + "scripts/active-kge/package.json": "aa2794d8318f73c91920499cf7504889a81d38133730ee963af772b5703ace92", + "scripts/active-kge/ranker.mjs": "45e70d5c59e61ba135186178385491dd128ef54947308799741ea4ddc198f0d8", + "scripts/active-kge/test.mjs": "cf967d40fba44d9777aa05ee9bc36d7ac7acbf88ffcb5a965cc3b837044349df", + "scripts/csi_training/PREDICTIVE.md": "5f0ab670045f2214ea79579595ca0678adddf84195ddae1932d18999e79597ce", + "scripts/csi_training/active_evaluation.py": "1f343aeec73c88c379f7225dd8ac310f8940bf5dda23ac63413f4ab475b767cf", + "scripts/csi_training/predictive.py": "1bc9c98cba03095eb09ceda1aaca5207edf7e63603de434fdd6ca8a15871d5d4", + "scripts/evaluate-active-evidence.py": "33ccee146fdb90701c16de84e367534bdd2c280bed8bc56a8e5198812949332e", + "scripts/field-context-probe/README.md": "eca527d2e9544667ef889e877ea70aae18ce5c0c6d91dd08f73d114503959ca5", + "scripts/field-context-probe/src/active.rs": "e9d6f97749324d891fdd8be3b3a9043817cc883af4802286b509e5ea68035a83", + "scripts/field-context-probe/src/main.rs": "0bb8790e1afa0ce6da990214d07650d2ab88dd2fc7ca2f7167b694c8361fb229", + "scripts/pretrain-csi-predictive.py": "61bbf0dd56beaece58df870fccab98809c31389dc78afc21e400c1962745e9bf", + "scripts/tests/test_csi_training_active_evaluation.py": "05d4b3b69ca0c703543ba16be385710e5c65f5790416a3f0527aad53c54cb827", + "scripts/tests/test_csi_training_predictive.py": "1e35229a47e74e946151b49e64fb05d6f6f3c14bc3c1c9dfac2f4882dede7d16", + "scripts/verify-active-replay.py": "12d2ac7914883f33fd27512d605aa55735b297043ea09f9204621402038130a8" + }, + "status": "software_contracts_pass", + "validation": { + "dataPolicy": "pass", + "independentSourceReview": "no_block", + "integratedReplay": "pass", + "kgeTests": 5, + "pythonTests": 41, + "rustTests": 14 + } +} diff --git a/docs/validation/active-kge-20261013.json b/docs/validation/active-kge-20261013.json new file mode 100644 index 0000000000..9960a4d158 --- /dev/null +++ b/docs/validation/active-kge-20261013.json @@ -0,0 +1,189 @@ +{ + "schemaVersion": 1, + "status": "complete", + "evidence": "SYNTHETIC", + "promotionAuthorized": false, + "package": { + "name": "@ruvector/kge", + "version": "0.2.0", + "engineVersion": "2.4.0", + "backend": "native", + "backendArtifact": "native/kge.linux-x64-gnu.node", + "backendArtifactSha256": "4a32175eab0256d0e48b2e619388fae21b3c25dfb0269c1988a74c48c0920463" + }, + "config": { + "scorer": "hole", + "dims": 32, + "seed": 42 + }, + "training": { + "epochs": 100, + "lr": 0.05, + "seed": 42 + }, + "trainingReports": [ + { + "batches": 1, + "entities": 36, + "epoch": 99, + "epochs": 100, + "loss": 2.2073171138763428, + "n3Penalty": 0.005866814870387316, + "relations": 2, + "triples": 54 + }, + { + "batches": 1, + "entities": 36, + "epoch": 99, + "epochs": 100, + "loss": 2.2097179889678955, + "n3Penalty": 0.0067641702480614185, + "relations": 2, + "triples": 54 + } + ], + "stats": { + "dims": 32, + "entities": 36, + "indexed": false, + "relations": 2, + "scorer": "hole", + "seed": 42, + "splits": { + "test": 0, + "train": 54, + "transfer": 0, + "unlabelled": 0, + "valid": 0 + }, + "triples": 54 + }, + "fixtureSha256": "f26b7520deaf60d18734ae99534e9177605dc1d0e387b169ab86014f4d583ccb", + "modelSha256": "1bbd3e8189a0f9aef5aba0ff2ea8f5361b16188d29d2e03d9d5ea249b54f0de2", + "controlModelSha256": "822c80b28bc7c0c17fdfe61ad5b18d08b646108ca5cddac39194b007d563e6ab", + "sourceSha256": { + "ranker.mjs": "45e70d5c59e61ba135186178385491dd128ef54947308799741ea4ddc198f0d8", + "cli.mjs": "8d8fb86801083b7519f1ced8a1c616e0ca5f246659e0ae197e598c1dda0ef56c", + "package-lock.json": "2822058884668c26b1be8cc455eafd298084d46c27ff28979f307232356d51d1" + }, + "quality": { + "kge": { + "queries": 6, + "mrr": 1, + "hitsAt1": 1, + "ranks": [ + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "frequency": { + "queries": 6, + "mrr": 0.6111111111111112, + "hitsAt1": 0.3333333333333333, + "ranks": [ + 1, + 1, + 2, + 2, + 3, + 3 + ] + }, + "permutedGraph": { + "queries": 6, + "mrr": 0.6666666666666666, + "hitsAt1": 0.3333333333333333, + "ranks": [ + 2, + 2, + 1, + 1, + 2, + 2 + ] + } + }, + "latency": { + "evidence": "MEASURED", + "scope": "Full rank including input validation and exact entity scoring; excludes load and training", + "rounds": [ + { + "round": 1, + "callsPerRuntime": 1000, + "warmupsPerRuntime": 100, + "cached": { + "minMs": 0.03262900000001423, + "p50Ms": 0.033781000000004724, + "p95Ms": 0.0579970000000003, + "derivedQueriesPerSecond": 26496.88241656002 + }, + "reference": { + "minMs": 0.033430999999978894, + "p50Ms": 0.03488300000000777, + "p95Ms": 0.05774700000000621, + "derivedQueriesPerSecond": 25574.565869187463 + }, + "bothPercentilesLower": false + }, + { + "round": 2, + "callsPerRuntime": 1000, + "warmupsPerRuntime": 100, + "cached": { + "minMs": 0.03262900000001423, + "p50Ms": 0.03350100000000111, + "p95Ms": 0.04986500000001115, + "derivedQueriesPerSecond": 27541.272455811028 + }, + "reference": { + "minMs": 0.03351100000000429, + "p50Ms": 0.03449200000000019, + "p95Ms": 0.05064600000000041, + "derivedQueriesPerSecond": 26479.3734683672 + }, + "bothPercentilesLower": true + }, + { + "round": 3, + "callsPerRuntime": 1000, + "warmupsPerRuntime": 100, + "cached": { + "minMs": 0.032619000000011056, + "p50Ms": 0.03368999999997868, + "p95Ms": 0.05342999999999165, + "derivedQueriesPerSecond": 27383.32928653228 + }, + "reference": { + "minMs": 0.03362099999998236, + "p50Ms": 0.03469200000000683, + "p95Ms": 0.05272000000002208, + "derivedQueriesPerSecond": 25894.580727767563 + }, + "bothPercentilesLower": false + } + ], + "retainedCachedValidation": false, + "speedGatePassed": false, + "decision": "Reference default retained after first frozen paired campaign failed round-one p95; later timings do not promote the candidate", + "gate": "Both p50 and p95 strictly lower in every round; fixed before measurement", + "paired": true, + "alternatingOrder": true, + "identicalModelAndInputs": true + }, + "environment": { + "node": "v24.19.0", + "platform": "linux", + "arch": "x64" + }, + "limitations": [ + "Authored synthetic transductive link split; context feature membership is trained, target links are not.", + "Synthetic feature groups encode the authored task. This is not independent real occupancy or sensor utility evidence.", + "One frozen configuration and seed; no hyperparameter search, relation composition, model promotion or sensor action.", + "Scores are uncalibrated ranking scores, not probabilities. Equal-frequency baseline uses fixed wifi/radar/door tie order." + ] +} diff --git a/docs/validation/active-kge-cache-rejected-20261013.json b/docs/validation/active-kge-cache-rejected-20261013.json new file mode 100644 index 0000000000..a63f0ba8fb --- /dev/null +++ b/docs/validation/active-kge-cache-rejected-20261013.json @@ -0,0 +1,187 @@ +{ + "schemaVersion": 1, + "status": "complete", + "evidence": "SYNTHETIC", + "promotionAuthorized": false, + "package": { + "name": "@ruvector/kge", + "version": "0.2.0", + "engineVersion": "2.4.0", + "backend": "native", + "backendArtifact": "native/kge.linux-x64-gnu.node", + "backendArtifactSha256": "4a32175eab0256d0e48b2e619388fae21b3c25dfb0269c1988a74c48c0920463" + }, + "config": { + "scorer": "hole", + "dims": 32, + "seed": 42 + }, + "training": { + "epochs": 100, + "lr": 0.05, + "seed": 42 + }, + "trainingReports": [ + { + "batches": 1, + "entities": 36, + "epoch": 99, + "epochs": 100, + "loss": 2.2073171138763428, + "n3Penalty": 0.005866814870387316, + "relations": 2, + "triples": 54 + }, + { + "batches": 1, + "entities": 36, + "epoch": 99, + "epochs": 100, + "loss": 2.2097179889678955, + "n3Penalty": 0.0067641702480614185, + "relations": 2, + "triples": 54 + } + ], + "stats": { + "dims": 32, + "entities": 36, + "indexed": false, + "relations": 2, + "scorer": "hole", + "seed": 42, + "splits": { + "test": 0, + "train": 54, + "transfer": 0, + "unlabelled": 0, + "valid": 0 + }, + "triples": 54 + }, + "fixtureSha256": "f26b7520deaf60d18734ae99534e9177605dc1d0e387b169ab86014f4d583ccb", + "modelSha256": "1bbd3e8189a0f9aef5aba0ff2ea8f5361b16188d29d2e03d9d5ea249b54f0de2", + "controlModelSha256": "822c80b28bc7c0c17fdfe61ad5b18d08b646108ca5cddac39194b007d563e6ab", + "sourceSha256": { + "ranker.mjs": "c4b2bd51ddfa2018f5cbe1d9b066f0dfbc79f0fea9160ae6c9966335f3f59e31", + "cli.mjs": "8d8fb86801083b7519f1ced8a1c616e0ca5f246659e0ae197e598c1dda0ef56c", + "package-lock.json": "2822058884668c26b1be8cc455eafd298084d46c27ff28979f307232356d51d1" + }, + "quality": { + "kge": { + "queries": 6, + "mrr": 1, + "hitsAt1": 1, + "ranks": [ + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "frequency": { + "queries": 6, + "mrr": 0.6111111111111112, + "hitsAt1": 0.3333333333333333, + "ranks": [ + 1, + 1, + 2, + 2, + 3, + 3 + ] + }, + "permutedGraph": { + "queries": 6, + "mrr": 0.6666666666666666, + "hitsAt1": 0.3333333333333333, + "ranks": [ + 2, + 2, + 1, + 1, + 2, + 2 + ] + } + }, + "latency": { + "evidence": "MEASURED", + "scope": "Full rank including input validation and exact entity scoring; excludes load and training", + "rounds": [ + { + "round": 1, + "callsPerRuntime": 1000, + "warmupsPerRuntime": 100, + "cached": { + "minMs": 0.032389000000023316, + "p50Ms": 0.0333299999999781, + "p95Ms": 0.05661599999999112, + "derivedQueriesPerSecond": 26529.186761309516 + }, + "reference": { + "minMs": 0.03340000000000032, + "p50Ms": 0.034381999999993695, + "p95Ms": 0.055213999999978114, + "derivedQueriesPerSecond": 25673.32927215523 + }, + "bothPercentilesLower": false + }, + { + "round": 2, + "callsPerRuntime": 1000, + "warmupsPerRuntime": 100, + "cached": { + "minMs": 0.03243800000001329, + "p50Ms": 0.03335000000001287, + "p95Ms": 0.04794300000000362, + "derivedQueriesPerSecond": 27881.30182593822 + }, + "reference": { + "minMs": 0.033469999999994116, + "p50Ms": 0.034331999999977825, + "p95Ms": 0.04954399999999737, + "derivedQueriesPerSecond": 26860.71780379707 + }, + "bothPercentilesLower": true + }, + { + "round": 3, + "callsPerRuntime": 1000, + "warmupsPerRuntime": 100, + "cached": { + "minMs": 0.032648999999992157, + "p50Ms": 0.03353999999995949, + "p95Ms": 0.05286000000000968, + "derivedQueriesPerSecond": 27189.17592381059 + }, + "reference": { + "minMs": 0.03347099999996317, + "p50Ms": 0.03451200000000654, + "p95Ms": 0.05696600000004537, + "derivedQueriesPerSecond": 25773.63495805914 + }, + "bothPercentilesLower": true + } + ], + "retainedCachedValidation": false, + "gate": "Both p50 and p95 strictly lower in every round; fixed before measurement", + "paired": true, + "alternatingOrder": true, + "identicalModelAndInputs": true + }, + "environment": { + "node": "v24.19.0", + "platform": "linux", + "arch": "x64" + }, + "limitations": [ + "Authored synthetic transductive link split; context feature membership is trained, target links are not.", + "Synthetic feature groups encode the authored task. This is not independent real occupancy or sensor utility evidence.", + "One frozen configuration and seed; no hyperparameter search, relation composition, model promotion or sensor action.", + "Scores are uncalibrated ranking scores, not probabilities. Equal-frequency baseline uses fixed wifi/radar/door tie order." + ] +} diff --git a/docs/validation/active-kge-field-replay-20261013.json b/docs/validation/active-kge-field-replay-20261013.json new file mode 100644 index 0000000000..42a9e1793b --- /dev/null +++ b/docs/validation/active-kge-field-replay-20261013.json @@ -0,0 +1,160 @@ +{ + "authority": { + "actuation": false, + "promotion": false, + "rankingChangesEvidence": false + }, + "budgetStop": { + "actuated": false, + "reason": "budget_exhausted", + "remaining": { + "airtime_us": 1200, + "energy_uj": 120, + "packets": 0 + }, + "requested": false + }, + "evidence": "SYNTHETIC", + "expiryPolicy": "Wrapper recomputes live beliefs; persisted WorldGraph history is not a current authorization or occupancy API.", + "ruFieldRevision": "b74bc035e4a3d4023665aa5479b41e938facd113", + "schemaVersion": 1, + "scope": "Logical wifi/radar/door sources use signed WiFi feature fixtures, not physical modality adapters. Scores and costs are authored, not calibrated or measured sensing values.", + "steps": [ + { + "atNs": 1800000000000000000, + "belief": { + "ancestryEdges": 1, + "expiresNs": 1800000002000000000 + }, + "capacityExhausted": false, + "contradiction": false, + "expiresNs": 1800000002000000000, + "fusionHeuristicScore": null, + "liveSourceCount": 1, + "proposal": { + "actuated": false, + "reason": "not_needed_or_capacity", + "requested": false + }, + "rootIds": [ + "explicit_empty" + ], + "scenario": "explicit_empty", + "scoreIsProbability": false, + "state": "empty" + }, + { + "atNs": 1800000000100000000, + "belief": { + "ancestryEdges": 1, + "expiresNs": 1800000002100000000 + }, + "capacityExhausted": false, + "contradiction": false, + "expiresNs": 1800000002100000000, + "fusionHeuristicScore": 0.8999999761581421, + "liveSourceCount": 1, + "proposal": { + "actuated": false, + "reason": "not_needed_or_capacity", + "requested": false + }, + "rootIds": [ + "person_observed" + ], + "scenario": "person_observed", + "scoreIsProbability": false, + "state": "occupied" + }, + { + "atNs": 1800000000200000000, + "belief": { + "ancestryEdges": 2, + "expiresNs": 1800000002100000000 + }, + "capacityExhausted": false, + "contradiction": true, + "expiresNs": 1800000002100000000, + "fusionHeuristicScore": null, + "liveSourceCount": 2, + "proposal": { + "actuated": false, + "proposalOnly": true, + "rankingScore": 2.926807165145874, + "remaining": { + "airtime_us": 1200, + "energy_uj": 120, + "packets": 0 + }, + "requested": true, + "reservedAuthoredCost": { + "airtime_us": 1000, + "energy_uj": 100, + "packets": 2 + }, + "scoreIsProbability": false, + "sensorId": "wifi" + }, + "rootIds": [ + "conflicting_source", + "person_observed" + ], + "scenario": "conflicting_source", + "scoreIsProbability": false, + "state": "unknown" + }, + { + "atNs": 1800000000300000000, + "belief": { + "ancestryEdges": 2, + "expiresNs": 1800000002100000000 + }, + "capacityExhausted": false, + "contradiction": false, + "expiresNs": 1800000002100000000, + "fusionHeuristicScore": 0.8999999761581421, + "liveSourceCount": 2, + "proposal": { + "actuated": false, + "reason": "not_needed_or_capacity", + "requested": false + }, + "rootIds": [ + "radar_agrees", + "person_observed" + ], + "scenario": "radar_agrees", + "scoreIsProbability": false, + "state": "occupied" + }, + { + "atNs": 1800000002300000000, + "belief": null, + "capacityExhausted": false, + "contradiction": false, + "expiresNs": 1800000002300000000, + "fusionHeuristicScore": null, + "liveSourceCount": 0, + "proposal": { + "actuated": false, + "reason": "budget_exhausted", + "remaining": { + "airtime_us": 1200, + "energy_uj": 120, + "packets": 0 + }, + "requested": false + }, + "rootIds": [], + "scenario": "motionless_or_missing_updates_expire_to_unknown", + "scoreIsProbability": false, + "state": "unknown" + } + ], + "worldGraph": { + "edges": 6, + "nodes": 8, + "roundTripPreserved": true + }, + "worldGraphRevision": "9b1c79c836cdacfb7b44f058c593157bac4c1dab" +} diff --git a/docs/validation/active-loop-benchmark-20261013.json b/docs/validation/active-loop-benchmark-20261013.json new file mode 100644 index 0000000000..6db57ee8fd --- /dev/null +++ b/docs/validation/active-loop-benchmark-20261013.json @@ -0,0 +1,55 @@ +{ + "evidence": "MEASURED software timing on SYNTHETIC fixture", + "iterationsPerRound": 1000, + "profile": "release required", + "rounds": [ + { + "active_ms": { + "p50": 0.07778, + "p95": 0.13768999999999998 + }, + "baseline_ms": { + "p50": 0.06514, + "p95": 0.10477 + }, + "paired_extra_ms": { + "p50": 0.012310000000000001, + "p95": 0.05581 + }, + "round": 1 + }, + { + "active_ms": { + "p50": 0.08532000000000001, + "p95": 0.13663 + }, + "baseline_ms": { + "p50": 0.06869, + "p95": 0.10629 + }, + "paired_extra_ms": { + "p50": 0.012830000000000008, + "p95": 0.05764000000000001 + }, + "round": 2 + }, + { + "active_ms": { + "p50": 0.077139, + "p95": 0.1187 + }, + "baseline_ms": { + "p50": 0.06509, + "p95": 0.09707 + }, + "paired_extra_ms": { + "p50": 0.012399999999999994, + "p95": 0.04122000000000001 + }, + "round": 3 + } + ], + "scope": "Fresh engines per iteration; baseline verifies, ingests and fuses one signed root. Active adds bounded root intake, latest-source selection, refusion, WorldGraph evidence/belief ancestry and receipt. Excludes signing, sensing, ranking I/O and hardware. One logical WiFi source, not persistent-stream throughput.", + "scoreIsProbability": false, + "warmupsPerRound": 100 +} diff --git a/docs/validation/active-predictive-public-20261013.json b/docs/validation/active-predictive-public-20261013.json new file mode 100644 index 0000000000..231494b85b --- /dev/null +++ b/docs/validation/active-predictive-public-20261013.json @@ -0,0 +1,110 @@ +{ + "anti_collapse": { + "collapse_warning": true, + "recording_effective_rank": 1.405533790588379, + "sample_std": 0.5267161726951599 + }, + "configuration": { + "batch_size": 8, + "epochs": 5, + "max_seconds": 120 + }, + "elapsed_seconds": 7.3358941419999155, + "evidence": "PUBLIC_TRAIN_ONLY", + "heldout_latent_mse": { + "constant": 0.9412673291542547, + "position_constant": 1.0487403672376125, + "trained": 0.9740209227843251, + "untrained": 1.1398585417287812 + }, + "heldout_samples": 139, + "history": [ + { + "epoch": 1, + "train_objective": 0.7868377583367484 + }, + { + "epoch": 2, + "train_objective": 0.519914128950664 + }, + { + "epoch": 3, + "train_objective": 0.44930804542132785 + }, + { + "epoch": 4, + "train_objective": 0.421546328493527 + }, + { + "epoch": 5, + "train_objective": 0.4112824141979218 + } + ], + "inference_latency": { + "calls_per_round": 100, + "rounds": [ + { + "p50_ms": 0.9265300000000001, + "p95_ms": 1.332325, + "round": 1 + }, + { + "p50_ms": 0.91916, + "p95_ms": 1.3263670499999998, + "round": 2 + }, + { + "p50_ms": 1.175335, + "p95_ms": 1.4781839999999997, + "round": 3 + } + ], + "scope": "CPU batch1 public frozen encode including validation/log normalization/token encoder/mean pooling; excludes acquisition and file IO", + "warmup": 10 + }, + "lineage": { + "evidence": "PUBLIC_TRAIN_ONLY", + "inner_fit": "room1 subjects1..4", + "inner_heldout": "room2 subjects5,6", + "loaded_partitions": [ + "train" + ], + "manifest_sha256": "74e0fe039acbed1b287bd11ee6b779becbb8f9b66c3c249d260ed66f5583f02a", + "source_revision": "c8bf6aa0d680fa02695c85a82b05e79b3ebb10c4", + "unused_training_rows": 418 + }, + "model_sha256": "be5cbf9a740fa73e46bdb4ba3cdd25e2c41b5c47d8a629d906658816f7207f7c", + "normalization_fit": "train_only", + "numpy_version": "2.4.4", + "promotionAuthorized": false, + "reload_embedding_max_error": 0, + "scope": "EMA stopped targets; same final teacher target and mask for all baselines. No heldout tuning, original validation/test decoding, activity-to-occupancy relabeling, or task accuracy claim.", + "source_sha256": "1bc9c98cba03095eb09ceda1aaca5207edf7e63603de434fdd6ca8a15871d5d4", + "spec": { + "axes": [ + "subcarrier", + "sample_index" + ], + "claim": "experimental masked latent prediction, not reproduction or occupancy model", + "ema": 0.99, + "heads": 4, + "input": [ + 52, + 190 + ], + "latent": 32, + "layers": 1, + "mask_fraction": 0.4, + "normalization": "train-only per-carrier log-amplitude mean/std", + "patch": [ + 4, + 19 + ], + "seed": 42, + "variance_floor": 1, + "variance_weight": 0.1 + }, + "status": "complete", + "torch_version": "2.11.0+cpu", + "train_samples": 280 +} diff --git a/docs/validation/active-transition-validation-20261013.json b/docs/validation/active-transition-validation-20261013.json new file mode 100644 index 0000000000..4017604924 --- /dev/null +++ b/docs/validation/active-transition-validation-20261013.json @@ -0,0 +1,66 @@ +{ + "schemaVersion": 1, + "candidate": "active-transition-metrics-v1", + "decision": "INCONCLUSIVE", + "decisionReason": "Local frozen acceptance passed; hosted checks at the proposed commit were not yet available when this receipt was created.", + "baseline": { + "commit": "56b0085b0bb21cf26fc4792e65cb3b42c16d2888", + "tree": "b8510c010ed1ca8100699cd6ec23d4722fed68f6", + "sourceSha256": "1f343aeec73c88c379f7225dd8ac310f8940bf5dda23ac63413f4ab475b767cf" + }, + "candidateFiles": { + "scripts/csi_training/active_evaluation.py": "ddafb74b32c14ba23e9a4091329eecb3550c040c9b62879126451471a5124018", + "scripts/tests/test_csi_training_active_evaluation.py": "6927c7368fb23b85b80467eb7d1eecd95cb0c0beb68aa1f54aacab78a8f5bd70", + "docs/active-evidence-experiment.md": "23d19ac9f5332c2e0a0c11433a99009237dc7fcfcf6eb2151123e2f22136267f" + }, + "hypothesis": "Room-clustered transition delay, unresolved-transition, and false-occupied-episode deltas close the duration-only evaluator gap without allowing unknown predictions to hide delayed resolution.", + "data": { + "evidence": "SYNTHETIC", + "frozenWorkload": { + "rooms": 100, + "intervalsPerRoom": 100, + "resamples": 100, + "timingIterationsPerArm": 30, + "seed": 42 + }, + "splitPolicy": "Not applicable to the deterministic synthetic regression and performance workload; no model fitting or final-test payload access occurred." + }, + "acceptance": { + "transitionSemantics": "Both transition directions in at least two rooms; upper 95% room-bootstrap bounds must be no greater than zero for candidate-minus-baseline delay, unresolved fraction, and false episodes per empty hour.", + "runtime": "Candidate p95 must be no greater than max(1.5 times baseline, baseline plus 1 ms).", + "memory": "Candidate traced peak must be no greater than baseline plus 524288 bytes." + }, + "tests": [ + { + "command": "PYTHONPATH=scripts:/home/ruv/projects/zenodo-intake-deps /home/ruv/projects/ruview-training-env/bin/python -m unittest discover -s scripts/tests -p 'test_csi_training*.py'", + "result": "PASS", + "count": 87, + "seconds": 9.485 + } + ], + "performance": { + "baselineP95Ms": 62.387706, + "candidateP95Ms": 91.731155, + "latencyLimitMs": 93.581559, + "baselinePeakBytes": 80368, + "candidatePeakBytes": 169488, + "memoryLimitBytes": 604656, + "result": "PASS" + }, + "safety": { + "fieldAcceptance": "not_established", + "promotionAuthorized": false, + "labelProvenanceVerified": false, + "unknownPredictionsExtendDelay": true, + "unresolvedTransitionsRetained": true + }, + "cost": "Unknown incremental infrastructure cost; existing authorized CPU only, no paid provisioning or training.", + "rollback": "Revert the candidate commit; the input schema and existing numericalGate field remain unchanged.", + "missingEvidence": [ + "Hosted checks at the exact proposed commit", + "Authenticated field labels and capture provenance", + "Fresh multi-room confirmation", + "Sub-interval acquisition and actuation latency" + ], + "nextAction": "Publish to the existing draft branch with an expected-head lease, verify exact-head hosted checks, and keep promotion disabled." +} diff --git a/docs/validation/espfi-compact-lineage-20261013.json b/docs/validation/espfi-compact-lineage-20261013.json new file mode 100644 index 0000000000..4cf8b336d1 --- /dev/null +++ b/docs/validation/espfi-compact-lineage-20261013.json @@ -0,0 +1,52 @@ +{ + "decision": "INCONCLUSIVE", + "date": "2026-10-13", + "hypothesis": "Compact export lineage and retention metadata must remain bound to the exact baseline receipt and model bytes admitted by MotionBundle, even if the source paths change after validation.", + "baseline": { + "commit": "8282a6b21e83756f636d377c4fe5be4a1e489792", + "tree": "6a4e65ec1dc9d22e946c6a39978ee6e291543201", + "source_sha256": { + "scripts/csi_training/espfi_motion.py": "c6a8529ccf9d9f834383a2bdf5b14626764ec7ca2ade7ad5d4c1c14797d76054", + "scripts/csi_training/espfi_motion_compact.py": "8da946e1c575ae20b96dc3fdb229bb85cdc5054f6c2efb774aca506773770c2e", + "scripts/tests/test_espfi_motion_compact.py": "109031ec230bb0aeadd26acabe2f5c119d714b4c71f9fc28351cf801176c65c5" + }, + "adversarial_result": "FAIL: a forged second receipt read changed emitted parent lineage" + }, + "candidate_source_sha256": { + "scripts/csi_training/espfi_motion.py": "25d4f84e16c0b699654caef5c19614ef1d23c7ef0cc1e224e931cc5b95195b63", + "scripts/csi_training/espfi_motion_compact.py": "33886d88705410646787a73b05474e36e50be0d4a26535147a1a3bfaddd4b037", + "scripts/tests/test_espfi_motion_compact.py": "68b562b1e31ef21ce1db10457f0965d5a882e853e3d8aa40240e82fc47a798e3" + }, + "data_and_split_policy": "No dataset, split, preprocessing, model weights, thresholds, predictions, or final-test payloads were read or changed.", + "acceptance": { + "adversarial_lineage_and_size_test": "PASS", + "focused_compact_tests": "4/4 PASS", + "motion_regression_tests": "40/40 PASS", + "broader_csi_tests": "72 PASS; collection stopped on missing local optional h5py dependency", + "diff_check": "PASS", + "independent_security_review": "NO BLOCK", + "hosted_ci": "PENDING" + }, + "load_benchmark": { + "workload": "500 alternating loads after 20 warmups; 100 traced loads per arm", + "baseline": { + "p50_ms": 0.974565, + "p95_ms": 1.322553, + "peak_bytes": 16793369 + }, + "candidate": { + "p50_ms": 0.98026, + "p95_ms": 1.285652, + "peak_bytes": 16793409 + }, + "frozen_gate": "candidate p95 <= max(1.2 * baseline p95, baseline p95 + 0.1 ms) and peak <= baseline + 4096 bytes", + "result": "PASS" + }, + "security_scope": { + "fixed": "Parent receipt hash, model hash, model byte count, and retention gate use the exact buffers validated by MotionBundle.", + "residual": "Initial multi-path intake is not an authenticated atomic directory snapshot; coherent pre-validation substitution remains outside this patch." + }, + "cost": "Existing authorized ruOS CPU only; monetary cost unknown.", + "rollback": "Revert the containing commit.", + "next_action": "Require terminal hosted CI at the exact published head before accepting the candidate." +} diff --git a/docs/validation/espfi-runtime-dirfd-20261013.json b/docs/validation/espfi-runtime-dirfd-20261013.json new file mode 100644 index 0000000000..97dd1e1351 --- /dev/null +++ b/docs/validation/espfi-runtime-dirfd-20261013.json @@ -0,0 +1,43 @@ +{ + "schema_version": 1, + "measured_at_utc": "2026-10-13", + "baseline_commit": "2f6191bd461b5688d1221d7fcc5059f82bdc85c9", + "baseline_tree": "7c3c9db05ed72eeb52066493cb9bafc15a65e5a1", + "baseline_source_sha256": "e8bd5b68175f85d66d6a983d174ffa8f78bfb7b02c91a6f981f2ff30f951140b", + "candidate_source_sha256": "46ab78b34e591bab9dfe8afdb4d37d44570adb7048250661192ac3129ca87212", + "candidate_test_sha256": "15a2f80912611b5dce4a06a238c5ba3cd65ce97af413c2f2ec9f646e03f20fe1", + "data": "synthetic test fixture only; model state and prediction math unchanged", + "workload": { + "warmup_pairs": 20, + "measured_pairs": 500, + "traced_loads_per_arm": 100, + "order": "baseline then candidate per pair" + }, + "baseline": { + "p50_ms": 1.929205, + "p95_ms": 2.376450, + "traced_peak_bytes": 1067282 + }, + "candidate": { + "p50_ms": 1.996310, + "p95_ms": 2.451570, + "traced_peak_bytes": 1067330 + }, + "frozen_gates": { + "p95": "candidate <= max(1.2 * baseline, baseline + 0.1 ms)", + "traced_peak": "candidate <= baseline + 4096 bytes", + "p95_pass": true, + "traced_peak_pass": true + }, + "security_scope": "final artifact directory pinned for one load; basename-only leaves opened relative to its descriptor", + "not_established": [ + "artifact authenticity", + "atomic cross-file snapshot", + "ancestor path stability before open", + "protection from writers inside the pinned directory", + "sensing accuracy or prediction latency" + ], + "cost": "existing authorized ruOS CPU; infrastructure cost unmetered", + "promotionAuthorized": false, + "rollback": "revert the candidate commit" +} diff --git a/docs/validation/espfi-runtime-generation-chain-20261013.json b/docs/validation/espfi-runtime-generation-chain-20261013.json new file mode 100644 index 0000000000..2e5a5ddb8e --- /dev/null +++ b/docs/validation/espfi-runtime-generation-chain-20261013.json @@ -0,0 +1,46 @@ +{ + "schema_version": 1, + "measured_at_utc": "2026-10-13", + "baseline_commit": "d3586c905176794a52db5c59eb274d955abe0ef0", + "baseline_tree": "2a2792b9a4abb9a96d92749c938f8f1c9d78c3cb", + "baseline_runtime_sha256": "46ab78b34e591bab9dfe8afdb4d37d44570adb7048250661192ac3129ca87212", + "baseline_exporter_sha256": "74b13890afd33fbdf40efce3e91f44ddeb505d0f6d84f2d9a0b03adcd92c6f52", + "candidate_runtime_sha256": "bc0e2097cf06b18fec843694d5f8fd57ca5268309229eb06a06b208cb9c3cdc3", + "candidate_exporter_sha256": "8da946e1c575ae20b96dc3fdb229bb85cdc5054f6c2efb774aca506773770c2e", + "candidate_runtime_test_sha256": "165f36b5ee8b889cdd5439dbcae3399a5e45eca44fe074e7b94de8ccf6bbcec1", + "data": "synthetic test fixture only; model state and prediction math unchanged", + "contract": "complete status metadata_sha256 binds exact metadata bytes; metadata model_sha256 binds exact model bytes", + "compatibility": "older unbound research bundles fail closed and require re-export", + "workload": { + "warmup_pairs": 20, + "measured_pairs": 500, + "traced_loads_per_arm": 100, + "order": "baseline then candidate per pair" + }, + "baseline": { + "p50_ms": 2.035235, + "p95_ms": 2.473300, + "traced_peak_bytes": 1067491 + }, + "candidate": { + "p50_ms": 2.039215, + "p95_ms": 2.545180, + "traced_peak_bytes": 1068498 + }, + "frozen_gates": { + "p95": "candidate <= max(1.2 * baseline, baseline + 0.1 ms)", + "traced_peak": "candidate <= baseline + 4096 bytes", + "p95_pass": true, + "traced_peak_pass": true + }, + "not_established": [ + "artifact producer authenticity", + "provenance truth", + "protection from a writer that can consistently replace and rehash the full bundle", + "metrics.json binding", + "sensing accuracy or prediction latency" + ], + "cost": "existing authorized ruOS CPU; infrastructure cost unmetered", + "promotionAuthorized": false, + "rollback": "revert the candidate commit and re-export only if returning to the unbound research format" +} diff --git a/docs/validation/zenodo-full-quality-20261013.json b/docs/validation/zenodo-full-quality-20261013.json new file mode 100644 index 0000000000..8cca7256d2 --- /dev/null +++ b/docs/validation/zenodo-full-quality-20261013.json @@ -0,0 +1,96 @@ +{ + "evidence": "PUBLIC_SAMPLE_QUALITY_DIAGNOSTIC", + "source": "10.5281/zenodo.18924941", + "candidate_source_sha256": "b2d53f65ac865860baf30c2ac9cb844b6f0be50346db2f9354c88754451c62f2", + "baseline_commit": "47589e00f636971ec7e0ca2483d4809f9ebcf775", + "samples": { + "empty": { + "baseline_reproduced": true, + "elapsed_seconds": 0.08474922499999593, + "inspected_frames": 596, + "decoded_complex_values": 610304, + "chunks_read": 19, + "finite_fraction": 1, + "zero_amplitude_fraction": 0.07758100880872483, + "zero_amplitude_fraction_by_antenna": [ + 0.0942612206375839, + 0.014654991610738254, + 0.08589817533557047, + 0.11550964765100671 + ], + "frame_nonzero_fraction_min": 0.251953125, + "frame_nonzero_fraction_p05": 0.908203125, + "frame_nonzero_fraction_median": 0.9248046875, + "frame_nonzero_fraction_mean": 0.9224189911912751, + "all_zero_frames": 0, + "amplitude_min": 0, + "amplitude_max": 2439.6282503693055, + "sha256": "561f2dd6ac9d08597365d90b08da829bdce99cba003261986a946e41fd0ba46b", + "all_csi_frames_decoded": true, + "validation_scope": "csi_dataset_values_only", + "training_eligible": false, + "promotionAuthorized": false, + "occupancy_inferred": false + }, + "sitting": { + "baseline_reproduced": true, + "elapsed_seconds": 0.06826856500003942, + "inspected_frames": 597, + "decoded_complex_values": 611328, + "chunks_read": 19, + "finite_fraction": 1, + "zero_amplitude_fraction": 0.09315457495812396, + "zero_amplitude_fraction_by_antenna": [ + 0.09258532244556114, + 0.024405883584589616, + 0.09631490787269682, + 0.15931218592964824 + ], + "frame_nonzero_fraction_min": 0.8798828125, + "frame_nonzero_fraction_p05": 0.890625, + "frame_nonzero_fraction_median": 0.90625, + "frame_nonzero_fraction_mean": 0.906845425041876, + "all_zero_frames": 0, + "amplitude_min": 0, + "amplitude_max": 2484.2513962962767, + "sha256": "4db0d302c6b63c9a1239133578de4ea5439a4b50f129c835860bfae873038761", + "all_csi_frames_decoded": true, + "validation_scope": "csi_dataset_values_only", + "training_eligible": false, + "promotionAuthorized": false, + "occupancy_inferred": false + }, + "walking": { + "baseline_reproduced": true, + "elapsed_seconds": 0.06813482600000498, + "inspected_frames": 597, + "decoded_complex_values": 611328, + "chunks_read": 19, + "finite_fraction": 1, + "zero_amplitude_fraction": 0.084005640180067, + "zero_amplitude_fraction_by_antenna": [ + 0.0881687081239531, + 0.013053548994974875, + 0.0963803391959799, + 0.13841996440536014 + ], + "frame_nonzero_fraction_min": 0.8515625, + "frame_nonzero_fraction_p05": 0.8935546875, + "frame_nonzero_fraction_median": 0.916015625, + "frame_nonzero_fraction_mean": 0.915994359819933, + "all_zero_frames": 0, + "amplitude_min": 0, + "amplitude_max": 2767.0001807011145, + "sha256": "7c1d78deb3fd586201a8472507942a5b5c7042286c051b890570cc8d592b20c9", + "all_csi_frames_decoded": true, + "validation_scope": "csi_dataset_values_only", + "training_eligible": false, + "promotionAuthorized": false, + "occupancy_inferred": false + } + }, + "total_elapsed_seconds": 0.22145309600000473, + "process_peak_rss_kib": 56528, + "training_performed": false, + "promotionAuthorized": false +} diff --git a/docs/validation/zenodo-intake-20261013.json b/docs/validation/zenodo-intake-20261013.json new file mode 100644 index 0000000000..697957a551 --- /dev/null +++ b/docs/validation/zenodo-intake-20261013.json @@ -0,0 +1,74 @@ +{ + "evidence": "PUBLIC_SAMPLE_DIAGNOSTIC", + "measured_at": "2026-10-13", + "source": "10.5281/zenodo.18924941", + "samples": { + "empty": { + "sha256": "561f2dd6ac9d08597365d90b08da829bdce99cba003261986a946e41fd0ba46b", + "source_shape_antenna_subcarrier_frame": [ + 4, + 256, + 596 + ], + "inspected_frames": 32, + "finite_fraction": 1, + "zero_amplitude_fraction": 0.071685791015625, + "amplitude_min": 0, + "amplitude_max": 2439.6282503693055, + "full_recording_validated": false, + "promotionAuthorized": false, + "occupancy_inferred": false + }, + "sitting": { + "sha256": "4db0d302c6b63c9a1239133578de4ea5439a4b50f129c835860bfae873038761", + "source_shape_antenna_subcarrier_frame": [ + 4, + 256, + 597 + ], + "inspected_frames": 32, + "finite_fraction": 1, + "zero_amplitude_fraction": 0.093505859375, + "amplitude_min": 0, + "amplitude_max": 2159.374446454343, + "full_recording_validated": false, + "promotionAuthorized": false, + "occupancy_inferred": false + }, + "walking": { + "sha256": "7c1d78deb3fd586201a8472507942a5b5c7042286c051b890570cc8d592b20c9", + "source_shape_antenna_subcarrier_frame": [ + 4, + 256, + 597 + ], + "inspected_frames": 32, + "finite_fraction": 1, + "zero_amplitude_fraction": 0.08697509765625, + "amplitude_min": 0, + "amplitude_max": 2268.596262008734, + "full_recording_validated": false, + "promotionAuthorized": false, + "occupancy_inferred": false + } + }, + "h5py": "3.14.0", + "numpy": "2.4.4", + "elapsed_seconds": 0.029940598999985468, + "source_hashes": { + "zenodo_sample.py": "6ea89c1e1a2d254665d1d44f04270893acabba3e3ce6924745cc7c66efcf7009", + "zenodo_mat.py": "0fb071712ab357f08667248eaaceddb28b8c66e6da28db67231a0f87b281d52d" + }, + "training_performed": false, + "promotionAuthorized": false, + "intake": { + "transferred_bytes": 5670108, + "requests": 22, + "elapsed_seconds": 16.843620125000005, + "snapshot_verified": false, + "full_archive_hash_verified": false, + "license_catalog": "CC-BY-4.0", + "license_readme": "CC-BY-SA-4.0", + "license_conflict": true + } +} diff --git a/docs/validation/zenodo-location-20261013.json b/docs/validation/zenodo-location-20261013.json new file mode 100644 index 0000000000..39a6ca21f6 --- /dev/null +++ b/docs/validation/zenodo-location-20261013.json @@ -0,0 +1,107 @@ +{ + "decision": "ACCEPT", + "evidence": "EXPLORATORY_PUBLIC_CAPTURE_DIAGNOSTIC", + "baseline_commit": "34eb522ec81c944b12988dd7422795a45f41058c", + "protocol_sha256": "6677e4c07b768322c0f46adf92ffff9359f1977d18462b89e38733a795bc6c26", + "old_fields_exact": true, + "prior_full_scan_reproduced": true, + "independent_oracle_exact": true, + "samples": { + "empty": { + "window": { + "inspected_frames": 32, + "frame_nonzero_fraction_min": 0.904296875, + "worst_frame_index_zero_based": 9, + "worst_frame_tie_count": 1, + "worst_frame_nonzero_fraction_by_antenna": [ + 0.875, + 0.99609375, + 0.875, + 0.87109375 + ] + }, + "all": { + "inspected_frames": 596, + "frame_nonzero_fraction_min": 0.251953125, + "worst_frame_index_zero_based": 69, + "worst_frame_tie_count": 1, + "worst_frame_nonzero_fraction_by_antenna": [ + 0.24609375, + 0.26171875, + 0.2578125, + 0.2421875 + ] + } + }, + "sitting": { + "window": { + "inspected_frames": 32, + "frame_nonzero_fraction_min": 0.8857421875, + "worst_frame_index_zero_based": 31, + "worst_frame_tie_count": 1, + "worst_frame_nonzero_fraction_by_antenna": [ + 0.90625, + 0.96484375, + 0.83984375, + 0.83203125 + ] + }, + "all": { + "inspected_frames": 597, + "frame_nonzero_fraction_min": 0.8798828125, + "worst_frame_index_zero_based": 350, + "worst_frame_tie_count": 1, + "worst_frame_nonzero_fraction_by_antenna": [ + 0.875, + 0.953125, + 0.88671875, + 0.8046875 + ] + } + }, + "walking": { + "window": { + "inspected_frames": 32, + "frame_nonzero_fraction_min": 0.876953125, + "worst_frame_index_zero_based": 26, + "worst_frame_tie_count": 1, + "worst_frame_nonzero_fraction_by_antenna": [ + 0.90234375, + 0.9921875, + 0.89453125, + 0.71875 + ] + }, + "all": { + "inspected_frames": 597, + "frame_nonzero_fraction_min": 0.8515625, + "worst_frame_index_zero_based": 396, + "worst_frame_tie_count": 1, + "worst_frame_nonzero_fraction_by_antenna": [ + 0.89453125, + 0.9609375, + 0.86328125, + 0.6875 + ] + } + } + }, + "elapsed_seconds": 1.649929291000035, + "peak_rss_kib": 57700, + "training_performed": false, + "promotionAuthorized": false, + "source_hashes": { + "scripts/csi_training/__init__.py": "eb851eefc626c473c16bc253f724bdec9373f1a86847b897bca5a851508e5544", + "scripts/csi_training/data.py": "8ee3eae346f31256c88b64c45b20541686ce8b5ff74846efedab2275aed7b687", + "scripts/csi_training/zenodo_sample.py": "6ea89c1e1a2d254665d1d44f04270893acabba3e3ce6924745cc7c66efcf7009", + "scripts/csi_training/zenodo_mat.py": "9e62fbe3690bf7ab381f206ec90cffd83b04061127abbc62eddfad95c1b4b678", + "scripts/tests/test_csi_training_zenodo.py": "09aa7663ff62d75e2c691bf31165526747a08c67279d44142c283d1ea3e549f7", + "scripts/csi_training/zenodo_mat_baseline.py": "b2d53f65ac865860baf30c2ac9cb844b6f0be50346db2f9354c88754451c62f2", + "measure.py": "c607a0ee56630f47f8243438db84c6c0efd52c25dbebe38950af70e884c41766" + }, + "data_hashes": { + "empty": "561f2dd6ac9d08597365d90b08da829bdce99cba003261986a946e41fd0ba46b", + "sitting": "4db0d302c6b63c9a1239133578de4ea5439a4b50f129c835860bfae873038761", + "walking": "7c1d78deb3fd586201a8472507942a5b5c7042286c051b890570cc8d592b20c9" + } +} diff --git a/docs/validation/zenodo-location-protocol-20261013.json b/docs/validation/zenodo-location-protocol-20261013.json new file mode 100644 index 0000000000..3eb0d38457 --- /dev/null +++ b/docs/validation/zenodo-location-protocol-20261013.json @@ -0,0 +1,32 @@ +{ + "hypothesis": "Add exact earliest worst frame and its native antenna coverage without changing prior diagnostics or reading additional data", + "baseline_commit": "34eb522ec81c944b12988dd7422795a45f41058c", + "baseline_tree": "3646eb238e732767ad8baa3ad8205cd2e9cefe7b", + "split_policy": "No split or training; three already exposed exploratory captures only; no held final test", + "acceptance": [ + "All focused adversarial tests pass", + "Exact equality of every old report field against baseline on both window and whole-recording modes", + "Exact equality of earliest minimum, ties and antenna coverage to independent per-frame oracle", + "No reads larger than 32 frames; no extra dataset reads in diagnostic", + "At most 30 seconds and 1 GiB virtual memory for verification", + "Promotion/training/occupancy flags remain false" + ], + "rollback": "Restore zenodo_mat.py and its tests from baseline commit; no model/runtime changes", + "budget_seconds": 30, + "virtual_memory_kib": 1048576, + "training_seconds": 0, + "data": { + "empty": "561f2dd6ac9d08597365d90b08da829bdce99cba003261986a946e41fd0ba46b", + "sitting": "4db0d302c6b63c9a1239133578de4ea5439a4b50f129c835860bfae873038761", + "walking": "7c1d78deb3fd586201a8472507942a5b5c7042286c051b890570cc8d592b20c9" + }, + "source_hashes": { + "scripts/csi_training/__init__.py": "eb851eefc626c473c16bc253f724bdec9373f1a86847b897bca5a851508e5544", + "scripts/csi_training/data.py": "8ee3eae346f31256c88b64c45b20541686ce8b5ff74846efedab2275aed7b687", + "scripts/csi_training/zenodo_sample.py": "6ea89c1e1a2d254665d1d44f04270893acabba3e3ce6924745cc7c66efcf7009", + "scripts/csi_training/zenodo_mat.py": "9e62fbe3690bf7ab381f206ec90cffd83b04061127abbc62eddfad95c1b4b678", + "scripts/tests/test_csi_training_zenodo.py": "09aa7663ff62d75e2c691bf31165526747a08c67279d44142c283d1ea3e549f7", + "scripts/csi_training/zenodo_mat_baseline.py": "b2d53f65ac865860baf30c2ac9cb844b6f0be50346db2f9354c88754451c62f2", + "measure.py": "c607a0ee56630f47f8243438db84c6c0efd52c25dbebe38950af70e884c41766" + } +} diff --git a/docs/zenodo-csi-intake.md b/docs/zenodo-csi-intake.md new file mode 100644 index 0000000000..57e7057834 --- /dev/null +++ b/docs/zenodo-csi-intake.md @@ -0,0 +1,185 @@ +# Selective public CSI intake + +## Scope and frozen acceptance + +2026-10-13: implement a research intake and format diagnostic for +[Zenodo 18924941](https://zenodo.org/records/18924941), version 2 published +2026-06-25. Baseline PR2256 head `606de9f64f931b62bb5f0daed457c2d2a602b797`, +tree `7efe6c1ac475598ba428fad9b782dd8eb4ff41b7`, has no intake for this source. +SciPy `whosmat` on the first acquired member rejects MATLAB v7.3; this source +requires HDF5. That is a format gap, not a model performance baseline. + +Before diagnostic payload measurement: freeze the three capture1 beacon members +selected in `zenodo_sample.SESSIONS`, first 32 frames only, native antenna and +subcarrier axes, finite complex values, no external HDF storage or plugins, +maximum 4 MiB compressed member and decompression chunk, 12 MiB aggregate HTTP +range budget. Acceptance is successful bounded intake and correct format/quality +reporting plus all adversarial tests. Reject malformed inputs; do not adjust +signal quality thresholds after looking at results. No accuracy threshold, +training, preprocessing fit, probe, inference optimization, or model selection. +Use existing CPU resources, no training, at most 30 minutes active work. Rollback +removes these two modules, tests, optional requirements and CI install line. + +Source files at diagnostic freeze: + +* `zenodo_sample.py`: `6ea89c1e1a2d254665d1d44f04270893acabba3e3ce6924745cc7c66efcf7009` +* `zenodo_mat.py`: `0fb071712ab357f08667248eaaceddb28b8c66e6da28db67231a0f87b281d52d` + +## Data meaning and exposure + +The source README describes Nexmon complex CSI, with empty, sitting and walking +recordings. The selected configuration flags explicitly identify presence and +movement; activity numbers are not used to infer occupancy semantics. This +diagnostic does not predict occupancy. The author describes collection in 2025 +centered on one office. Do not treat three recordings as independent room, +participant or device generalization. Participant identity, device aliases and +stable physical group identity remain unverified. + +All selected members and the source revision are now exposed for exploratory +development. Never present them as fresh confirmation or reopen a previously +held final test. Any future scientific split requires reviewed source aliases, +room/person/session/device provenance and complete exposure history. There is no +training adapter or automatic connection to the ADR425 advisory preflight. + +The catalog declares CC-BY-4.0 while its README declares CC-BY-SA-4.0. Preserve +both notices and source attribution. The discrepancy remains unresolved; raw +samples stay private, and no dataset or model redistribution is authorized here. +This is not a license compatibility decision. + +## Reproduce + +The downloader uses the standard library and fetches only six explicitly named +members, writing flat filenames into a new private directory. It never extracts +archive paths. The optional diagnostic requires NumPy and pinned h5py: + +```sh +python -m pip install -r scripts/requirements-zenodo-intake.txt +PYTHONPATH=scripts python -m unittest discover -s scripts/tests -p test_csi_training_zenodo.py -v +PYTHONPATH=scripts timeout 150s python -m csi_training.zenodo_sample --output /private/zenodo-new +(ulimit -v 1048576; OPENBLAS_NUM_THREADS=1 PYTHONPATH=scripts timeout 30s \ + python -m csi_training.zenodo_mat /private/zenodo-new/empty.mat \ + --sha256 561f2dd6ac9d08597365d90b08da829bdce99cba003261986a946e41fd0ba46b \ + --all-frames) +``` + +Use a trusted private parent directory. Download failures retain a failed status +and partial files; reruns require a new directory. Local SHA256 pins are observed +content identities, not signatures from the dataset publisher. The server +supplied no ETag during acquisition. Exact HTTPS ranges and ZIP CRC do not verify +the full 16,125,799,937 byte archive or guarantee a single immutable snapshot. +The receipt says both explicitly. Transfer took 22 requests and 5,670,108 bytes +in 16.844 seconds on existing ruOS CPU infrastructure. This is a single intake +observation, not a paired speed benchmark or inference improvement. + +## Security boundary + +HTTP rejects redirects, ignored or incorrect ranges, encoding changes and ETag +changes; enforces byte, request and between-request time budgets. A socket timeout +is not a hard total wall clock deadline; use the executor timeout as the outer +limit. ZIP checks member uniqueness, compression method, encryption, symlinks, +expanded size and CRC. Full archive parsing is bounded by the range read budget. + +The diagnostic reads a bounded regular descriptor with nofollow/nonblock, +checks its SHA256, and opens HDF from those bytes. It rejects soft/external links, +virtual/external storage, unexpected axes/dtypes, oversized chunks and filters +other than builtin deflate/shuffle/Fletcher32 before reading the selected window. +The default inspects only the first 32 frames; `--all-frames` scans every CSI +frame in 32-frame slices. Decoding every CSI value does not validate source +provenance, labels, other HDF objects or signal usefulness. Native HDF5 parsing +is not sandboxed by Python metadata checks. Run hostile inputs in an externally +bounded process. No network or sensor actions occur during diagnostics. No claim +of a complete dependency vulnerability audit is made. + +## Result + +ACCEPT for draft research intake review. All three first windows contain finite +values; zero amplitude fractions are 0.071686, 0.093506 and 0.086975. These are +reported diagnostics, not a quality threshold or evidence of class separation. +Seven offline adversarial tests pass on ruOS in 0.124 seconds. Independent source +review found no high or critical blocker within the private research scope; +outer time/memory limits are required for native parsing. No new paid resources +were used; incremental existing infrastructure cost is unmetered. Full raw +receipts and acquired samples remain private. Sanitized aggregates are in +[the diagnostic receipt](validation/zenodo-intake-20261013.json). + +Next: resolve the license discrepancy and establish stable physical provenance +before any training or independent evaluation. Main risk is mistaking these +exposed, correlated office recordings for independent confirmation. + +## Frozen whole-recording diagnostic followup + +Frozen before decoding beyond the first 32 frames. Baseline is draft PR 2256 +commit `47589e00f636971ec7e0ca2483d4809f9ebcf775`, whose diagnostic reads only the +first 32 frames. Candidate source SHA256 is +`b2d53f65ac865860baf30c2ac9cb844b6f0be50346db2f9354c88754451c62f2`; +synthetic evaluator SHA256 is +`3e0e03eac570f93eb150dd8d8e51df23a68e09c43732c0608408a129d67064cb`. +The three already exposed MAT identities remain `561f2dd6ac9d...`, +`4db0d302c6b6...`, and `7c1d78deb3fd...`. No new split exists and no labels are +read. This is a capture integrity and coverage diagnostic, not a model +experiment. + +The single candidate scans every native frame in fixed slices of at most 32, +reports finite complex count, zero amplitude by antenna, per-frame nonzero +coverage and amplitude range, and never emits an occupancy decision. Acceptance +requires exact input hashes, successful complete scans for all three recordings, +all finite decoded values, no full decoded CSI array, synthetic tail corruption +detection, all focused tests, at most 30 seconds and 1 GiB virtual memory per +sample, and `training_eligible=false` plus `promotionAuthorized=false`. No +quality threshold is selected after seeing the data. Full-scan measurements are +descriptive and cannot authorize training. Rollback restores the first-window +diagnostic and its prior tests. + +The frozen candidate was accepted for draft review. It reproduced every prior +first-window aggregate, then decoded all 596/597/597 frames in 19 slices each: +1,832,960 complex values were finite, with no all-zero frame. Whole-recording +zero amplitude fractions were 0.077581 empty, 0.093155 sitting and 0.084006 +walking. Minimum per-frame nonzero coverage was 0.251953, 0.879883 and 0.851562, +respectively. The low empty-sample minimum is a capture-quality observation to +investigate, not evidence for or against occupancy. The three scans plus baseline +reproduction took 0.221 seconds and process peak RSS was 56,528 KiB under the +frozen outer limits on existing ruOS CPU infrastructure. This was one run, not a +latency optimization benchmark. Eight focused adversarial tests pass in 0.151 +seconds after the review fixes. Sanitized aggregates are in +[`validation/zenodo-full-quality-20261013.json`](validation/zenodo-full-quality-20261013.json); +raw captures and the detailed private receipt remain untracked. + +## Locate the worst coverage frame + +The diagnostic now reports `worst_frame_index_zero_based`, +`worst_frame_tie_count`, and `worst_frame_nonzero_fraction_by_antenna`. +Indices refer to the native CSI frame axis, not timestamps. Ties select the +earliest frame; the antenna vector belongs to that frame and retains native +antenna order. Both minima and tie counts cover only `inspected_frames`: +the default still checks the first 32 frames, while `--all-frames` checks all. +No capture-quality threshold, occupancy classification or training gate is added. + +Frozen baseline: `34eb522ec81c944b12988dd7422795a45f41058c`. +Acceptance required exact preservation of every old report field in both modes, +agreement with an independent frame-by-frame nonzero-count oracle, adversarial +tests for chunk-boundary ties, antenna order and unread tails, and the existing +30-second/1-GiB verification limits. Source, evaluator and input identities are +in [the frozen protocol](validation/zenodo-location-protocol-20261013.json). +The same three already exposed recordings were used; there is no new split, +training, model selection or final-test access. The implementation retains +32-frame reads and at most one eight-element antenna vector, with no extra +dataset read. Existing file/hash/HDF validation is unchanged. + +ACCEPT for draft diagnostic review: ten focused tests pass. Every old field and +the prior whole-scan receipt reproduced exactly. The independent oracle agrees +in both modes for all three samples. The empty-sample minimum is at frame 69 +(zero-based), with native antenna nonzero fractions 0.246094, 0.261719, 0.257812 +and 0.242188. This localizes the previously reported drop across all four +antennas; its physical cause remains unknown. The sitting and walking minima +are at frames 350 and 396. These filenames are source labels, not predictions. +Verification took 1.650 seconds with peak RSS 57,700 KiB on existing ruOS CPU; +this includes baseline, candidate and oracle work and is not a speed comparison. +See [the aggregate receipt](validation/zenodo-location-20261013.json). + +Reproduce using the diagnostic and test commands above. Security review confirms +no new input, network, file or model authority; output is bounded to three extra +fields and contains no raw signal values. Raw receipts remain private. Existing +infrastructure cost is unmetered; no paid resource or training was started. +Rollback restores the diagnostic and test from the baseline commit. +Next: correlate frame 69 with acquisition metadata before assigning a cause; +license and physical provenance still block model use. diff --git a/scripts/active-kge/.gitignore b/scripts/active-kge/.gitignore new file mode 100644 index 0000000000..5d6c3648a7 --- /dev/null +++ b/scripts/active-kge/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +runs/ diff --git a/scripts/active-kge/README.md b/scripts/active-kge/README.md new file mode 100644 index 0000000000..4a7efd59a9 --- /dev/null +++ b/scripts/active-kge/README.md @@ -0,0 +1,44 @@ +# Synthetic active-sensor KGE experiment + +This uses the **published `@ruvector/kge` 0.2.0**, pinned by exact version and npm integrity in `package-lock.json`. It uses the package's real native/WASM loader; there is no mock scorer or substitute fallback. The local Linux x64 run used the bundled native engine (internal version `2.4.0`, distinct from npm version `0.2.0`). Package license: MIT. Node 18 or later is required. No hardware requests, network calls during inference, model promotion, or occupancy claims are implemented. + +## Reproduce + +```sh +cd scripts/active-kge +npm ci --ignore-scripts --no-audit --no-fund +npm test +mkdir -p runs +node cli.mjs --train runs/seed42 --context context_0_8 > runs/ranking.json +node cli.mjs --artifact runs/seed42 --context context_1_8 --k 3 > runs/ranking-next.json +``` + +`--train` requires a new directory; it will not overwrite any experiment. Model and receipt are written with exclusive creation and made read-only after success. Failed training writes a separate `failed.json`. `--artifact` verifies the model SHA-256 and bounded fixture contract before loading. The loader also checks the exact authored entity/relation vocabulary, indexed training triples, split tags, model configuration and training receipt; rehashing an altered graph cannot satisfy that contract. Source hashes are captured before training and checked again after measurement. These hashes detect accidental changes, not adversarial replacement of both model and receipt; they are not signatures. + +Use `node cli.mjs` for clean stdout, or `npm --silent run rank -- ...`. Successful stdout is exactly one JSON object: + +```json +{"schemaVersion":1,"evidence":"SYNTHETIC","candidates":[{"sensorId":"wifi","score":2.9},{"sensorId":"door","score":1.3},{"sensorId":"radar","score":1.2}]} +``` + +Those numbers illustrate the shape, not a measured receipt. The allowlist is exactly `wifi`, `radar`, `door`; top K is an integer from 1 to 3. Scores are uncalibrated link-ranking values, **not probabilities**. The generated file is the input for the Rust probe's `--active-loop --ranking FILE`; it does not itself trigger any sensor. + +## Frozen experiment and evidence limits + +`fixture()` authors three abstract feature groups with ten contexts each. All thirty `observedFeature` links are training side information. Eight contexts per group have a training `relevantSensor` link (24 total); the other two target links per group are held out (six total). Held-out target links never enter `addTriples`, training, optimizer selection, or the permutation control. All 54 admitted facts carry explicit `train` tags. This is **transductive link holdout**, not unseen-context or unseen-room generalization: the held-out context entities and their feature links are already known. + +A single frozen HolE configuration uses dimensions 32, seed 42, 100 epochs and learning rate 0.05. No hyperparameter search or automatic optimization/promotion is called. Exact `predict` scores all 36 entities, then filters to the three sensor IDs. HolE relation composition is not used or claimed. + +The receipt reports MRR and Hits@1 against (1) a training-frequency baseline with fixed wifi/radar/door tie breaking and (2) another trained KGE whose 24 relevance labels are shuffled deterministically while preserving their class counts. It also records the package/backend, model and control hashes, native/WASM artifact hash, fixture hash, source/lock hashes and environment. The backend seed is passed at model creation and training; tests confirm identical model hashes on repeated runs in the tested environment, not across all platforms. + +The allocation-reducing cached-context candidate remains experimental: its first fixed paired comparison failed the round-one p95 gate, so the original validation path remains the default and no speed improvement is claimed. Later receipt timings do not automatically promote it. + +Latency measures the complete `rank` call (validation, statistics, exact prediction, filtering and sorting), with 100 warmups followed by 1,000 calls per runtime in each of three paired rounds, rotating the six held-out contexts and alternating runtime order on an identical model. Both median and p95 must be lower in every round to pass the predeclared comparison gate. Loading and training are excluded. Throughput is derived from mean query latency; it is not sensor throughput. Timings are machine-specific. + +The synthetic groups encode the task by construction. Strong results demonstrate that this package can recover those authored links, not that WiFi, radar or door sensors have measured relevance in a real room. The six-query evaluation is tiny, and its uncertainty must not be generalized to occupancy sensing. No real signals, participant data, empty-room captures or independent operational labels were used. + +## Primary package sources + +- Published metadata/version: https://registry.npmjs.org/@ruvector%2fkge/0.2.0 (verified 2026-10-13; published 2026-09-28). +- Package repository pin reported by npm: https://github.com/ruvnet/ruvector/tree/e7b0bc37e4042382a7d981ce03e03aa1e5db35a6/npm/packages/kge +- The installed release's `dist/client.d.ts`, `dist/types.d.ts`, `dist/binding.js` and `index.js` were inspected before implementation. Used API: `createKge`, `addTriples`, `train`, `predict`, `stats`, `save`, `loadKge`. diff --git a/scripts/active-kge/cli.mjs b/scripts/active-kge/cli.mjs new file mode 100644 index 0000000000..b59bc6edf8 --- /dev/null +++ b/scripts/active-kge/cli.mjs @@ -0,0 +1,13 @@ +import {trainExperiment,loadArtifact,rank} from './ranker.mjs'; +try { + const args=process.argv.slice(2), opts={}; + if(args.length%2 || args.length>8) throw new Error('Expected --train DIR or --artifact DIR, --context ID, optional --k 1..3'); + for(let i=0;i4096) throw new Error('Invalid arguments'); + opts[args[i]]=args[i+1]; + } + if(Boolean(opts['--train'])===Boolean(opts['--artifact'])) throw new Error('Select exactly one train or artifact mode'); + const model=opts['--train']?(await trainExperiment(opts['--train'])).model:loadArtifact(opts['--artifact']); + const result=rank(model,opts['--context']??'context_0_8',opts['--k']===undefined?3:Number(opts['--k'])); + process.stdout.write(JSON.stringify(result)+'\n'); +} catch(error) {process.stderr.write(String(error.message)+'\n');process.exitCode=1;} diff --git a/scripts/active-kge/package-lock.json b/scripts/active-kge/package-lock.json new file mode 100644 index 0000000000..41ceac1b44 --- /dev/null +++ b/scripts/active-kge/package-lock.json @@ -0,0 +1,30 @@ +{ + "name": "ruview-active-kge-experiment", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ruview-active-kge-experiment", + "version": "0.0.0", + "dependencies": { + "@ruvector/kge": "0.2.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ruvector/kge": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@ruvector/kge/-/kge-0.2.0.tgz", + "integrity": "sha512-WwfysfBa3/3HVmSLrwVvso+TuFp8rOubUJZIsoOUaWbLidzYvw35zuQthKhxSzH0wJE4CybJYVwOkCeyOZMCMA==", + "license": "MIT", + "bin": { + "kge": "bin/cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + } + } +} diff --git a/scripts/active-kge/package.json b/scripts/active-kge/package.json new file mode 100644 index 0000000000..d6cde7716f --- /dev/null +++ b/scripts/active-kge/package.json @@ -0,0 +1,6 @@ +{ + "name": "ruview-active-kge-experiment", "version": "0.0.0", "private": true, + "type": "module", "engines": {"node": ">=18"}, + "scripts": {"test": "node --test test.mjs", "rank": "node cli.mjs"}, + "dependencies": {"@ruvector/kge": "0.2.0"} +} diff --git a/scripts/active-kge/ranker.mjs b/scripts/active-kge/ranker.mjs new file mode 100644 index 0000000000..0bc03edf30 --- /dev/null +++ b/scripts/active-kge/ranker.mjs @@ -0,0 +1,176 @@ +/** Authored synthetic link-ranking experiment. No hardware actions or probabilities. */ +import {createKge, loadKge} from '@ruvector/kge'; +import {createHash} from 'node:crypto'; +import {readFileSync, writeFileSync, mkdirSync, openSync, closeSync, fstatSync, readSync, chmodSync, constants} from 'node:fs'; +import {join, dirname} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {createRequire} from 'node:module'; +import {performance} from 'node:perf_hooks'; +const require = createRequire(import.meta.url); +const root = dirname(fileURLToPath(import.meta.url)); +export const SENSORS = Object.freeze(['wifi', 'radar', 'door']); +export const CONFIG = Object.freeze({scorer:'hole', dims:32, seed:42}); +export const TRAIN = Object.freeze({epochs:100, lr:0.05, seed:42}); +export const hash = bytes => createHash('sha256').update(bytes).digest('hex'); +export function fixture() { + const train = [], heldout = [], contexts = []; + for (let type=0; type<3; type++) { + for (let i=0; i<10; i++) { + const s = `context_${type}_${i}`; + contexts.push(s); + train.push({s,r:'observedFeature',o:`feature_${type}`,split:'train'}); + const triple = {s,r:'relevantSensor',o:SENSORS[type],split:'train'}; + if(i<8) train.push(triple); else heldout.push({s,r:triple.r,o:triple.o}); + } + } + // Context feature membership is allowed transductive side information; + // held-out relevantSensor target links never enter either model. + return {train, heldout, contexts}; +} +// Private, constructed once; no caller receives mutable cache handles. +const contextSet = new Set(fixture().contexts); +const expectedGraph = (() => { + const entities=[], relations=[], triples=[]; + const intern=(items,label)=>{let id=items.indexOf(label);if(id<0){id=items.length;items.push(label);}return id;}; + for(const t of fixture().train) triples.push({s:intern(entities,t.s),r:intern(relations,t.r),o:intern(entities,t.o)}); + return {entities,relations,triples,splits:triples.map(()=> 'train')}; +})(); +const sourceHashes = () => Object.fromEntries(['ranker.mjs','cli.mjs','package-lock.json'].map(n=>[n,hash(readFileSync(join(root,n)))])); +const same = (a,b) => JSON.stringify(a)===JSON.stringify(b); +export function permutedTraining(triples) { + const out=triples.map(t=>({...t})); + const targets=out.filter(t=>t.r==='relevantSensor'); + const labels=targets.map(t=>t.o); + let state=314159; + for(let i=labels.length-1;i>0;i--) { + state=(Math.imul(state,1664525)+1013904223)>>>0; + const j=state%(i+1); [labels[i],labels[j]]=[labels[j],labels[i]]; + } + targets.forEach((t,i)=>{t.o=labels[i];}); + return out; +} +export function referenceRank(model, context, k=3) { + return score(model,context,k,fixture().contexts.includes(context)); +} +export function cachedRank(model, context, k=3) { + return score(model,context,k,contextSet.has(context)); +} +// The first frozen paired run failed its p95 gate; preserve the reference default. +export const rank = referenceRank; +function score(model, context, k, admitted) { + if(!admitted || !Number.isInteger(k) || k<1 || k>3) + throw new Error('Invalid bounded context or top K'); + const stats=model.stats(); + if(!Number.isInteger(stats.entities) || stats.entities<3 || stats.entities>128) + throw new Error('Invalid bounded entity population'); + const result=model.predict({s:context,r:'relevantSensor',k:stats.entities,useIndex:false}); + if(result.exact!==true || result.ann!==false) throw new Error('Exhaustive ranking required'); + const candidates=result.candidates.filter(c=>SENSORS.includes(c.entity)).map(c=>({sensorId:c.entity,score:c.score})); + if(candidates.length!==3 || new Set(candidates.map(c=>c.sensorId)).size!==3 || candidates.some(c=>!Number.isFinite(c.score))) + throw new Error('Invalid sensor scores'); + candidates.sort((a,b)=>b.score-a.score || SENSORS.indexOf(a.sensorId)-SENSORS.indexOf(b.sensorId)); + return {schemaVersion:1,evidence:'SYNTHETIC',candidates:candidates.slice(0,k)}; +} +function evaluate(predict, heldout) { + const ranks=heldout.map(t=>predict(t.s).findIndex(c=>c.sensorId===t.o)+1); + if(ranks.some(r=>r<1)) throw new Error('Missing held-out candidate'); + return {queries:ranks.length,mrr:ranks.reduce((a,r)=>a+1/r,0)/ranks.length, + hitsAt1:ranks.filter(r=>r===1).length/ranks.length,ranks}; +} +export function pairedTiming(model, contexts) { + const rounds=[]; + for(let round=0;round<3;round++) { + for(let i=0;i<100;i++) {const c=contexts[(i+round)%contexts.length];cachedRank(model,c);referenceRank(model,c);} + const ms={cached:[],reference:[]}; + for(let i=0;i<1000;i++) { + const c=contexts[(i+round)%contexts.length]; + const calls=[['cached',cachedRank],['reference',referenceRank]]; + if((i+round)%2) calls.reverse(); + for(const [name,fn] of calls) {const t=performance.now();fn(model,c);ms[name].push(performance.now()-t);} + } + const summary={round:round+1,callsPerRuntime:1000,warmupsPerRuntime:100}; + for(const [name,values] of Object.entries(ms)) { + values.sort((a,b)=>a-b);summary[name]={minMs:values[0],p50Ms:values[499],p95Ms:values[949], + derivedQueriesPerSecond:1000/(values.reduce((a,b)=>a+b,0)/values.length)}; + } + summary.bothPercentilesLower=summary.cached.p50Msr.bothPercentilesLower), + decision:'Reference default retained after first frozen paired campaign failed round-one p95; later timings do not promote the candidate', + gate:'Both p50 and p95 strictly lower in every round; fixed before measurement', + paired:true,alternatingOrder:true,identicalModelAndInputs:true}; +} +function writeNew(path,value) {writeFileSync(path,JSON.stringify(value,null,2)+'\n',{flag:'wx',mode:0o600});} +export function readBounded(path,limit) { + const fd=openSync(path,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK); + try {const st=fstatSync(fd);if(!st.isFile()||st.size<1||st.size>limit) throw new Error('Expected bounded regular artifact'); + const buffer=Buffer.alloc(limit+1);let size=0; + while(sizelimit) throw new Error('Oversized artifact');return buffer.subarray(0,size); + } finally {closeSync(fd);} +} +export async function trainExperiment(output) { + mkdirSync(output,{recursive:false}); + try { + const beforeSources=sourceHashes(); + const data=fixture(); + const targetKeys=new Set(data.heldout.map(t=>JSON.stringify([t.s,t.r,t.o]))); + if(data.train.some(t=>targetKeys.has(JSON.stringify([t.s,t.r,t.o])))) throw new Error('Held-out target leakage'); + const models=[]; + const trainingReports=[]; + for(const triples of [data.train,permutedTraining(data.train)]) { + const model=createKge(CONFIG); + model.addTriples(triples); + trainingReports.push(await model.train(TRAIN)); + models.push(model); + } + const [model,control]=models; + const modelBytes=model.save(); + writeFileSync(join(output,'model.json'),modelBytes,{flag:'wx',mode:0o600}); + const counts=Object.fromEntries(SENSORS.map(sensor=>[sensor,data.train.filter(t=>t.r==='relevantSensor'&&t.o===sensor).length])); + const frequency=SENSORS.map(sensorId=>({sensorId,score:counts[sensorId]})).sort((a,b)=>b.score-a.score || SENSORS.indexOf(a.sensorId)-SENSORS.indexOf(b.sensorId)); + const quality={kge:evaluate(c=>rank(model,c).candidates,data.heldout), + frequency:evaluate(()=>frequency,data.heldout),permutedGraph:evaluate(c=>rank(control,c).candidates,data.heldout)}; + const packageRoot=dirname(require.resolve('@ruvector/kge/package.json')); + const pkg=JSON.parse(readFileSync(join(packageRoot,'package.json'))); + if(pkg.version!=='0.2.0') throw new Error('Unexpected package version'); + const nativeNames={'linux-x64':'kge.linux-x64-gnu.node','linux-arm64':'kge.linux-arm64-gnu.node','darwin-x64':'kge.darwin-x64.node','darwin-arm64':'kge.darwin-arm64.node','win32-x64':'kge.win32-x64-msvc.node'}; + const backendArtifact=model.backend==='wasm'?'wasm/ruvector_kge_wasm_bg.wasm':`native/${nativeNames[process.platform+'-'+process.arch]}`; + const receipt={schemaVersion:1,status:'complete',evidence:'SYNTHETIC',promotionAuthorized:false, + package:{name:pkg.name,version:pkg.version,engineVersion:model.version,backend:model.backend,backendArtifact,backendArtifactSha256:hash(readFileSync(join(packageRoot,backendArtifact)))}, + config:CONFIG,training:TRAIN,trainingReports,stats:model.stats(),fixtureSha256:hash(JSON.stringify(data)), + modelSha256:hash(modelBytes),controlModelSha256:hash(control.save()), + sourceSha256:beforeSources, + quality,latency:{evidence:'MEASURED',scope:'Full rank including input validation and exact entity scoring; excludes load and training',...pairedTiming(model,data.heldout.map(t=>t.s))}, + environment:{node:process.version,platform:process.platform,arch:process.arch}, + limitations:['Authored synthetic transductive link split; context feature membership is trained, target links are not.', + 'Synthetic feature groups encode the authored task. This is not independent real occupancy or sensor utility evidence.', + 'One frozen configuration and seed; no hyperparameter search, relation composition, model promotion or sensor action.', + 'Scores are uncalibrated ranking scores, not probabilities. Equal-frequency baseline uses fixed wifi/radar/door tie order.']}; + if(!same(beforeSources,sourceHashes())) throw new Error('Sources changed during experiment'); + writeNew(join(output,'receipt.json'),receipt); + chmodSync(join(output,'receipt.json'),0o400); + chmodSync(join(output,'model.json'),0o400); + return {model,receipt}; + } catch(error) {writeNew(join(output,'failed.json'),{status:'failed',promotionAuthorized:false,error:String(error.message)});throw error;} +} +export function loadArtifact(directory) { + const receipt=JSON.parse(readBounded(join(directory,'receipt.json'),256*1024)); + const bytes=readBounded(join(directory,'model.json'),2*1024*1024); + if(receipt.status!=='complete'||receipt.evidence!=='SYNTHETIC'||receipt.promotionAuthorized!==false||receipt.package?.version!=='0.2.0'||receipt.modelSha256!==hash(bytes)||receipt.fixtureSha256!==hash(JSON.stringify(fixture()))) + throw new Error('Invalid synthetic artifact provenance'); + const envelope=JSON.parse(bytes); + const m=envelope.model; + if(!same(receipt.config,CONFIG)||!same(receipt.training,TRAIN)||!Array.isArray(receipt.trainingReports)||receipt.trainingReports.length!==2 + || receipt.trainingReports.some(t=>t.entities!==36||t.relations!==2||t.triples!==54||t.epochs!==100||t.epoch!==99||!Number.isFinite(t.loss)||!Number.isFinite(t.n3Penalty))) + throw new Error('Invalid training receipt'); + if(m?.version!==1 || !same(m?.config,CONFIG) || !same(m?.entities?.labels,expectedGraph.entities) + || !same(m?.relations?.labels,expectedGraph.relations) || !same(m?.triples,expectedGraph.triples) + || !same(m?.splits,expectedGraph.splits) || m?.tables?.dims!==32 + || !Array.isArray(m?.tables?.entities)||m.tables.entities.length!==1152 + || !Array.isArray(m?.tables?.relations)||m.tables.relations.length!==64 + || [...m.tables.entities,...m.tables.relations].some(x=>!Number.isFinite(x))) + throw new Error('Invalid authored fixture model envelope'); + return loadKge(bytes.toString()); +} diff --git a/scripts/active-kge/test.mjs b/scripts/active-kge/test.mjs new file mode 100644 index 0000000000..79c1e16403 --- /dev/null +++ b/scripts/active-kge/test.mjs @@ -0,0 +1,66 @@ +import {test, before, after} from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync,rmSync,readFileSync,writeFileSync,mkdirSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {spawnSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +const cliPath=fileURLToPath(new URL('./cli.mjs',import.meta.url)); +import {fixture,permutedTraining,trainExperiment,loadArtifact,rank,cachedRank,SENSORS,hash} from './ranker.mjs'; +let temp, artifact, trained; +before(async()=>{temp=mkdtempSync(join(tmpdir(),'active-kge-test-'));artifact=join(temp,'artifact');trained=await trainExperiment(artifact);}); +after(()=>rmSync(temp,{recursive:true,force:true})); +test('held-out target facts absent, known context side facts explicit, control preserves marginals',()=>{ + const f=fixture();assert.equal(f.train.length,54);assert.equal(f.heldout.length,6); + for(const t of f.heldout) {assert(!f.train.some(x=>x.s===t.s&&x.r===t.r));assert(f.train.some(x=>x.s===t.s&&x.r==='observedFeature'));} + const p=permutedTraining(f.train);assert.notDeepEqual(p,f.train); + for(const sensor of SENSORS) assert.equal(p.filter(t=>t.r==='relevantSensor'&&t.o===sensor).length,8); +}); +test('actual backend, finite allowlisted ranking, immutable artifact receipt and deterministic seed',async()=>{ + assert(['native','wasm'].includes(trained.model.backend));assert.equal(trained.receipt.package.version,'0.2.0'); + const loaded=loadArtifact(artifact);const a=rank(trained.model,'context_0_8'); + assert.deepEqual(rank(loaded,'context_0_8'),a); + for(const context of fixture().contexts) for(const k of [1,2,3]) assert.deepEqual(cachedRank(loaded,context,k),rank(loaded,context,k));assert.deepEqual(Object.keys(a),['schemaVersion','evidence','candidates']); + assert.equal(a.candidates.length,3);for(const c of a.candidates) {assert(SENSORS.includes(c.sensorId));assert(Number.isFinite(c.score));} + assert.equal(trained.receipt.quality.kge.queries,6);assert.equal(trained.receipt.quality.frequency.hitsAt1,1/3); + assert.equal(trained.receipt.latency.rounds.length,3);assert(trained.receipt.latency.rounds.every(r=>r.callsPerRuntime===1000)); + assert.equal(trained.receipt.promotionAuthorized,false); + const second=await trainExperiment(join(temp,'repeat')); + assert.equal(second.receipt.modelSha256,trained.receipt.modelSha256); + await assert.rejects(trainExperiment(artifact),/exist/i); +}); +test('CLI stdout has only contract JSON; bad input rejected',()=>{ + const run=spawnSync(process.execPath,[cliPath,'--artifact',artifact,'--context','context_1_8','--k','2'],{encoding:'utf8'}); + assert.equal(run.status,0,run.stderr);assert.equal(run.stderr,'');const out=JSON.parse(run.stdout);assert.equal(out.candidates.length,2); + for(const [context,k] of [['unknown',3],['context_0_8',0],['context_0_8',4],['context_0_8',NaN]]) assert.throws(()=>rank(trained.model,context,k),/Invalid/); + const bad=spawnSync(process.execPath,[cliPath,'--artifact',artifact,'--k','100000'],{encoding:'utf8'});assert.equal(bad.status,1);assert.equal(bad.stdout,''); +}); +test('corrupt artifact rejected before backend load',()=>{ + const dir=join(temp,'corrupt');mkdirSync(dir);writeFileSync(join(dir,'receipt.json'),readFileSync(join(artifact,'receipt.json'))); + writeFileSync(join(dir,'model.json'),readFileSync(join(artifact,'model.json'))+' '); + assert.throws(()=>loadArtifact(dir),/provenance/); +}); + +test('rehashing cannot admit altered graph vocabulary, indices, splits or missing training report',()=>{ + const mutations=[ + m=>{m.triples[0].s=999;}, + m=>{m.entities.labels[0]='untrusted_context';}, + m=>{m.relations.labels[0]='otherRelation';}, + m=>{m.splits[0]='test';}, + m=>{delete m.splits;}, + m=>{m.config.seed=43;}, + m=>{const held=fixture().heldout[0];m.triples[0]={s:m.entities.labels.indexOf(held.s),r:m.relations.labels.indexOf(held.r),o:m.entities.labels.indexOf(held.o)};}, + ]; + mutations.forEach((mutate,i)=>{ + const dir=join(temp,'altered-'+i);mkdirSync(dir); + const envelope=JSON.parse(readFileSync(join(artifact,'model.json')));mutate(envelope.model); + const bytes=JSON.stringify(envelope),receipt=JSON.parse(readFileSync(join(artifact,'receipt.json')));receipt.modelSha256=hash(bytes); + writeFileSync(join(dir,'model.json'),bytes);writeFileSync(join(dir,'receipt.json'),JSON.stringify(receipt)); + assert.throws(()=>loadArtifact(dir),/authored fixture/); + }); + const dir=join(temp,'missing-training');mkdirSync(dir); + const receipt=JSON.parse(readFileSync(join(artifact,'receipt.json')));delete receipt.trainingReports; + writeFileSync(join(dir,'model.json'),readFileSync(join(artifact,'model.json'))); + writeFileSync(join(dir,'receipt.json'),JSON.stringify(receipt)); + assert.throws(()=>loadArtifact(dir),/training receipt/); +}); diff --git a/scripts/benchmark-csi-intake.py b/scripts/benchmark-csi-intake.py new file mode 100644 index 0000000000..ca559e4ebf --- /dev/null +++ b/scripts/benchmark-csi-intake.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Synthetic intake benchmark. --baseline must be the exact pinned trusted source.""" +import argparse +import hashlib +import json +from pathlib import Path +import platform +import sys +import time +import tracemalloc +import types + +sys.path.insert(0, str(Path(__file__).resolve().parent / 'tests')) +from test_csi_training_intake_races import IntakeRaceTests +from csi_training import data + +BASELINE_SHA = 'e50e5305b8678e7dc9039d99faa8cc71c20132af786bdd1b3538def73ef49055' +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--baseline', required=True) +args = parser.parse_args() +with data._open_regular(args.baseline, 1024 * 1024) as (stream, _): + source = stream.read(1024 * 1024 + 1) +if hashlib.sha256(source).hexdigest() != BASELINE_SHA: + raise SystemExit('Expected pinned baseline source; arbitrary Python is not admitted') +baseline = types.ModuleType('intake_baseline');sys.modules[baseline.__name__] = baseline +exec(compile(source, args.baseline, 'exec'), baseline.__dict__) +case = IntakeRaceTests();case.setUp() +try: + old = baseline.load_manifest(case.manifest, allow_synthetic=True) + new = data.load_manifest(case.manifest, allow_synthetic=True) + assert old.manifest_sha256 == new.manifest_sha256 and old.inventory == new.inventory + for split in data.SPLITS: + a, b = old.partitions[split], new.partitions[split] + assert (a.features == b.features).all() and (a.timestamps == b.timestamps).all() + assert a.node_ids == b.node_ids and a.groups == b.groups and a.session_ids == b.session_ids + modules = {'baseline': baseline, 'candidate': data} + timings = {key: [] for key in modules} + for _ in range(20): + for module in modules.values(): module.load_manifest(case.manifest, allow_synthetic=True) + for i in range(1000): + for key in (list(modules) if i % 2 else list(reversed(modules))): + before = time.perf_counter_ns() + modules[key].load_manifest(case.manifest, allow_synthetic=True) + timings[key].append((time.perf_counter_ns() - before) / 1e6) + peaks = {} + for key, module in modules.items(): + tracemalloc.start();module.load_manifest(case.manifest, allow_synthetic=True) + peaks[key] = tracemalloc.get_traced_memory()[1];tracemalloc.stop() + print(json.dumps({'evidence': 'SYNTHETIC', 'baseline_commit': '721a094f09b97422e32555fc8444cdaa17c5fd3a', + 'baseline_source_sha256': BASELINE_SHA, + 'candidate_source_sha256': hashlib.sha256(Path(data.__file__).read_bytes()).hexdigest(), + 'valid_partition_parity': True, 'samples_per_arm': 1000, 'fixture_records': 9, + 'fixture_files': 3, 'warmups_per_arm': 20, + 'timing': {k: {'p50_ms': sorted(v)[500], 'p95_ms': sorted(v)[950]} for k,v in timings.items()}, + 'python_tracemalloc_peak_bytes': peaks, 'python': platform.python_version(), + 'scope': 'Paired alternating full intake from warm filesystem cache. Shared CPU; tracemalloc excludes native allocations. No inference or accuracy claim.', + 'promotionAuthorized': False}, indent=2)) +finally: + case.doCleanups() diff --git a/scripts/csi_training/PREDICTIVE.md b/scripts/csi_training/PREDICTIVE.md new file mode 100644 index 0000000000..f7c468847b --- /dev/null +++ b/scripts/csi_training/PREDICTIVE.md @@ -0,0 +1,82 @@ +# Experimental masked latent CSI prediction + +This small CPU harness is inspired by the masked latent prediction idea associated +with CSI-JEPA and AM-FM. It is **not a reproduction** of either system. It makes +no occupancy, pose, activity-accuracy, robustness or SOTA claim. No downloads, +remote weights, publishing or automatic promotion occur. + +```sh +python scripts/pretrain-csi-predictive.py --synthetic --output /tmp/csi-predictive-run --epochs 5 --max-seconds 120 +# Optional already-authorized local ESP-Fi prepared manifest: +python scripts/pretrain-csi-predictive.py --manifest /path/prepared/manifest.json --output /tmp/csi-predictive-public --epochs 5 --max-seconds 120 +python -m unittest discover -s scripts/tests -p test_csi_training_predictive.py +``` + +Dependencies are the existing Torch, NumPy and safetensors training environment. +The existing eight-summary-feature dataset and encoder are incompatible with this +experiment's explicit amplitude axes and are not silently converted. + +## Fixed model and evidence boundary + +Input is nonnegative amplitude `[recording, 52 carrier indices, 190 pooled sample +indices]`. A patch spans four carriers by 19 samples, giving 130 tokens. Carrier +order and sample index do not imply calibrated physical coordinates or time. +A 32-dimensional, one-layer, four-head transformer encodes patches. The online +model predicts an EMA teacher's stopped latent targets on 52 masked patches per +recording. Target patch values are replaced by a mask token before context +attention. Normalization uses per-carrier log-amplitude statistics fitted only +on the training split, never per-recording normalization that could reveal +masked values. An additional sample-variance penalty discourages collapse. + +This is a baseline prototype: the variance penalty is not a guarantee against +collapse. Reports include sample standard deviation, effective rank of pooled +recording embeddings, and a warning. Low latent error alone does not establish +a useful representation. + +Public intake reuses the existing hash/provenance-verified ESP-Fi adapter's +train-only partition path. Original validation and test array payloads are +never decoded. Inner training uses room 1 / participants 1–4; inner heldout uses +room 2 / participants 5–6. Other original training recordings are excluded. +These rows have previously participated in earlier training-fold research, so +this is **adaptive research**, not a new independent confirmation population. +No activity label becomes an occupancy label. Within this run heldout signals +never fit normalization, gradients, EMA state, checkpoints or hyperparameters. +The synthetic mode is explicitly labeled SYNTHETIC. + +Epoch count and all hyperparameters are fixed before evaluation. After the final +step, heldout latent MSE is measured for the trained predictor, its untrained +initial copy a global constant and a per-token positional constant estimated only on training +teacher targets. All four +use the same final teacher targets and same evaluation masks. Teacher coordinates +move during training, so this comparison is a representation diagnostic, not +an external task metric. No success threshold or promotion is inferred from it. + +The per-token baseline controls positional priors: beating a global constant +alone does not establish learned signal structure. The mask-conditioned variance +penalty can also reflect mask noise; unmasked heldout diagnostics are reported. + +Frozen encoder inference is timed in three rounds of 100 rotating batch-one +calls after ten warmups, including input checks, log normalization, encoding and +pooling, excluding capture and file IO. These CPU timings are not occupancy +accuracy evidence. + +The time budget is checked between optimization/evaluation steps; it cannot +preempt a running Torch operator. Incomplete runs have failed status. CPU seed42, +deterministic Torch operations and fixed masking seeds are recorded in the +contract. Exact replay still depends on the software/platform environment. + +## Frozen downstream interface + +`FrozenEncoder(directory).encode(amplitudes)` returns `[N,32]` embeddings using +a bounded, hash-checked safetensors encoder and train normalization. Artifact JSON rejects duplicate keys and nonfinite numbers. Reads use no-follow, +nonblocking open and validate the actual file descriptor as a bounded regular +file; unsupported platforms fail explicitly. Source hashes are captured before +training and rechecked before completion. Hashes are +integrity checks, not signatures. Encoder parameters are frozen. The exported +artifact contains the online encoder, not a resumable teacher/optimizer checkpoint. + +`bundle.new_adapter(classes)` creates an **untrained** linear head. Its forward +method accepts amplitudes and updates only that head if the caller trains it. +It does not infer task semantics. A supervised study needs real task labels, +fresh grouped confirmation data and appropriate baseline comparisons. Existing +heldout diagnostics cannot authorize occupancy deployment. diff --git a/scripts/csi_training/active_evaluation.py b/scripts/csi_training/active_evaluation.py new file mode 100644 index 0000000000..9a908f544a --- /dev/null +++ b/scripts/csi_training/active_evaluation.py @@ -0,0 +1,302 @@ +"""Paired duration metrics on aligned, independently labelled room intervals. + +No hardware access, training, model promotion or truth authentication. The caller +must establish label provenance and frozen room splits outside this evaluator. +""" +import json +import math +import os +import random +import stat + +MAX_BYTES = 4 * 1024 * 1024 +STATES = {"empty", "occupied", "unknown"} + + +def number(value, name, minimum=0, maximum=1e12): + if type(value) not in (int, float) or not math.isfinite(value) or not minimum <= value <= maximum: + raise ValueError(f"invalid {name}") + return float(value) + + +def exact(value, keys): + if not isinstance(value, dict) or set(value) != set(keys): + raise ValueError("unexpected schema fields") + + +def load(path): + def pairs(items): + result = {} + for key, value in items: + if key in result: + raise ValueError("duplicate JSON key") + result[key] = value + return result + fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK | getattr(os, "O_NOFOLLOW", 0)) + with os.fdopen(fd, "rb") as file: + info = os.fstat(file.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_BYTES: + raise ValueError("input must be bounded regular file") + raw = file.read(MAX_BYTES + 1) + if len(raw) > MAX_BYTES: + raise ValueError("input exceeds limit") + return json.loads(raw, object_pairs_hook=pairs, + parse_constant=lambda _: (_ for _ in ()).throw(ValueError("nonfinite JSON"))) + + +def _id(value): + if not isinstance(value, str) or not value or len(value) > 128 or not value.isascii() or any(ord(c) < 32 for c in value): + raise ValueError("invalid identifier") + return value + + +TRANSITIONS = ("empty_to_occupied", "occupied_to_empty") +SIDES = ("baseline", "candidate") + + +def _new_transition_totals(): + return {"empty_seconds": 0.0, + "baseline_false_episodes": 0, "candidate_false_episodes": 0, + "empty_to_occupied": [0, 0.0, 0.0, 0, 0], + "occupied_to_empty": [0, 0.0, 0.0, 0, 0]} + + +def _collect_transitions(total, intervals): + previous_truth = None + previous_predictions = {} + for start, end, truth, baseline, candidate in intervals: + if truth == "empty": + total["empty_seconds"] += (end-start)/1000 + for side, prediction in zip(SIDES, (baseline, candidate)): + if (truth == "empty" and prediction == "occupied" + and (previous_truth != "empty" + or previous_predictions.get(side) != "occupied")): + total[f"{side}_false_episodes"] += 1 + previous_predictions[side] = prediction + previous_truth = truth + + run_start = 0 + while run_start < len(intervals): + truth = intervals[run_start][2] + run_stop = run_start + 1 + while run_stop < len(intervals) and intervals[run_stop][2] == truth: + run_stop += 1 + if run_start: + direction = f"{intervals[run_start-1][2]}_to_{truth}" + values = total[direction] + values[0] += 1 + start_ms = intervals[run_start][0] + end_ms = intervals[run_stop-1][1] + for side_index, tuple_index in enumerate((3, 4)): + matched = next((row[0] for row in intervals[run_start:run_stop] + if row[tuple_index] == truth), None) + if matched is None: + matched = end_ms + values[3+side_index] += 1 + values[1+side_index] += matched-start_ms + run_start = run_stop + + +def _summarize_transitions(values): + empty = sum(row["empty_seconds"] for row in values) + if empty == 0: + return None + baseline_episodes = sum(row["baseline_false_episodes"] for row in values) + candidate_episodes = sum(row["candidate_false_episodes"] for row in values) + result = { + "empty_seconds": empty, + "baseline_false_episodes_per_empty_hour": baseline_episodes/empty*3600, + "candidate_false_episodes_per_empty_hour": candidate_episodes/empty*3600, + "false_episode_delta_per_empty_hour": (candidate_episodes-baseline_episodes)/empty*3600, + } + for direction in TRANSITIONS: + totals = [sum(row[direction][i] for row in values) for i in range(5)] + events, baseline_delay, candidate_delay, baseline_unresolved, candidate_unresolved = totals + result[direction] = None if events == 0 else { + "events": int(events), + "baseline_mean_delay_ms": baseline_delay/events, + "candidate_mean_delay_ms": candidate_delay/events, + "delay_delta_ms": (candidate_delay-baseline_delay)/events, + "baseline_unresolved_fraction": baseline_unresolved/events, + "candidate_unresolved_fraction": candidate_unresolved/events, + "unresolved_fraction_delta": (candidate_unresolved-baseline_unresolved)/events, + } + return result + + +def evaluate(document, resamples=1000, seed=42): + exact(document, ("schemaVersion", "evidence", "sessions")) + if type(document["schemaVersion"]) is not int or document["schemaVersion"] != 1 or document["evidence"] not in ("SYNTHETIC", "MEASURED"): + raise ValueError("unsupported schema or evidence label") + sessions = document["sessions"] + if not isinstance(sessions, list) or not 1 <= len(sessions) <= 1000: + raise ValueError("invalid session count") + if type(resamples) is not int or not 100 <= resamples <= 10000 or type(seed) is not int or not 0 <= seed < 2**32: + raise ValueError("invalid bootstrap settings") + rooms, transition_rooms, identities, interval_count = {}, {}, set(), 0 + for session in sessions: + exact(session, ("room", "session", "budget", "intervals")) + room, sid = _id(session["room"]), _id(session["session"]) + if sid in identities: + raise ValueError("duplicate session identifier") + identities.add(sid) + budget = session["budget"] + exact(budget, ("baseline", "candidate")) + for side in budget.values(): + exact(side, ("packets", "airtime_ms", "energy_mj", "available_sensors")) + if type(side["packets"]) is not int: + raise ValueError("packet count must be integer") + for dimension in ("packets", "airtime_ms", "energy_mj"): + number(side[dimension], dimension) + sensors = side["available_sensors"] + if not isinstance(sensors, list) or not 1 <= len(sensors) <= 64: + raise ValueError("invalid availability") + if len({_id(sensor) for sensor in sensors}) != len(sensors): + raise ValueError("duplicate sensor") + if set(budget["baseline"]["available_sensors"]) != set(budget["candidate"]["available_sensors"]): + raise ValueError("unequal sensor availability") + for dimension in ("packets", "airtime_ms", "energy_mj"): + if budget["candidate"][dimension] != budget["baseline"][dimension]: + raise ValueError("unequal sensing budget") + intervals = session["intervals"] + if not isinstance(intervals, list) or not intervals: + raise ValueError("missing intervals") + interval_count += len(intervals) + if interval_count > 100000: + raise ValueError("too many intervals") + # empty, occupied, total; per policy false, hit, unknown, unknown-empty, unknown-occupied + totals = rooms.setdefault(room, [0.] * 13) + transition_totals = transition_rooms.setdefault(room, _new_transition_totals()) + validated_intervals = [] + previous = None + for interval in intervals: + exact(interval, ("start_ms", "end_ms", "truth", "baseline", "candidate")) + start = number(interval["start_ms"], "start") + end = number(interval["end_ms"], "end") + if end <= start or (previous is not None and start != previous): + raise ValueError("intervals must be increasing and contiguous") + previous = end + if interval["truth"] not in ("empty", "occupied"): + raise ValueError("independent binary truth required") + duration = (end - start) / 1000 + occupied = interval["truth"] == "occupied" + totals[int(occupied)] += duration + totals[2] += duration + for j, side in enumerate(("baseline", "candidate")): + prediction = interval[side] + if not isinstance(prediction, str) or prediction not in STATES: + raise ValueError("invalid prediction") + offset = 3 + j * 5 + totals[offset] += duration * (not occupied and prediction == "occupied") + totals[offset+1] += duration * (occupied and prediction == "occupied") + totals[offset+2] += duration * (prediction == "unknown") + totals[offset+3] += duration * (prediction == "unknown" and not occupied) + totals[offset+4] += duration * (prediction == "unknown" and occupied) + validated_intervals.append((start, end, interval["truth"], + interval["baseline"], interval["candidate"])) + _collect_transitions(transition_totals, validated_intervals) + + def summarize(values): + total = [sum(row[i] for row in values) for i in range(13)] + empty, occupied, duration = total[:3] + if empty == 0 or occupied == 0: + return None + bfalse, bhit, bunk, bue, buo, cfalse, chit, cunk, cue, cuo = total[3:] + return {"empty_seconds": empty, "occupied_seconds": occupied, + "baseline_false_seconds_per_empty_hour": bfalse / empty * 3600, + "candidate_false_seconds_per_empty_hour": cfalse / empty * 3600, + "baseline_recall": bhit / occupied, "candidate_recall": chit / occupied, + "baseline_unknown_fraction": bunk / duration, + "candidate_unknown_fraction": cunk / duration, + "false_duration_relative_reduction": None if bfalse == 0 else 1-cfalse/bfalse, + "recall_delta": (chit-bhit)/occupied, + "unknown_empty_delta": (cue-bue)/empty, + "unknown_occupied_delta": (cuo-buo)/occupied} + + point = summarize(list(rooms.values())) + if point is None: + raise ValueError("both empty and occupied exposure required") + rng = random.Random(seed) + clusters = list(rooms.values()) + reduction, recall, unknown_empty, unknown_occupied = [], [], [], [] + for _ in range(resamples): + sample = summarize([clusters[rng.randrange(len(clusters))] for _ in clusters]) + if sample is not None: + recall.append(sample["recall_delta"]) + unknown_empty.append(sample["unknown_empty_delta"]) + unknown_occupied.append(sample["unknown_occupied_delta"]) + if sample["false_duration_relative_reduction"] is not None: + reduction.append(sample["false_duration_relative_reduction"]) + def interval(values): + ordered = sorted(values) + return [ordered[int((len(ordered)-1)*q)] for q in (.025, .975)] if ordered else None + rci, dci = interval(reduction), interval(recall) + eci, oci = interval(unknown_empty), interval(unknown_occupied) + evaluable = len(rooms) >= 2 and len(reduction) >= .95 * resamples and point["false_duration_relative_reduction"] is not None + passes = bool(evaluable and point["false_duration_relative_reduction"] >= .20 and rci[0] > 0 and dci[0] >= -.01 and eci[1] <= .01 and oci[1] <= .01) + numerical_gate = "pass" if passes else "fail" if evaluable else "inconclusive" + + transition_point = _summarize_transitions(list(transition_rooms.values())) + transition_clusters = list(transition_rooms.values()) + transition_values = {direction: {"delay": [], "unresolved": []} + for direction in TRANSITIONS} + false_episodes = [] + transition_valid = 0 + for _ in range(resamples): + sample = _summarize_transitions([ + transition_clusters[rng.randrange(len(transition_clusters))] + for _ in transition_clusters]) + if sample is not None and all(sample[direction] is not None + for direction in TRANSITIONS): + transition_valid += 1 + false_episodes.append(sample["false_episode_delta_per_empty_hour"]) + for direction in TRANSITIONS: + transition_values[direction]["delay"].append( + sample[direction]["delay_delta_ms"]) + transition_values[direction]["unresolved"].append( + sample[direction]["unresolved_fraction_delta"]) + transition_intervals = { + direction: { + "delay_delta_ms_interval": interval(transition_values[direction]["delay"]), + "unresolved_fraction_delta_interval": interval( + transition_values[direction]["unresolved"]), + } for direction in TRANSITIONS} + false_episode_interval = interval(false_episodes) + transition_room_coverage = { + direction: sum(row[direction][0] > 0 for row in transition_clusters) + for direction in TRANSITIONS} + transition_evaluable = bool( + transition_point is not None + and all(transition_room_coverage[direction] >= 2 for direction in TRANSITIONS) + and transition_valid >= .95*resamples) + transition_passes = bool( + transition_evaluable and false_episode_interval[1] <= 0 + and all(transition_intervals[direction]["delay_delta_ms_interval"][1] <= 0 + and transition_intervals[direction]["unresolved_fraction_delta_interval"][1] <= 0 + for direction in TRANSITIONS)) + transition_gate = ("pass" if transition_passes else + "fail" if transition_evaluable else "inconclusive") + overall_gate = ("fail" if "fail" in (numerical_gate, transition_gate) else + "pass" if numerical_gate == transition_gate == "pass" else + "inconclusive") + return {"schemaVersion": 1, "inputDeclaredEvidence": document["evidence"], + "labelProvenanceVerified": False, "promotionAuthorized": False, + "rooms": len(rooms), "sessions": len(sessions), "intervals": interval_count, + "metrics": point, "bootstrap": {"cluster": "room", "seed": seed, + "resamples": resamples, "valid": len(reduction), "confidence": .95, + "reduction_interval": rci, "recall_delta_interval": dci, + "recall_valid": len(recall), "unknown_empty_delta_interval": eci, + "unknown_occupied_delta_interval": oci}, + "numericalGate": numerical_gate, + "transitionMetrics": transition_point, + "transitionBootstrap": {"cluster": "room", "seed": seed, + "resamples": resamples, "valid": transition_valid, + "confidence": .95, "room_coverage": transition_room_coverage, + "false_episode_delta_per_empty_hour_interval": false_episode_interval, + **transition_intervals}, + "transitionGate": transition_gate, "overallGate": overall_gate, + "fieldAcceptance": "not_established", + "limitations": ["Declared labels and budget costs are not independently authenticated", + "Fresh room splits and capture completeness require external review", + "Bootstrap intervals on few rooms may be unstable; size a confirmation study", + "Transition timing is interval-aligned and excludes sub-interval, acquisition and actuation latency"]} diff --git a/scripts/csi_training/confirmation.py b/scripts/csi_training/confirmation.py new file mode 100644 index 0000000000..f288d4dcd8 --- /dev/null +++ b/scripts/csi_training/confirmation.py @@ -0,0 +1,263 @@ +"""Metadata-only preflight. A pass never authenticates captures or authorizes promotion.""" +import argparse +import hashlib +import json +import os +import re +import stat + +MAX_BYTES = 2 * 1024 * 1024 +MAX_DATASET_MANIFEST_BYTES = 3_000_000 +MAX_RECORDS = 4096 +SPLITS = ('train', 'development', 'confirmation') +AXES = ('room', 'participant', 'session') +TOKEN = re.compile(r'[A-Za-z0-9_.:]{1,128}\Z') +SHA = re.compile(r'[0-9a-f]{64}\Z') +# This source has already informed development and final-test inspection. +KNOWN_EXPOSED = {('ESP-Fi HAR', 'c8bf6aa0d680fa02695c85a82b05e79b3ebb10c4')} + + +def exact(value, keys): + if type(value) is not dict or set(value) != set(keys): + raise ValueError('Unexpected schema keys') + + +def token(value): + if type(value) is not str or not TOKEN.fullmatch(value): + raise ValueError('Expected bounded opaque identifier') + return value + + +def source(value): + exact(value, ('dataset', 'revision')) + if type(value['dataset']) is not str or not 1 <= len(value['dataset']) <= 128: + raise ValueError('Expected dataset identifier') + return value['dataset'], token(value['revision']) + + +def digest(value): + if type(value) is not str or not SHA.fullmatch(value): + raise ValueError('Expected lowercase SHA256') + return value + + +def canonical_hash(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':'), + allow_nan=False).encode()).hexdigest() + + +def evaluate(manifest, ledger): + exact(manifest, ('schema_version', 'protocol_sha256', 'claim', 'records')) + exact(ledger, ('schema_version', 'exposed_sources')) + if type(manifest['schema_version']) is not int or manifest['schema_version'] != 1: + raise ValueError('Unsupported manifest version') + if type(ledger['schema_version']) is not int or ledger['schema_version'] != 1: + raise ValueError('Unsupported ledger version') + digest(manifest['protocol_sha256']) + if manifest['claim'] not in ('new_room_person', 'new_room_person_device'): + raise ValueError('Unsupported claim') + exposed = ledger['exposed_sources'] + if type(exposed) is not list or len(exposed) > MAX_RECORDS: + raise ValueError('Invalid exposure list') + exposed = KNOWN_EXPOSED | {source(row) for row in exposed} + records = manifest['records'] + if type(records) is not list or not 1 <= len(records) <= MAX_RECORDS: + raise ValueError('Invalid record count') + axes = AXES + (('device',) if manifest['claim'].endswith('_device') else ()) + groups = {s: {a: set() for a in axes} for s in SPLITS} + classes = {s: [0] * 7 for s in SPLITS} + seen_ids, seen_signals, reasons = set(), set(), set() + for row in records: + exact(row, ('id', 'signal_sha256', 'source', 'split', 'room', 'participant', + 'session', 'device', 'activity')) + for key in ('id', 'room', 'participant', 'session', 'device'): + token(row[key]) + signal = digest(row['signal_sha256']); origin = source(row['source']) + split = row['split']; activity = row['activity'] + if type(split) is not str or split not in SPLITS: + raise ValueError('Invalid split') + if type(activity) is not int or not 0 <= activity < 7: + raise ValueError('Invalid numeric activity') + if row['id'] in seen_ids: reasons.add('duplicate_record_id') + if signal in seen_signals: reasons.add('duplicate_signal_digest') + seen_ids.add(row['id']); seen_signals.add(signal) + classes[split][activity] += 1 + for axis in axes: groups[split][axis].add(row[axis]) + if split == 'confirmation' and origin in exposed: + reasons.add('confirmation_source_previously_exposed') + for axis in axes: + for i, left in enumerate(SPLITS): + for right in SPLITS[i+1:]: + if groups[left][axis] & groups[right][axis]: + reasons.add('overlapping_' + axis) + for split in SPLITS: + minimum = 10 if split == 'confirmation' else 1 + if min(classes[split]) < minimum: reasons.add(split + '_class_coverage') + for axis in ('room', 'participant'): + if len(groups['confirmation'][axis]) < 2: + reasons.add('insufficient_confirmation_' + axis + '_coverage') + return {'schema_version': 1, 'evidence': 'METADATA_ONLY', + 'preflight_passed': not reasons, 'promotionAuthorized': False, + 'protocol_sha256': manifest['protocol_sha256'], + 'manifest_sha256': canonical_hash(manifest), 'ledger_sha256': canonical_hash(ledger), + 'reasons': sorted(reasons), 'class_counts': classes, + 'group_counts': {s: {a: len(v) for a, v in g.items()} for s, g in groups.items()}, + 'scope': 'Declared metadata only. No capture authentication, measured accuracy, or independent confirmation result.'} + + +def _pairs(pairs): + result = {} + for key, value in pairs: + if key in result: raise ValueError('Duplicate JSON key') + result[key] = value + return result + + +def _read_regular(path, maximum): + if (type(maximum) is not int or not 1 <= maximum <= 16 * 1024 * 1024 + or not hasattr(os, 'O_NOFOLLOW') or not hasattr(os, 'O_NONBLOCK')): + raise ValueError('Safe file intake unavailable on this platform') + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(fd, 'rb') as handle: + info = os.fstat(handle.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_size > maximum: + raise ValueError('Expected bounded regular file') + raw = handle.read(info.st_size + 1) + if len(raw) != info.st_size: + raise ValueError('File changed during read') + return raw + + +def read_json_and_sha256(path, maximum=MAX_BYTES): + raw = _read_regular(path, maximum) + def invalid(value): raise ValueError('Nonfinite JSON constant') + return (json.loads(raw, object_pairs_hook=_pairs, parse_constant=invalid), + hashlib.sha256(raw).hexdigest()) + + +def read_json(path): + return read_json_and_sha256(path)[0] + + +def regular_sha256(path, maximum=MAX_DATASET_MANIFEST_BYTES): + """Hash one bounded regular file through the descriptor that was checked.""" + return hashlib.sha256(_read_regular(path, maximum)).hexdigest() + + +def _dataset_binding(manifest, dataset, required_source=None): + """Bind declared confirmation records to a supervised ESP-Fi manifest.""" + if type(dataset) is not dict: + raise ValueError('Dataset manifest must be an object') + if type(dataset.get('schema_version')) is not int or dataset['schema_version'] != 1: + raise ValueError('Unsupported dataset manifest version') + dataset_name = dataset.get('dataset') + revision = dataset.get('source_revision') + if type(dataset_name) is not str or not 1 <= len(dataset_name) <= 128: + raise ValueError('Invalid dataset identity') + token(revision) + dataset_source = (dataset_name, revision) + records = dataset.get('records') + if type(records) is not list or not 1 <= len(records) <= MAX_RECORDS: + raise ValueError('Invalid dataset record count') + split_map = {'train': 'train', 'validation': 'development', 'test': 'confirmation'} + retained, duplicates = {}, False + for row in records: + if type(row) is not dict or row.get('split') not in (*split_map, None): + raise ValueError('Invalid dataset record') + signal = digest(row.get('signal_sha256')) + identity = row.get('identity') + if (type(identity) is not list or len(identity) != 4 + or any(type(value) is not int for value in identity) + or not 1 <= identity[2] <= 7): + raise ValueError('Invalid dataset identity tuple') + if row['split'] is not None: + if signal in retained: + duplicates = True + retained[signal] = row + planned = {row['signal_sha256']: row for row in manifest['records']} + common = set(retained) & set(planned) + reasons = set() + if required_source is not None: + if (type(required_source) is not tuple or len(required_source) != 2 + or type(required_source[0]) is not str or type(required_source[1]) is not str): + raise ValueError('Invalid required source') + if dataset_source != required_source: + reasons.add('canonical_dataset_source_mismatch') + if duplicates: + reasons.add('dataset_duplicate_signal_digest') + if set(retained) != set(planned): + reasons.add('dataset_record_set_mismatch') + expected_source = dataset_source + for signal in common: + plan, row = planned[signal], retained[signal] + if source(plan['source']) != expected_source: + reasons.add('dataset_source_mismatch') + if plan['split'] != split_map[row['split']]: + reasons.add('dataset_split_mismatch') + if plan['activity'] != row['identity'][2] - 1: + reasons.add('dataset_activity_mismatch') + if plan['room'] != 'room:' + str(row['identity'][0]): + reasons.add('dataset_room_mismatch') + if plan['participant'] != 'participant:' + str(row['identity'][1]): + reasons.add('dataset_participant_mismatch') + session = row.get('session_id') + if session is None: + reasons.add('dataset_session_identity_unbound') + elif plan['session'] != token(session): + reasons.add('dataset_session_mismatch') + if manifest['claim'].endswith('_device'): + device = row.get('device_id') + if device is None: + reasons.add('dataset_device_identity_unbound') + elif plan['device'] != token(device): + reasons.add('dataset_device_mismatch') + return reasons, {'retained_records': len(retained), 'planned_records': len(planned), + 'matched_records': len(common), 'exact_record_set': set(retained) == set(planned)} + + +def training_gate(manifest_path, ledger_path, dataset_manifest_path, + expected_protocol_sha256, expected_dataset_manifest_sha256, + required_source=None): + """Evaluate declared metadata and bind it to frozen protocol/dataset bytes. + + This remains a metadata gate. It does not authenticate captures, consent, + licensing, source aliases, or the completeness of the exposure ledger. + """ + digest(expected_protocol_sha256) + digest(expected_dataset_manifest_sha256) + manifest = read_json(manifest_path) + report = evaluate(manifest, read_json(ledger_path)) + reasons = set(report['reasons']) + if report['protocol_sha256'] != expected_protocol_sha256: + reasons.add('protocol_digest_mismatch') + dataset, actual = read_json_and_sha256(dataset_manifest_path, MAX_DATASET_MANIFEST_BYTES) + if actual != expected_dataset_manifest_sha256: + reasons.add('dataset_manifest_digest_mismatch') + binding_reasons, binding = _dataset_binding(manifest, dataset, required_source) + reasons.update(binding_reasons) + result = dict(report) + result.update({'preflight_passed': not reasons, + 'reasons': sorted(reasons), + 'dataset_manifest_sha256': actual, + 'expected_dataset_manifest_sha256': expected_dataset_manifest_sha256, + 'dataset_binding': binding, + 'scope': report['scope'] + ' Dataset records and bytes are bound to declarations; provenance claims remain unauthenticated.'}) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--manifest', required=True) + parser.add_argument('--ledger', required=True) + args = parser.parse_args() + try: + report = evaluate(read_json(args.manifest), read_json(args.ledger)) + except (ValueError, OSError, TypeError, RecursionError, OverflowError): + print(json.dumps({'status': 'invalid_input', 'promotionAuthorized': False})) + return 1 + print(json.dumps(report, sort_keys=True)) + return 0 if report['preflight_passed'] else 2 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/csi_training/data.py b/scripts/csi_training/data.py index 3a6e500d25..623261ad53 100644 --- a/scripts/csi_training/data.py +++ b/scripts/csi_training/data.py @@ -9,6 +9,8 @@ import hashlib import json import math +import os +from contextlib import contextmanager from pathlib import Path import re import stat @@ -85,16 +87,35 @@ def pairs(items): raise DataError("invalid JSON input") from exc -def _regular(path, maximum): - if path.is_symlink(): - raise DataError("symlink input is not permitted") +@contextmanager +def _open_regular(path, maximum): + """Validate the same descriptor consumed by the caller, never check then reopen. + + O_NONBLOCK prevents a substituted FIFO from hanging before fstat. The final + path component cannot be a symlink. Parent directories remain a trusted + caller boundary; this helper does not implement filesystem confinement. + """ + if not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_NONBLOCK"): + raise DataError("secure regular-file intake unavailable on this platform") + try: + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + except OSError as exc: + raise DataError("input unavailable or symlink input is not permitted") from exc try: - info = path.stat() + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_size > maximum: + raise DataError("input must be a bounded regular file") + with os.fdopen(fd, "rb", closefd=False) as stream: + yield stream, info.st_size 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 + raise DataError("input read failed") from exc + finally: + os.close(fd) + + +def _regular(path, maximum): + with _open_regular(path, maximum) as (_, size): + return size def _text(value, name): @@ -105,8 +126,10 @@ def _text(value, name): def _manifest(path): path = Path(path).absolute() - _regular(path, MAX_MANIFEST_BYTES) - raw = path.read_bytes() + with _open_regular(path, MAX_MANIFEST_BYTES) as (stream, size): + raw = stream.read(size + 1) + if len(raw) != size: + raise DataError("manifest changed during read") if len(raw) > MAX_MANIFEST_BYTES: raise DataError("manifest exceeds limit") obj = _json(raw) @@ -170,6 +193,7 @@ def load_manifest(manifest, group_by="room_id", allow_synthetic=False): seen_observations = set() frame_count = 0 input_lines = 0 + total_read = 0 for entry, source, index in entries: split, group = entry["split"], entry[group_by] if entry["evidence"] == "synthetic" and not allow_synthetic: @@ -179,7 +203,7 @@ def load_manifest(manifest, group_by="room_id", allow_synthetic=False): 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: + with _open_regular(source, MAX_FILE_BYTES) as (stream, _): while True: line = stream.readline(MAX_LINE_BYTES + 1) if not line: @@ -188,6 +212,9 @@ def load_manifest(manifest, group_by="room_id", allow_synthetic=False): if input_lines > MAX_INPUT_LINES: raise DataError("dataset exceeds input line limit") size += len(line) + total_read += len(line) + if total_read > MAX_TOTAL_BYTES: + raise DataError("total dataset exceeds byte limit during read") if len(line) > MAX_LINE_BYTES or size > MAX_FILE_BYTES: raise DataError("capture line or file exceeds limit") digest.update(line) diff --git a/scripts/csi_training/espfi.py b/scripts/csi_training/espfi.py index ba04e899b3..63a4c62c77 100644 --- a/scripts/csi_training/espfi.py +++ b/scripts/csi_training/espfi.py @@ -5,15 +5,20 @@ """ from __future__ import annotations import argparse +from contextlib import contextmanager import hashlib import io import json +import os from pathlib import Path, PurePosixPath import re +import stat +import tempfile import zipfile import numpy as np REVISION = 'c8bf6aa0d680fa02695c85a82b05e79b3ebb10c4' +DATASET = 'ESP-Fi HAR' ARCHIVE_SHA256 = '85f3df633c5b1a48ca4b1423467fce22821b21eeb3eed085a8d9f983dc156660' ARCHIVE_BYTES = 644939981 SOURCE_URL = f'https://media.githubusercontent.com/media/AutoSmartGroup/ESP-Fi-HAR/{REVISION}/ESP-Fi_dataset.rar' @@ -21,6 +26,9 @@ IDENTITY = re.compile(r'([1-4])-([1-8])-([1-7])-(10|[1-9])\.mat\Z') MAX_MEMBER = 2_000_000 MAX_NPZ = 600_000_000 +# The largest admitted supervised payload is 193,688,352 bytes at pool=1. +# This bound includes conservative DEFLATE, NPY and ZIP container headroom. +MAX_CONFIRMED_NPZ = 200_000_000 def digest(path): @@ -28,6 +36,75 @@ def digest(path): return hashlib.file_digest(f, 'sha256').hexdigest() +def _copy_bundle(source, snapshot, size): + source.seek(0) + copied, value = 0, hashlib.sha256() + while copied < size: + block = source.read(min(1024 * 1024, size - copied)) + if not block: + raise ValueError('dataset bundle changed during snapshot') + if snapshot.write(block) != len(block): + raise ValueError('dataset bundle snapshot write failed') + value.update(block) + copied += len(block) + if copied != size or source.read(1): + raise ValueError('dataset bundle changed during snapshot') + return value.hexdigest() + + +@contextmanager +def _verified_bundle_snapshot(path, expected_sha256): + """Copy verified bytes into a private file before parser exposure.""" + snapshot = None + try: + with _open_regular_bundle(path) as (source, size): + snapshot = tempfile.TemporaryFile(mode='w+b', prefix='ruview-npz-') + if _copy_bundle(source, snapshot, size) != expected_sha256: + raise ValueError('unsafe or modified dataset bundle') + snapshot.flush() + snapshot.seek(0) + yield snapshot + except OSError as exc: + raise ValueError('dataset bundle snapshot unavailable') from exc + finally: + if snapshot is not None: + snapshot.close() + + +@contextmanager +def _open_regular_bundle(path): + """Open one bounded final-component descriptor for snapshot creation. + + Parent directories remain a trusted deployment boundary. O_NONBLOCK keeps a + substituted FIFO from blocking before fstat; O_NOFOLLOW rejects a final + symlink. A descriptor prevents pathname replacement while the verified + private snapshot is copied. + """ + if not hasattr(os, 'O_NOFOLLOW') or not hasattr(os, 'O_NONBLOCK'): + raise ValueError('secure dataset bundle intake unavailable') + flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK + if hasattr(os, 'O_CLOEXEC'): + flags |= os.O_CLOEXEC + if hasattr(os, 'O_BINARY'): + flags |= os.O_BINARY + try: + fd = os.open(path, flags) + except OSError as exc: + raise ValueError('dataset bundle unavailable or symlinked') from exc + try: + try: + info = os.fstat(fd) + except OSError as exc: + raise ValueError('dataset bundle read failed') from exc + if (not stat.S_ISREG(info.st_mode) + or not 0 < info.st_size <= MAX_CONFIRMED_NPZ): + raise ValueError('dataset bundle must be a bounded regular file') + with os.fdopen(fd, 'rb', closefd=False) as stream: + yield stream, info.st_size + finally: + os.close(fd) + + def parse_identity(name): p = PurePosixPath(name) if p.is_absolute() or '..' in p.parts or '\\' in name: @@ -126,7 +203,7 @@ def prepare(archive, output, pool=5): output.mkdir(parents=True) target = output / 'amplitudes.npz' np.savez_compressed(target, **arrays) - manifest = {'schema_version': 1, 'dataset': 'ESP-Fi HAR', 'source_url': SOURCE_URL, + manifest = {'schema_version': 1, 'dataset': DATASET, 'source_url': SOURCE_URL, 'source_revision': REVISION, 'archive_sha256': ARCHIVE_SHA256, 'license': 'CC-BY-4.0', 'license_url': f'https://github.com/AutoSmartGroup/ESP-Fi-HAR/blob/{REVISION}/Model%20Code/DATA_LICENSE.txt', 'attribution': 'Wen et al. ESP-Fi HAR. Ad Hoc Networks 186 (2026) 104192. doi:10.1016/j.adhoc.2026.104192', @@ -142,9 +219,10 @@ def prepare(archive, output, pool=5): return path -def load_dataset(manifest): +def load_dataset(manifest, expected_manifest_sha256=None): """Load all partitions for a final frozen evaluation, including test signals.""" - return _load_partitions(manifest, ('train', 'validation', 'test')) + return _load_partitions(manifest, ('train', 'validation', 'test'), + expected_manifest_sha256) def load_development(manifest): @@ -157,14 +235,28 @@ def load_development(manifest): return _load_partitions(manifest, ('train', 'validation')) -def _load_partitions(manifest, splits): - path = Path(manifest).resolve() - if path.stat().st_size > 3_000_000: - raise ValueError('manifest too large') - meta = json.loads(path.read_text()) +def _load_partitions(manifest, splits, expected_manifest_sha256=None): + supplied_path = Path(manifest) + if expected_manifest_sha256 is not None: + from .confirmation import (MAX_DATASET_MANIFEST_BYTES, digest as checked_digest, + read_json_and_sha256) + checked_digest(expected_manifest_sha256) + meta, manifest_sha256 = read_json_and_sha256( + supplied_path, MAX_DATASET_MANIFEST_BYTES) + if manifest_sha256 != expected_manifest_sha256: + raise ValueError('Dataset manifest changed after confirmation preflight') + # The final manifest component is not reopened. Parent directories remain + # a trusted deployment boundary for resolving the referenced NPZ. + path = supplied_path.parent.resolve() / supplied_path.name + else: + path = supplied_path.resolve() + if path.stat().st_size > 3_000_000: + raise ValueError('manifest too large') + meta = json.loads(path.read_text()) + manifest_sha256 = digest(path) if meta.get('schema_version') != 1 or meta.get('classes') != CLASSES: raise ValueError('unsupported dataset contract') - if (meta.get('source_revision') != REVISION or meta.get('archive_sha256') != ARCHIVE_SHA256 + if (meta.get('dataset') != DATASET or meta.get('source_revision') != REVISION or meta.get('archive_sha256') != ARCHIVE_SHA256 or meta.get('source_url') != SOURCE_URL or meta.get('license') != 'CC-BY-4.0' or meta.get('split_policy') != 'joint_room_subject_intersection_v1' or meta.get('temporal_mean_pool') not in (1, 2, 5, 10, 19, 25, 50, 95) @@ -186,10 +278,48 @@ def _load_partitions(manifest, splits): duplicate = occurrences[record['signal_sha256']] > 1 if record['split'] != (None if duplicate else split_identity(ident)): raise ValueError('incorrect source split assignment') - npz = (path.parent / meta['npz']).resolve() - if npz.parent != path.parent or npz.stat().st_size > MAX_NPZ or digest(npz) != meta['npz_sha256']: - raise ValueError('unsafe or modified dataset bundle') - with zipfile.ZipFile(npz) as z: + npz_name, npz_sha256 = meta.get('npz'), meta.get('npz_sha256') + if (not isinstance(npz_name, str) or not npz_name or len(npz_name) > 255 + or Path(npz_name).name != npz_name or '\\' in npz_name + or npz_name in ('.', '..') or not isinstance(npz_sha256, str) + or not re.fullmatch('[0-9a-f]{64}', npz_sha256)): + raise ValueError('unsafe dataset bundle declaration') + npz = path.parent / npz_name + if expected_manifest_sha256 is not None: + with _verified_bundle_snapshot(npz, npz_sha256) as snapshot: + _inspect_bundle(snapshot, meta, records) + parts, groups, signals, identities_seen = _decode_bundle( + snapshot, meta, lookup, splits) + else: + resolved_npz = npz.resolve() + if (resolved_npz.parent != path.parent or resolved_npz.stat().st_size > MAX_NPZ + or digest(resolved_npz) != npz_sha256): + raise ValueError('unsafe or modified dataset bundle') + _inspect_bundle(resolved_npz, meta, records) + parts, groups, signals, identities_seen = _decode_bundle( + resolved_npz, meta, lookup, splits) + for a, b in (('train', 'validation'), ('train', 'test'), ('validation', 'test')): + if a in groups and b in groups and any(groups[a][i] & groups[b][i] for i in (0, 1)): + raise ValueError('room or participant leakage') + if identities_seen != {i for i, r in lookup.items() if r['split'] in splits}: + raise ValueError('incomplete retained population') + expected_counts = {s: sum(r['split'] == s for r in records) + for s in ('train', 'validation', 'test')} + if meta.get('counts') != expected_counts: + raise ValueError('split counts mismatch') + meta['manifest_sha256'] = manifest_sha256 + meta['loaded_partitions'] = list(splits) + return parts, meta + + +def _rewind(bundle): + if hasattr(bundle, 'seek'): + bundle.seek(0) + + +def _inspect_bundle(bundle, meta, records): + _rewind(bundle) + with zipfile.ZipFile(bundle) as z: infos = z.infolist() expected_names = {s + suffix + '.npy' for s in ('train', 'validation', 'test') for suffix in ('_x', '_y', '_identity')} @@ -213,8 +343,13 @@ def _load_partitions(manifest, splits): if (shape != expected_shape or dtype != expected_dtype or fortran or f.tell() + int(np.prod(shape)) * dtype.itemsize != info.file_size): raise ValueError('invalid numpy header or payload size') + _rewind(bundle) + + +def _decode_bundle(bundle, meta, lookup, splits): parts, groups, signals, identities_seen = {}, {}, set(), set() - with np.load(npz, allow_pickle=False) as data: + _rewind(bundle) + with np.load(bundle, allow_pickle=False) as data: for split in splits: x, y, identities = (data[split + suffix] for suffix in ('_x', '_y', '_identity')) if (x.dtype != np.float32 or x.ndim != 3 or x.shape[1:] != (52, meta['window_samples']) @@ -238,18 +373,7 @@ def _load_partitions(manifest, splits): signals.add(h) groups[split] = (set(identities[:, 0]), set(identities[:, 1])) parts[split] = {'x': x, 'y': y, 'identity': identities} - for a, b in (('train', 'validation'), ('train', 'test'), ('validation', 'test')): - if a in groups and b in groups and any(groups[a][i] & groups[b][i] for i in (0, 1)): - raise ValueError('room or participant leakage') - if identities_seen != {i for i, r in lookup.items() if r['split'] in splits}: - raise ValueError('incomplete retained population') - expected_counts = {s: sum(r['split'] == s for r in records) - for s in ('train', 'validation', 'test')} - if meta.get('counts') != expected_counts: - raise ValueError('split counts mismatch') - meta['manifest_sha256'] = digest(path) - meta['loaded_partitions'] = list(splits) - return parts, meta + return parts, groups, signals, identities_seen def main(): diff --git a/scripts/csi_training/espfi_motion.py b/scripts/csi_training/espfi_motion.py index 8d56dd85d8..4fd027d8c6 100644 --- a/scripts/csi_training/espfi_motion.py +++ b/scripts/csi_training/espfi_motion.py @@ -4,6 +4,7 @@ Only grouped training folds select a candidate; final test arrays are absent. """ import argparse +import hashlib import json from pathlib import Path import time @@ -88,7 +89,8 @@ class MotionBundle: def __init__(self, directory): root = Path(directory) status = json.loads(_read(root/'status.json', 65536)) - report = json.loads(_read(root/'metrics.json', 16*1024**2)) + report_bytes = _read(root/'metrics.json', 16*1024**2) + report = json.loads(report_bytes) if (not isinstance(status, dict) or not isinstance(report, dict) or status.get('status') != 'complete' or status.get('promotionAuthorized') is not False or report.get('status') != 'complete' or report.get('promotionAuthorized') is not False @@ -99,8 +101,8 @@ def __init__(self, directory): if self.candidate not in GRID: raise ValueError('Unknown candidate') data = _read(root/'model.safetensors', 1024**2) - import hashlib - if hashlib.sha256(data).hexdigest() != report.get('model_sha256'): + model_sha256 = hashlib.sha256(data).hexdigest() + if model_sha256 != report.get('model_sha256'): raise ValueError('Model hash mismatch') self.state = load(data) width = WIDTHS[self.candidate['family']] @@ -112,6 +114,13 @@ def __init__(self, directory): for k, shape in expected.items()) or not (self.state['feature_scale'] > 0).all()): raise ValueError('Invalid motion model state') + provenance = report.get('provenance', {}) + self._verified_manifest_sha256 = (provenance.get('manifest_sha256') + if isinstance(provenance, dict) else None) + self._verified_model_sha256 = model_sha256 + self._verified_model_bytes = len(data) + self._verified_receipt_sha256 = hashlib.sha256(report_bytes).hexdigest() + def predict(self, values): return predict_features(self.state, motion_features(values, self.candidate['family'])) diff --git a/scripts/csi_training/espfi_motion_compact.py b/scripts/csi_training/espfi_motion_compact.py index 75ab0e73bb..9cfd35b527 100644 --- a/scripts/csi_training/espfi_motion_compact.py +++ b/scripts/csi_training/espfi_motion_compact.py @@ -39,10 +39,14 @@ def validate(state): class CompactMotionBundle: def __init__(self, directory, folded=True): root = Path(directory) - status = json.loads(_read(root/'status.json', 65536)) + status_bytes = _read(root/'status.json', 65536) + status = json.loads(status_bytes) if not isinstance(status, dict) or status.get('status') != 'complete' or status.get('promotionAuthorized') is not False: raise ValueError('Incomplete compact ACF artifact') - metadata = json.loads(_read(root/'metadata.json', 65536)) + metadata_bytes = _read(root/'metadata.json', 65536) + if hashlib.sha256(metadata_bytes).hexdigest() != status.get('metadata_sha256'): + raise ValueError('Compact metadata hash mismatch') + metadata = json.loads(metadata_bytes) if (not isinstance(metadata, dict) or metadata.get('spec') != SPEC or metadata.get('status') != 'complete' or metadata.get('promotionAuthorized') is not False): raise ValueError('Invalid compact ACF metadata') @@ -85,10 +89,8 @@ def export(parts, provenance, baseline_directory, output, max_seconds=600): raise ValueError('Invalid time budget') _validate(parts) baseline = MotionBundle(baseline_directory) - prior_bytes = _read(Path(baseline_directory)/'metrics.json',16*1024**2) - prior = json.loads(prior_bytes) if (baseline.candidate != BASELINE or not provenance.get('manifest_sha256') - or prior.get('provenance',{}).get('manifest_sha256') != provenance['manifest_sha256']): + or baseline._verified_manifest_sha256 != provenance['manifest_sha256']): raise ValueError('Baseline candidate or provenance mismatch') root = Path(output) root.mkdir(parents=True, exist_ok=False) @@ -144,15 +146,15 @@ def export(parts, provenance, baseline_directory, output, max_seconds=600): timings[name].append((time.perf_counter_ns()-before)/1e6) latency = {k:{'p50_ms':float(np.percentile(v,50)),'p95_ms':float(np.percentile(v,95))} for k,v in timings.items()} size = (root/'model.safetensors').stat().st_size - old_size = (Path(baseline_directory)/'model.safetensors').stat().st_size + old_size = baseline._verified_model_bytes checks = {'model_below_10percent':size<.1*old_size, 'parity_all_admitted':True, 'p95_within_5percent':latency['candidate']['p95_ms']<=1.05*latency['incumbent']['p95_ms']} metadata = {'status':'complete','promotionAuthorized':False,'spec':SPEC, 'model_sha256':digest(root/'model.safetensors'), 'random_map_sha256':{k:tensor_hash(v) for k,v in generated.items()}, - 'baseline_model_sha256':prior['model_sha256'], - 'baseline_receipt_sha256':hashlib.sha256(prior_bytes).hexdigest(), + 'baseline_model_sha256':baseline._verified_model_sha256, + 'baseline_receipt_sha256':baseline._verified_receipt_sha256, 'provenance':{'manifest_sha256':provenance['manifest_sha256']}} report = dict(metadata, provenance=provenance, evidence='SYNTHETIC' if provenance.get('evidence')=='SYNTHETIC' else 'MEASURED', parity=parity,latency=latency,model_bytes=size,baseline_model_bytes=old_size, @@ -169,7 +171,8 @@ def export(parts, provenance, baseline_directory, output, max_seconds=600): raise TimeoutError('Compact export budget exceeded') write_json(root/'metadata.json',metadata) write_json(root/'metrics.json',report) - write_json(root/'status.json',{'status':'complete','promotionAuthorized':False}) + write_json(root/'status.json',{'status':'complete','promotionAuthorized':False, + 'metadata_sha256':digest(root/'metadata.json')}) return report except Exception: write_json(root/'status.json',{'status':'failed','promotionAuthorized':False}) diff --git a/scripts/csi_training/espfi_motion_runtime.py b/scripts/csi_training/espfi_motion_runtime.py index 452c93eae1..425cb496ae 100644 --- a/scripts/csi_training/espfi_motion_runtime.py +++ b/scripts/csi_training/espfi_motion_runtime.py @@ -6,7 +6,9 @@ """ import hashlib import json +import os from pathlib import Path +import stat from threading import Lock import numpy as np from safetensors.numpy import load @@ -37,34 +39,102 @@ def validate(state): raise ValueError('Invalid compact float64 ACF state') -def _read(path): - path = Path(path) - if path.is_symlink() or not path.is_file(): - raise ValueError('Expected regular bounded artifact') - with path.open('rb') as handle: - data = handle.read(65537) - if len(data) > 65536: - raise ValueError('Oversized artifact') - return data +def _file_identity(details): + return (details.st_dev, details.st_ino, stat.S_IFMT(details.st_mode), + details.st_size, details.st_mtime_ns, details.st_ctime_ns) + + +def _open_directory(path): + """Open and pin the artifact directory used by one bundle load.""" + if not hasattr(os, 'O_NOFOLLOW') or not hasattr(os, 'O_DIRECTORY'): + raise ValueError('Secure artifact intake unavailable') + flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY + if hasattr(os, 'O_CLOEXEC'): + flags |= os.O_CLOEXEC + try: + descriptor = os.open(Path(path), flags) + except OSError as exc: + raise ValueError('Expected artifact directory') from exc + try: + if not stat.S_ISDIR(os.fstat(descriptor).st_mode): + raise ValueError('Expected artifact directory') + return descriptor + except (OSError, ValueError): + os.close(descriptor) + raise + + +def _read(path, directory_fd=None): + """Read a small bounded artifact through the descriptor that was checked.""" + if not hasattr(os, 'O_NOFOLLOW') or not hasattr(os, 'O_NONBLOCK'): + raise ValueError('Secure artifact intake unavailable') + target = Path(path) + if directory_fd is not None and target != Path(target.name): + raise ValueError('Artifact name must be relative to its directory') + if (directory_fd is not None and + (not hasattr(os, 'supports_dir_fd') or os.open not in os.supports_dir_fd)): + raise ValueError('Secure artifact intake unavailable') + flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK + if hasattr(os, 'O_CLOEXEC'): + flags |= os.O_CLOEXEC + if hasattr(os, 'O_BINARY'): + flags |= os.O_BINARY + try: + descriptor = os.open(target.name if directory_fd is not None else target, + flags, dir_fd=directory_fd) + except OSError as exc: + raise ValueError('Expected regular bounded artifact') from exc + try: + details = os.fstat(descriptor) + if not stat.S_ISREG(details.st_mode) or details.st_size <= 0: + raise ValueError('Expected regular bounded artifact') + if details.st_size > 65536: + raise ValueError('Oversized artifact') + chunks = [] + remaining = details.st_size + while remaining: + chunk = os.read(descriptor, remaining) + if not chunk: + raise ValueError('Artifact changed while reading') + chunks.append(chunk) + remaining -= len(chunk) + if os.read(descriptor, 1): + raise ValueError('Artifact changed while reading') + after = os.fstat(descriptor) + if _file_identity(after) != _file_identity(details): + raise ValueError('Artifact changed while reading') + return b''.join(chunks) + except OSError as exc: + raise ValueError('Unable to read bounded artifact') from exc + finally: + os.close(descriptor) def load_state(directory): root = Path(directory) - status = json.loads(_read(root/'status.json')) - if not isinstance(status,dict) or status.get('status') != 'complete' or status.get('promotionAuthorized') is not False: - raise ValueError('Incomplete compact ACF artifact') - metadata = json.loads(_read(root/'metadata.json')) - if (not isinstance(metadata,dict) or metadata.get('spec') != SPEC or metadata.get('status') != 'complete' - or metadata.get('promotionAuthorized') is not False): - raise ValueError('Invalid compact ACF metadata') - data = _read(root/'model.safetensors') - if hashlib.sha256(data).hexdigest() != metadata.get('model_sha256'): - raise ValueError('Compact model hash mismatch') - compact = load(data) - validate(compact) - generated = random_map() - if {k:tensor_hash(v) for k,v in generated.items()} != metadata.get('random_map_sha256'): - raise ValueError('Regenerated map differs from export') + directory_fd = _open_directory(root) + try: + status_bytes = _read('status.json', directory_fd) + status = json.loads(status_bytes) + if not isinstance(status,dict) or status.get('status') != 'complete' or status.get('promotionAuthorized') is not False: + raise ValueError('Incomplete compact ACF artifact') + metadata_bytes = _read('metadata.json', directory_fd) + if hashlib.sha256(metadata_bytes).hexdigest() != status.get('metadata_sha256'): + raise ValueError('Compact metadata hash mismatch') + metadata = json.loads(metadata_bytes) + if (not isinstance(metadata,dict) or metadata.get('spec') != SPEC or metadata.get('status') != 'complete' + or metadata.get('promotionAuthorized') is not False): + raise ValueError('Invalid compact ACF metadata') + data = _read('model.safetensors', directory_fd) + if hashlib.sha256(data).hexdigest() != metadata.get('model_sha256'): + raise ValueError('Compact model hash mismatch') + compact = load(data) + validate(compact) + generated = random_map() + if {k:tensor_hash(v) for k,v in generated.items()} != metadata.get('random_map_sha256'): + raise ValueError('Regenerated map differs from export') + finally: + os.close(directory_fd) projection = np.ascontiguousarray(generated['projection']/compact['feature_scale'][:,None]) state = {'projection':projection, 'phase':generated['phase']-compact['feature_mean']@projection, diff --git a/scripts/csi_training/espfi_train.py b/scripts/csi_training/espfi_train.py index 476b4b2cf3..257482abca 100644 --- a/scripts/csi_training/espfi_train.py +++ b/scripts/csi_training/espfi_train.py @@ -363,9 +363,42 @@ def main(argv=None): parser.add_argument('--seed', type=int, default=42) parser.add_argument('--max-seconds', type=int, default=600) parser.add_argument('--optimize-bundle', help='Existing statistics bundle; inference only, no training or test evaluation') + parser.add_argument('--confirmation-manifest', help='Independent-confirmation metadata plan (enables enforced gate)') + parser.add_argument('--exposure-ledger', help='Previously exposed dataset revisions for the confirmation gate') + parser.add_argument('--expected-protocol-sha256', help='Frozen protocol digest for the confirmation gate') + parser.add_argument('--expected-dataset-manifest-sha256', help='Frozen digest of --manifest bytes') args = parser.parse_args(argv) + gate_names = ('confirmation_manifest', 'exposure_ledger', 'expected_protocol_sha256', + 'expected_dataset_manifest_sha256') + gate_values = [getattr(args, name) for name in gate_names] + gate_enabled = all(gate_values) + if (any(gate_values) and not gate_enabled) or (gate_enabled and args.optimize_bundle): + print(json.dumps({'status': 'invalid_input', 'promotionAuthorized': False})) + raise SystemExit(1) + preflight = None + if gate_enabled: + from .confirmation import training_gate + from .espfi import DATASET, REVISION + try: + preflight = training_gate(args.confirmation_manifest, args.exposure_ledger, + args.manifest, args.expected_protocol_sha256, + args.expected_dataset_manifest_sha256, + required_source=(DATASET, REVISION)) + except (ValueError, OSError, TypeError, RecursionError, OverflowError): + print(json.dumps({'status': 'invalid_input', 'promotionAuthorized': False})) + raise SystemExit(1) + if not preflight['preflight_passed']: + print(json.dumps(preflight, sort_keys=True)) + raise SystemExit(2) from .espfi import load_dataset - partitions, provenance = load_dataset(args.manifest) + partitions, provenance = load_dataset( + args.manifest, + expected_manifest_sha256=(preflight['dataset_manifest_sha256'] + if preflight else None)) + if preflight: + if provenance.get('manifest_sha256') != preflight['dataset_manifest_sha256']: + raise ValueError('Dataset manifest changed after confirmation preflight') + provenance = dict(provenance, confirmation_preflight=preflight) if args.optimize_bundle: parent = json.loads((Path(args.optimize_bundle) / 'metrics.json').read_text()) if provenance['manifest_sha256'] != parent['provenance']['manifest_sha256']: diff --git a/scripts/csi_training/predictive.py b/scripts/csi_training/predictive.py new file mode 100644 index 0000000000..988a19133a --- /dev/null +++ b/scripts/csi_training/predictive.py @@ -0,0 +1,319 @@ +"""Experimental masked latent CSI prediction, not CSI-JEPA/AM-FM reproduction.""" +import argparse +import copy +import hashlib +import json +import os +import stat +import math +from pathlib import Path +import time +import numpy as np +import torch +from torch import nn +from torch.nn import functional as F +from safetensors.torch import load, save_file + +SPEC={'input':[52,190],'axes':['subcarrier','sample_index'],'patch':[4,19], + 'latent':32,'heads':4,'layers':1,'mask_fraction':.4,'ema':.99, + 'variance_weight':.1,'variance_floor':1.,'seed':42, + 'normalization':'train-only per-carrier log-amplitude mean/std', + 'claim':'experimental masked latent prediction, not reproduction or occupancy model'} + + +def _read(path,limit): + if not hasattr(os,'O_NOFOLLOW') or not hasattr(os,'O_NONBLOCK'): + raise ValueError('Secure regular-file reader unavailable on this platform') + try: + fd=os.open(Path(path),os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK) + try: + info=os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_size>limit: + raise ValueError('Expected bounded regular file') + with os.fdopen(fd,'rb',closefd=False) as handle:data=handle.read(limit+1) + finally: + os.close(fd) + except OSError as exc: + raise ValueError('Cannot open verified regular artifact') from exc + if len(data)>limit:raise ValueError('Artifact exceeds bound') + return data + + +def _json(data): + def pairs(items): + result={} + for key,value in items: + if key in result:raise ValueError('Duplicate artifact JSON key') + result[key]=value + return result + def constant(_):raise ValueError('Nonfinite artifact JSON number') + def number(value): + result=float(value) + if not math.isfinite(result):raise ValueError('Nonfinite artifact JSON number') + return result + return json.loads(data,object_pairs_hook=pairs,parse_constant=constant,parse_float=number) + + +def _write(path,value): + Path(path).write_text(json.dumps(value,indent=2,sort_keys=True,allow_nan=False)+'\n') + + +def amplitudes(values): + x=np.asarray(values) + if (x.dtype.kind not in 'fiu' or x.ndim!=3 or x.shape[1:]!=(52,190) + or not 1<=len(x)<=4096 or not np.isfinite(x).all() or (x<0).any()): + raise ValueError('Expected 1..4096 finite nonnegative amplitude windows N52T190') + return x + + +def normalize(values,mean,scale): + mean,scale=np.asarray(mean),np.asarray(scale) + if mean.shape!=(52,) or scale.shape!=(52,) or not np.isfinite(mean).all() or not np.isfinite(scale).all() or not (scale>0).all(): + raise ValueError('Invalid normalization state') + x=amplitudes(values).astype(np.float64) + result=((np.log(np.maximum(x,1e-6))-mean[None,:,None])/scale[None,:,None]).astype(np.float32) + if not np.isfinite(result).all():raise ValueError('Normalization overflow') + return torch.from_numpy(result) + + +def split_training(x,identity): + """Use original training rows only; labels/trial fields never enter features.""" + x=amplitudes(x);ids=np.asarray(identity) + if (ids.dtype.kind not in 'iu' or ids.shape!=(len(x),4) + or not np.isin(ids[:,0],[1,2]).all() or not np.isin(ids[:,1],range(1,7)).all()): + raise ValueError('Only original training room/participant IDs admitted') + fit=(ids[:,0]==1)&np.isin(ids[:,1],[1,2,3,4]) + held=(ids[:,0]==2)&np.isin(ids[:,1],[5,6]) + return {'train':{'x':x[fit],'groups':ids[fit,:2]}, + 'heldout':{'x':x[held],'groups':ids[held,:2]}} + + +def validate_parts(parts): + if set(parts)!={'train','heldout'}:raise ValueError('Only group-disjoint inner train/heldout partitions admitted') + seen=set();groups={} + for name,part in parts.items(): + x=amplitudes(part['x']);ids=np.asarray(part['groups']) + if len(x)<2:raise ValueError('Each training/heldout group split requires at least two windows') + if ids.shape!=(len(x),2) or ids.dtype.kind not in 'iu':raise ValueError('Explicit room/participant groups required') + groups[name]=[set(ids[:,i]) for i in range(2)] + for row in x: + key=hashlib.sha256(np.ascontiguousarray(row,dtype=np.float64).tobytes()).hexdigest() + if key in seen:raise ValueError('Duplicate recording across admitted population') + seen.add(key) + if any(groups['train'][i]&groups['heldout'][i] for i in range(2)): + raise ValueError('Room or participant group leakage') + + +class PatchEncoder(nn.Module): + def __init__(self): + super().__init__() + self.project=nn.Linear(76,32) + self.position=nn.Parameter(torch.randn(1,130,32)*.02) + self.mask_token=nn.Parameter(torch.zeros(1,1,32)) + layer=nn.TransformerEncoderLayer(32,4,dim_feedforward=64,dropout=0.,batch_first=True) + self.context=nn.TransformerEncoder(layer,1,enable_nested_tensor=False) + + def forward(self,x,mask=None): + tokens=x.reshape(len(x),13,4,10,19).permute(0,1,3,2,4).reshape(len(x),130,76) + tokens=self.project(tokens) + if mask is not None:tokens=torch.where(mask[...,None],self.mask_token,tokens) + return self.context(tokens+self.position) + + +class Predictor(nn.Module): + def __init__(self): + super().__init__();self.encoder=PatchEncoder();self.head=nn.Sequential(nn.Linear(32,32),nn.GELU(),nn.Linear(32,32)) + def forward(self,x,mask):return self.head(self.encoder(x,mask)) + + +def make_mask(count,generator): + mask=torch.zeros(count,130,dtype=torch.bool) + indices=torch.rand(count,130,generator=generator).argsort(dim=1)[:,:52] + return mask.scatter_(1,indices,True) + + +def objective(student,teacher,x,mask): + prediction=student(x,mask) + with torch.no_grad():target=teacher(x) + latent=student.encoder(x,mask) + std=torch.sqrt(latent.var(dim=0,unbiased=False)+1e-4) + variance=F.relu(1.-std).mean() + loss=F.mse_loss(prediction[mask],target[mask])+.1*variance + return loss,{'latent_loss':F.mse_loss(prediction[mask],target[mask]).detach(), + 'sample_std':std.mean().detach(),'variance_penalty':variance.detach()},target + + +@torch.no_grad() +def ema_update(teacher,student): + for target,source in zip(teacher.parameters(),student.encoder.parameters()): + target.mul_(.99).add_(source,alpha=.01) + + +def _diagnostics(tokens): + sample_std=tokens.std(dim=0,unbiased=False).mean() + centered=tokens.mean(1)-tokens.mean((0,1)) + singular=torch.linalg.svdvals(centered) + power=singular.square();prob=power/power.sum().clamp_min(1e-12) + rank=torch.exp(-(prob*torch.log(prob.clamp_min(1e-12))).sum()) if power.sum()>0 else torch.tensor(0.) + return {'sample_std':float(sample_std),'recording_effective_rank':float(rank), + 'collapse_warning':bool(sample_std<.05 or rank<1.5)} + + +class FrozenEncoder: + """Verified research encoder; no occupancy labels or automatic adaptation.""" + def __init__(self,directory): + root=Path(directory);status=_json(_read(root/'status.json',65536)) + if not isinstance(status,dict) or status.get('status')!='complete' or status.get('promotionAuthorized') is not False:raise ValueError('Incomplete research artifact') + report=_json(_read(root/'metrics.json',1024**2)) + if not isinstance(report,dict) or report.get('status')!='complete' or report.get('spec')!=SPEC or report.get('promotionAuthorized') is not False:raise ValueError('Invalid predictive contract') + data=_read(root/'model.safetensors',2*1024**2) + if hashlib.sha256(data).hexdigest()!=report.get('model_sha256'):raise ValueError('Model hash mismatch') + state=load(data);self.encoder=PatchEncoder().eval().requires_grad_(False) + expected=self.encoder.state_dict() + if set(state)!=set(expected)|{'normalization.mean','normalization.scale'}:raise ValueError('Unexpected tensor keys') + for key,value in expected.items(): + if state[key].shape!=value.shape or state[key].dtype!=value.dtype or not torch.isfinite(state[key]).all():raise ValueError('Invalid encoder tensor') + self.encoder.load_state_dict({k:state[k] for k in expected},strict=True) + self.mean=state['normalization.mean'].numpy();self.scale=state['normalization.scale'].numpy() + if (self.mean.shape!=(52,) or self.scale.shape!=(52,) or self.mean.dtype!=np.float64 or self.scale.dtype!=np.float64 + or not np.isfinite(self.mean).all() or not np.isfinite(self.scale).all() or not (self.scale>0).all()):raise ValueError('Invalid train normalization') + + def encode(self,values): + values=amplitudes(values) + with torch.no_grad(): + result=np.concatenate([self.encoder(normalize(values[i:i+32],self.mean,self.scale)).mean(1).numpy() + for i in range(0,len(values),32)]) + if not np.isfinite(result).all():raise ValueError('Nonfinite frozen embeddings') + return result + + def new_adapter(self,classes): + if type(classes) is not int or not 2<=classes<=64:raise ValueError('Adapter classes must be2..64') + return FrozenAdapter(self,classes) + + +class FrozenAdapter(nn.Module): + """Untrained linear head; caller must provide lawful task labels and fresh splits.""" + def __init__(self,bundle,classes): + super().__init__();self.bundle=bundle;self.head=nn.Linear(32,classes) + def forward(self,values):return self.head(torch.from_numpy(self.bundle.encode(values))) + + +def train(parts,provenance,output,epochs=5,batch_size=8,max_seconds=120): + if type(epochs) is not int or not 1<=epochs<=100 or type(batch_size) is not int or not 2<=batch_size<=64 or type(max_seconds) not in (int,float) or not math.isfinite(max_seconds) or not 1<=max_seconds<=3600: + raise ValueError('Invalid bounded training configuration') + source_digest=hashlib.sha256(_read(Path(__file__),1024**2)).hexdigest() + validate_parts(parts) + if not isinstance(provenance,dict) or provenance.get('evidence') not in ('SYNTHETIC','PUBLIC_TRAIN_ONLY'): + raise ValueError('Explicit synthetic or existing public-training lineage required') + if len(json.dumps(provenance,allow_nan=False))>65536:raise ValueError('Lineage exceeds bound') + torch.manual_seed(42);torch.set_num_threads(1);torch.use_deterministic_algorithms(True) + root=Path(output);root.mkdir(parents=True,exist_ok=False) + _write(root/'status.json',{'status':'running','promotionAuthorized':False}) + started=time.monotonic() + try: + x=parts['train']['x'].astype(np.float64);logged=np.log(np.maximum(x,1e-6)) + mean=logged.mean(axis=(0,2));scale=np.maximum(logged.std(axis=(0,2)),1e-6) + training=normalize(x,mean,scale) + student=Predictor();initial=copy.deepcopy(student).eval();teacher=copy.deepcopy(student.encoder).eval().requires_grad_(False) + optimizer=torch.optim.AdamW(student.parameters(),lr=1e-3,weight_decay=1e-4) + generator=torch.Generator().manual_seed(42);history=[] + for epoch in range(epochs): + order=torch.randperm(len(training),generator=generator);losses=[] + for start in range(0,len(order),batch_size): + if time.monotonic()-started>max_seconds:raise TimeoutError('Budget exceeded between steps; no step preemption guarantee') + ids=order[start:start+batch_size] + if len(ids)<2:continue + mask=make_mask(len(ids),generator);optimizer.zero_grad(set_to_none=True) + loss,diagnostic,_=objective(student,teacher,training[ids],mask) + if not torch.isfinite(loss):raise ValueError('Nonfinite pretraining loss') + loss.backward();torch.nn.utils.clip_grad_norm_(student.parameters(),1.);optimizer.step();ema_update(teacher,student) + losses.append(float(loss.detach())) + history.append({'epoch':epoch+1,'train_objective':float(np.mean(losses))}) + student.eval() + # Fit constant baseline exclusively on training teacher targets. + with torch.no_grad(): + target_sum=torch.zeros(32);position_sum=torch.zeros(130,32);count=0;recordings=0 + for offset in range(0,len(training),batch_size): + if time.monotonic()-started>max_seconds:raise TimeoutError('Budget exceeded during baseline evaluation') + target=teacher(training[offset:offset+batch_size]);target_sum+=target.sum((0,1));count+=target.numel()//32;position_sum+=target.sum(0);recordings+=len(target) + constant=target_sum/count;position_constant=position_sum/recordings + # Final heldout-only evaluation: never used for checkpoints or selection. + held=normalize(parts['heldout']['x'],mean,scale);evaluations={'trained':[],'untrained':[],'constant':[],'position_constant':[]};latent_rows=[] + evaluation_rng=torch.Generator().manual_seed(734) + with torch.no_grad(): + for offset in range(0,len(held),batch_size): + if time.monotonic()-started>max_seconds:raise TimeoutError('Budget exceeded during heldout evaluation') + rows=held[offset:offset+batch_size];mask=make_mask(len(rows),evaluation_rng);target=teacher(rows) + for name,prediction in [('trained',student(rows,mask)),('untrained',initial(rows,mask)),('constant',constant.expand_as(target)),('position_constant',position_constant.expand_as(target))]: + evaluations[name].append((float(F.mse_loss(prediction[mask],target[mask])),len(rows))) + latent_rows.append(student.encoder(rows).cpu()) + losses={name:sum(value*n for value,n in entries)/sum(n for _,n in entries) for name,entries in evaluations.items()} + state={k:v.detach().cpu().contiguous() for k,v in student.encoder.state_dict().items()} + state.update({'normalization.mean':torch.from_numpy(mean),'normalization.scale':torch.from_numpy(scale)}) + save_file(state,str(root/'model.safetensors')) + replay=load(_read(root/'model.safetensors',2*1024**2)) + if any(not torch.equal(state[k],replay[k]) for k in state):raise ValueError('Saved state differs') + restored=PatchEncoder().eval().requires_grad_(False) + restored.load_state_dict({k:replay[k] for k in restored.state_dict()},strict=True) + with torch.no_grad(): + original_embedding=student.encoder(held[:batch_size]).mean(1) + restored_embedding=restored(held[:batch_size]).mean(1) + if not torch.equal(original_embedding,restored_embedding):raise ValueError('Reload changes embeddings') + # Benchmark the exported frozen encoder through its public encode path. + # Construct from already verified tensors while lifecycle remains running. + frozen=FrozenEncoder.__new__(FrozenEncoder) + frozen.encoder=restored;frozen.mean=mean;frozen.scale=scale + benchmark_input=parts['heldout']['x'] + for i in range(10):frozen.encode(benchmark_input[i%len(benchmark_input):i%len(benchmark_input)+1]) + latency=[] + for round_id in range(3): + elapsed=[] + for i in range(100): + if time.monotonic()-started>max_seconds:raise TimeoutError('Budget exceeded during inference benchmark') + j=i%len(benchmark_input);before=time.perf_counter_ns() + frozen.encode(benchmark_input[j:j+1]);elapsed.append((time.perf_counter_ns()-before)/1e6) + latency.append({'round':round_id+1,'p50_ms':float(np.percentile(elapsed,50)), + 'p95_ms':float(np.percentile(elapsed,95))}) + report={'status':'complete','promotionAuthorized':False,'spec':SPEC,'evidence':provenance['evidence'], + 'lineage':provenance,'configuration':{'epochs':epochs,'batch_size':batch_size,'max_seconds':max_seconds}, + 'history':history,'heldout_latent_mse':losses,'anti_collapse':_diagnostics(torch.cat(latent_rows)), + 'train_samples':len(training),'heldout_samples':len(held),'normalization_fit':'train_only','reload_embedding_max_error':0., + 'torch_version':torch.__version__,'numpy_version':np.__version__, + 'model_sha256':hashlib.sha256((root/'model.safetensors').read_bytes()).hexdigest(), + 'source_sha256':source_digest,'inference_latency':{'rounds':latency,'warmup':10,'calls_per_round':100, + 'scope':'CPU batch1 public frozen encode including validation/log normalization/token encoder/mean pooling; excludes acquisition and file IO'},'elapsed_seconds':time.monotonic()-started, + 'scope':'EMA stopped targets; same final teacher target and mask for all baselines. No heldout tuning, original validation/test decoding, activity-to-occupancy relabeling, or task accuracy claim.'} + if time.monotonic()-started>max_seconds:raise TimeoutError('Budget exceeded before completion') + if hashlib.sha256(_read(Path(__file__),1024**2)).hexdigest()!=source_digest:raise ValueError('Training source changed during run') + _write(root/'metrics.json',report);_write(root/'status.json',{'status':'complete','promotionAuthorized':False}) + return report + except Exception: + _write(root/'status.json',{'status':'failed','promotionAuthorized':False});raise + + +def synthetic(): + rng=np.random.default_rng(42);parts={} + for name,n,room,participant in [('train',24,1,1),('heldout',8,2,5)]: + phase=rng.uniform(0,6.28,(n,1,1));carrier=rng.normal(0,.2,(n,52,1));time_axis=np.linspace(0,6.28,190)[None,None,:] + x=np.exp(carrier+.3*np.sin(time_axis+phase)+rng.normal(0,.03,(n,52,190))).astype(np.float32) + parts[name]={'x':x,'groups':np.tile([room,participant],(n,1))} + return parts,{'evidence':'SYNTHETIC','source':'deterministic synthetic amplitude fixture, seed42'} + + +def main(): + parser=argparse.ArgumentParser(description=__doc__);group=parser.add_mutually_exclusive_group(required=True) + group.add_argument('--synthetic',action='store_true');group.add_argument('--manifest') + parser.add_argument('--output',required=True);parser.add_argument('--epochs',type=int,default=5) + parser.add_argument('--batch-size',type=int,default=8);parser.add_argument('--max-seconds',type=int,default=120) + args=parser.parse_args() + if args.synthetic:parts,provenance=synthetic() + else: + from .espfi import _load_partitions + admitted,meta=_load_partitions(args.manifest,('train',)) + parts=split_training(admitted['train']['x'],admitted['train']['identity']) + provenance={'evidence':'PUBLIC_TRAIN_ONLY','manifest_sha256':meta['manifest_sha256'],'source_revision':meta['source_revision'], + 'loaded_partitions':['train'],'inner_fit':'room1 subjects1..4','inner_heldout':'room2 subjects5,6', + 'unused_training_rows':len(admitted['train']['x'])-sum(len(v['x']) for v in parts.values())} + print(json.dumps(train(parts,provenance,args.output,args.epochs,args.batch_size,args.max_seconds),indent=2)) + +if __name__=='__main__':main() diff --git a/scripts/csi_training/predictive_probe.py b/scripts/csi_training/predictive_probe.py new file mode 100644 index 0000000000..0766f6846e --- /dev/null +++ b/scripts/csi_training/predictive_probe.py @@ -0,0 +1,141 @@ +"""Fixed label-dependent ridge probes for frozen activity representations. + +Only training labels/features fit the probes. Development metrics never select +hyperparameters. Numeric activity IDs 0..6 have no occupancy semantics. +""" +import hashlib +import json + +import numpy as np + +NAMES = ('baseline', 'candidate', 'untrained', 'statistics') +CONFIG = {'alpha': 1.0, 'intercept': True, 'scaling': 'train_mean_population_std', + 'zero_variance_scale': 1.0, 'classes': list(range(7)), + 'macro_f1_classes': 'all_seven_including_absent', 'selection': 'none'} + + +def _features(value): + if (not isinstance(value, np.ndarray) or value.ndim != 2 + or value.dtype.kind != 'f' or value.dtype.itemsize not in (4, 8) + or not 1 <= value.shape[0] <= 4096 or not 1 <= value.shape[1] <= 1024): + raise ValueError('Expected bounded float32/float64 N-by-D features') + if not np.isfinite(value).all() or np.any(np.abs(value) > 1e100): + raise ValueError('Features must be finite and magnitude <= 1e100') + return value.astype(np.float64, copy=True) + + +def _labels(value, length=None): + if (not isinstance(value, np.ndarray) or value.ndim != 1 + or value.dtype.kind not in 'iu' or not 1 <= len(value) <= 4096 + or (length is not None and len(value) != length) + or np.any(value < 0) or np.any(value > 6)): + raise ValueError('Expected aligned integer activity labels 0..6 (no bool)') + return value.astype(np.int64, copy=True) + + +def metrics(y, prediction): + y = _labels(y) + prediction = _labels(prediction, len(y)) + confusion = np.bincount(7*y + prediction, minlength=49).reshape(7, 7) + true_positive = np.diag(confusion) + denominator = confusion.sum(0) + confusion.sum(1) + f1 = np.divide(2.*true_positive, denominator, out=np.zeros(7), where=denominator != 0) + return {'accuracy': float(true_positive.sum()/len(y)), 'macro_f1': float(f1.mean()), + 'per_class_f1': f1.tolist(), 'confusion': confusion.tolist(), + 'confusion_axes': 'rows=true numeric activity, columns=predicted numeric activity'} + + +def _state_hash(state): + result = hashlib.sha256(json.dumps(CONFIG, sort_keys=True).encode()) + for name in ('mean', 'scale', 'weights', 'bias'): + value = np.ascontiguousarray(state[name], dtype='= best_f1+.02, + 'accuracy_within_001_of_best': candidate['accuracy'] >= best_accuracy-.01, + 'no_new_zero_f1_class': all(c > 0 or b == 0 for c, b in + zip(candidate['per_class_f1'], probes['baseline']['per_class_f1']))} + return {'schema_version': 1, 'task': 'frozen_numeric_activity_linear_probe', + 'promotionAuthorized': False, 'classes': list(range(7)), 'probes': probes, + 'majority': majority, 'gate': {'checks': checks, 'passed': all(checks.values()), + 'best_comparator_macro_f1': best_f1, 'best_comparator_accuracy': best_accuracy, + 'macro_f1_margin': .02, 'accuracy_tolerance': .01}, + 'scope': 'Fixed alpha=1; train-only scaling and label fit. Development labels score only. ' + 'No hyperparameter search or representation selection. Caller supplies provenance ' + 'and separately evaluates representation collapse and latency.'} diff --git a/scripts/csi_training/predictive_transfer.py b/scripts/csi_training/predictive_transfer.py new file mode 100644 index 0000000000..1b0e2df8b9 --- /dev/null +++ b/scripts/csi_training/predictive_transfer.py @@ -0,0 +1,232 @@ +"""Fixed two-arm masked-CSI representation experiment; VICReg-inspired, not reproduction.""" +import argparse +import copy +import hashlib +import math +from pathlib import Path +import time +import numpy as np +import torch +from torch.nn import functional as F +from safetensors.torch import load, save_file +from .predictive import (SPEC as BASE_SPEC, Predictor, PatchEncoder, FrozenEncoder, + _read, _json, _write, amplitudes, normalize, validate_parts, split_training, + make_mask, objective, ema_update, _diagnostics, synthetic) + +PUBLIC_BASELINE_SHA256='be5cbf9a740fa73e46bdb4ba3cdd25e2c41b5c47d8a629d906658816f7207f7c' + +SPEC={'schema':'predictive_transfer_v1','base':BASE_SPEC,'epochs':5,'batch_size':8,'seed':42, + 'arms':{'baseline':{'record_variance':0.,'record_covariance':0.}, + 'candidate':{'record_variance':1.,'record_covariance':.1}}, + 'record_regularizer':'unmasked pooled32 embeddings; mean relu(1-sqrt(population_variance+1e-4)); squared offdiagonal unbiased covariance sum/32', + 'scope':'fixed research comparison inspired by VICReg, not reproduction; no confirmation or occupancy claim', + 'promotionAuthorized':False} + + +def record_penalties(embeddings): + if embeddings.ndim!=2 or embeddings.shape[1]!=32 or len(embeddings)<2: + raise ValueError('Record penalty requires at least two32-dimensional embeddings') + centered=embeddings-embeddings.mean(0) + variance=F.relu(1.-torch.sqrt(centered.square().mean(0)+1e-4)).mean() + covariance=centered.T@centered/(len(embeddings)-1) + off_diagonal=covariance-torch.diag_embed(covariance.diagonal()) + return variance,off_diagonal.square().sum()/32 + + +def transfer_objective(student,teacher,x,mask,arm): + if arm not in SPEC['arms']:raise ValueError('Unknown fixed arm') + loss,diagnostic,target=objective(student,teacher,x,mask) + if arm=='candidate': + variance,covariance=record_penalties(student.encoder(x).mean(1)) + loss=loss+variance+.1*covariance + diagnostic=dict(diagnostic,record_variance=variance.detach(),record_covariance=covariance.detach()) + return loss,diagnostic,target + + +def _state_hash(state): + result=hashlib.sha256() + for key,value in sorted(state.items()): + result.update(key.encode());result.update(value.detach().cpu().contiguous().numpy().tobytes()) + return result.hexdigest() + + +class TransferEncoder(FrozenEncoder): + """Versioned frozen online encoder; inherited bounded encode and adapter API.""" + def __init__(self,directory): + root=Path(directory);status=_json(_read(root/'status.json',65536));report=_json(_read(root/'metadata.json',65536)) + if (not isinstance(status,dict) or not isinstance(report,dict) or status.get('status')!='complete' + or report.get('status')!='complete' or status.get('promotionAuthorized') is not False + or report.get('promotionAuthorized') is not False or report.get('spec')!=SPEC + or report.get('arm') not in ('baseline','candidate','untrained')): + raise ValueError('Invalid complete transfer artifact') + data=_read(root/'model.safetensors',2*1024**2) + if hashlib.sha256(data).hexdigest()!=report.get('model_sha256'):raise ValueError('Transfer model hash mismatch') + state=load(data);self.encoder=PatchEncoder().eval().requires_grad_(False);expected=self.encoder.state_dict() + if set(state)!=set(expected)|{'normalization.mean','normalization.scale'}:raise ValueError('Invalid transfer tensor keys') + for key,value in expected.items(): + if state[key].shape!=value.shape or state[key].dtype!=value.dtype or not torch.isfinite(state[key]).all():raise ValueError('Invalid transfer tensor') + self.encoder.load_state_dict({k:state[k] for k in expected}) + self.mean=state['normalization.mean'].numpy();self.scale=state['normalization.scale'].numpy() + if (self.mean.shape!=(52,) or self.scale.shape!=(52,) or self.mean.dtype!=np.float64 or self.scale.dtype!=np.float64 + or not np.isfinite(self.mean).all() or not np.isfinite(self.scale).all() or not (self.scale>0).all()): + raise ValueError('Invalid train normalization') + + +def _frozen(encoder,mean,scale): + bundle=TransferEncoder.__new__(TransferEncoder);bundle.encoder=encoder.eval().requires_grad_(False) + bundle.mean=mean;bundle.scale=scale + return bundle + + +def _save_encoder(root,arm,encoder,mean,scale,source_hashes,lineage): + root.mkdir();_write(root/'status.json',{'status':'running','promotionAuthorized':False}) + state={k:v.detach().cpu().contiguous() for k,v in encoder.state_dict().items()} + state.update({'normalization.mean':torch.from_numpy(mean),'normalization.scale':torch.from_numpy(scale)}) + save_file(state,str(root/'model.safetensors')) + replay=load(_read(root/'model.safetensors',2*1024**2)) + if any(not torch.equal(state[k],replay[k]) for k in state):raise ValueError('Export changed encoder') + metadata={'status':'complete','promotionAuthorized':False,'spec':SPEC,'arm':arm, + 'model_sha256':hashlib.sha256(_read(root/'model.safetensors',2*1024**2)).hexdigest(), + 'source_sha256':source_hashes,'lineage':lineage} + _write(root/'metadata.json',metadata) + # Remains running until every campaign gate/evaluation and source check finishes. + return metadata + + +def statistics(values): + x=amplitudes(values).astype(np.float64) + return np.concatenate((x.mean(-1),x.std(-1),np.abs(np.diff(x,axis=-1)).mean(-1)),axis=1) + + +def run(parts,lineage,output,max_seconds=240,baseline_artifact=None): + if type(max_seconds) not in (int,float) or not math.isfinite(max_seconds) or not 1<=max_seconds<=3600: + raise ValueError('Invalid time budget') + validate_parts(parts) + if not isinstance(lineage,dict) or lineage.get('evidence') not in ('SYNTHETIC','PUBLIC_TRAIN_ONLY'): + raise ValueError('Explicit original-training or synthetic lineage required') + import json + if len(json.dumps(lineage,allow_nan=False))>16384:raise ValueError('Oversized lineage') + for part in parts.values(): + y=np.asarray(part['y']) + if y.shape!=(len(part['x']),) or y.dtype.kind not in 'iu' or set(y)!=set(range(7)): + raise ValueError('Seven numeric activity classes required for frozen probe comparison') + reference=None;reference_hash=None + if baseline_artifact is not None: + reference_bytes=_read(baseline_artifact,2*1024**2);reference_hash=hashlib.sha256(reference_bytes).hexdigest() + if lineage['evidence']=='PUBLIC_TRAIN_ONLY' and reference_hash!=PUBLIC_BASELINE_SHA256: + raise ValueError('Public original baseline artifact hash mismatch') + reference=load(reference_bytes) + elif lineage['evidence']=='PUBLIC_TRAIN_ONLY': + raise ValueError('Original public baseline artifact is required') + from .predictive_probe import run_probes + names=('predictive_transfer.py','predictive.py','predictive_probe.py','espfi.py') + source_hashes={n:hashlib.sha256(_read(Path(__file__).with_name(n),1024**2)).hexdigest() for n in names} + root=Path(output);root.mkdir(parents=True,exist_ok=False);_write(root/'status.json',{'status':'running','promotionAuthorized':False}) + _write(root/'contract.json',SPEC) + started=time.monotonic() + def budget(): + if time.monotonic()-started>max_seconds:raise TimeoutError('Campaign budget exceeded between operations; no preemption guarantee') + try: + torch.manual_seed(42);torch.set_num_threads(1);torch.use_deterministic_algorithms(True) + x=parts['train']['x'].astype(np.float64);logged=np.log(np.maximum(x,1e-6)) + mean=logged.mean((0,2));scale=np.maximum(logged.std((0,2)),1e-6);training=normalize(x,mean,scale) + initial=Predictor();initial_hash=_state_hash(initial.state_dict()) + trained={};histories={};traces={} + for arm in ('baseline','candidate'): + student=copy.deepcopy(initial);teacher=copy.deepcopy(student.encoder).eval().requires_grad_(False) + optimizer=torch.optim.AdamW(student.parameters(),lr=1e-3,weight_decay=1e-4) + generator=torch.Generator().manual_seed(42);trace=hashlib.sha256();history=[] + for epoch in range(5): + order=torch.randperm(len(training),generator=generator);losses=[] + for offset in range(0,len(order),8): + budget();ids=order[offset:offset+8] + if len(ids)<2:continue + mask=make_mask(len(ids),generator);trace.update(ids.numpy().tobytes());trace.update(mask.numpy().tobytes()) + optimizer.zero_grad(set_to_none=True) + loss,_,_=transfer_objective(student,teacher,training[ids],mask,arm) + if not torch.isfinite(loss):raise ValueError('Nonfinite representation loss') + loss.backward();torch.nn.utils.clip_grad_norm_(student.parameters(),1.);optimizer.step();ema_update(teacher,student) + losses.append(float(loss.detach())) + history.append({'epoch':epoch+1,'objective':float(np.mean(losses))}) + histories[arm]=history;traces[arm]=trace.hexdigest();trained[arm]=student.encoder.eval() + if arm=='baseline' and reference is not None: + actual=dict(student.encoder.state_dict(),**{'normalization.mean':torch.from_numpy(mean),'normalization.scale':torch.from_numpy(scale)}) + if set(actual)!=set(reference) or any(not torch.equal(actual[k],reference[k]) for k in actual): + raise ValueError('Original baseline state did not reproduce exactly') + if len(set(traces.values()))!=1:raise ValueError('Arm mask or sample order differs') + # Both fixed arms finish before any heldout representation is evaluated. + _write(root/'representation_frozen.json',{'spec':SPEC,'initial_state_sha256':initial_hash, + 'mask_order_sha256':traces,'history':histories,'source_sha256':source_hashes}) + trained['untrained']=copy.deepcopy(initial.encoder).eval() + frozen={name:_frozen(encoder,mean,scale) for name,encoder in trained.items()} + metadata={name:_save_encoder(root/name,name,bundle.encoder,mean,scale,source_hashes,lineage) for name,bundle in frozen.items()} + embeddings={};diagnostics={};timings={} + for name,bundle in frozen.items(): + budget();embeddings[name]={'train':bundle.encode(parts['train']['x']),'dev':bundle.encode(parts['heldout']['x'])} + # Validate exported state produces the same heldout embeddings before completion. + replay=load(_read(root/name/'model.safetensors',2*1024**2));restored=PatchEncoder().eval().requires_grad_(False) + restored.load_state_dict({k:replay[k] for k in restored.state_dict()}) + replay_bundle=_frozen(restored,replay['normalization.mean'].numpy(),replay['normalization.scale'].numpy()) + if not np.array_equal(replay_bundle.encode(parts['heldout']['x']),embeddings[name]['dev']):raise ValueError('Reload changes embeddings') + with torch.no_grad(): + tokens=torch.cat([bundle.encoder(normalize(parts['heldout']['x'][i:i+8],mean,scale)) for i in range(0,len(parts['heldout']['x']),8)]) + diagnostics[name]=_diagnostics(tokens) + # Same input and alternating order in each paired timing round. + for i in range(10): + j=i%len(parts['heldout']['x']) + for bundle in frozen.values():bundle.encode(parts['heldout']['x'][j:j+1]) + timings={name:[] for name in frozen} + for round_id in range(3): + elapsed={name:[] for name in frozen} + for i in range(100): + budget();j=i%len(parts['heldout']['x']);sample=parts['heldout']['x'][j:j+1] + calls=list(frozen.items()) + if i%2:calls.reverse() + for name,bundle in calls: + before=time.perf_counter_ns();bundle.encode(sample) + elapsed[name].append((time.perf_counter_ns()-before)/1e6) + for name,values in elapsed.items(): + timings[name].append({'round':round_id+1,'p50_ms':float(np.percentile(values,50)), + 'p95_ms':float(np.percentile(values,95))}) + embeddings['statistics']={key:statistics(parts[split]['x']) for key,split in [('train','train'),('dev','heldout')]} + budget();probes=run_probes(embeddings,parts['train']['y'],parts['heldout']['y']);budget() + checks={'probe_gate':probes['gate']['passed'], + 'candidate_effective_rank_at_least2':diagnostics['candidate']['recording_effective_rank']>=2., + 'p95_within20percent_all_rounds':all(c['p95_ms']<=1.2*b['p95_ms'] for c,b in zip(timings['candidate'],timings['baseline']))} + report={'status':'complete','promotionAuthorized':False,'spec':SPEC,'lineage':lineage,'evidence':lineage['evidence'], + 'initial_state_sha256':initial_hash,'mask_order_sha256':traces,'histories':histories, + 'original_baseline_sha256':reference_hash,'original_baseline_exact':True if reference is not None else None, + 'models':metadata,'collapse':diagnostics,'inference_latency':timings,'probes':probes,'checks':checks, + 'research_gate_passed':all(checks.values()),'embedding_shapes':{name:{k:list(v.shape) for k,v in rows.items()} for name,rows in embeddings.items()}, + 'source_sha256':source_hashes,'torch_version':torch.__version__,'numpy_version':np.__version__, + 'elapsed_seconds':time.monotonic()-started,'scope':'Fixed two-arm adaptive research on reused original training groups. No original validation/test payload, no heldout updates or selection. Encoder timings3x100 alternating paired batch1 include checks/normalization/encode, exclude acquisition/IO. No occupancy or confirmation claim.'} + if any(hashlib.sha256(_read(Path(__file__).with_name(n),1024**2)).hexdigest()!=value for n,value in source_hashes.items()):raise ValueError('Source changed during campaign') + budget();_write(root/'metrics.json',report) + for name in frozen:_write(root/name/'status.json',{'status':'complete','promotionAuthorized':False}) + _write(root/'status.json',{'status':'complete','promotionAuthorized':False}) + return report + except Exception: + for name in ('baseline','candidate','untrained'): + if (root/name).is_dir():_write(root/name/'status.json',{'status':'failed','promotionAuthorized':False}) + _write(root/'status.json',{'status':'failed','promotionAuthorized':False});raise + + +def main(): + parser=argparse.ArgumentParser(description=__doc__);group=parser.add_mutually_exclusive_group(required=True) + group.add_argument('--synthetic',action='store_true');group.add_argument('--manifest');parser.add_argument('--output',required=True) + parser.add_argument('--max-seconds',type=int,default=240);parser.add_argument('--baseline-artifact');args=parser.parse_args() + if args.synthetic: + parts,lineage=synthetic() + for part in parts.values():part['y']=np.arange(len(part['x']),dtype=np.int64)%7 + else: + if not args.baseline_artifact:parser.error('--baseline-artifact is required for public comparison') + from .espfi import _load_partitions + admitted,meta=_load_partitions(args.manifest,('train',));part=admitted['train'];parts=split_training(part['x'],part['identity']) + ids=part['identity'];fit=(ids[:,0]==1)&np.isin(ids[:,1],[1,2,3,4]);held=(ids[:,0]==2)&np.isin(ids[:,1],[5,6]) + parts['train']['y']=part['y'][fit];parts['heldout']['y']=part['y'][held] + lineage={'evidence':'PUBLIC_TRAIN_ONLY','manifest_sha256':meta['manifest_sha256'],'loaded_partitions':['train'], + 'source_revision':meta['source_revision'],'inner_fit':'room1subjects1..4','inner_heldout':'room2subjects5,6'} + import json + print(json.dumps(run(parts,lineage,args.output,args.max_seconds,args.baseline_artifact),indent=2)) + +if __name__=='__main__':main() diff --git a/scripts/csi_training/zenodo_mat.py b/scripts/csi_training/zenodo_mat.py new file mode 100644 index 0000000000..495a3f7d98 --- /dev/null +++ b/scripts/csi_training/zenodo_mat.py @@ -0,0 +1,132 @@ +"""Bounded v7.3 CSI diagnostic. Native axes retained; not a training adapter.""" +import argparse +import hashlib +import io +import json +import math + +import h5py +import numpy as np + +from .data import _open_regular +from .zenodo_sample import MAX_MEMBER + +FRAME_CHUNK = 32 + + +def _diagnostics(data, frames): + """Aggregate native CSI quality without materializing the full recording.""" + zero_count = total_count = chunks_read = 0 + amplitude_min, amplitude_max = math.inf, 0.0 + antenna_zeros = np.zeros(data.shape[0], dtype=np.uint64) + frame_nonzero = np.empty(frames, dtype=np.float64) + worst_fraction = math.inf + worst_index = 0 + worst_antenna_nonzero = None + for start in range(0, frames, FRAME_CHUNK): + stop = min(start + FRAME_CHUNK, frames) + values = data[:, :, start:stop] + real, imag = values['real'], values['imag'] + finite = np.isfinite(real) & np.isfinite(imag) + if not finite.all(): + raise ValueError('Nonfinite CSI in inspected frames') + amplitude = np.hypot(real, imag) + if not np.isfinite(amplitude).all(): + raise ValueError('Amplitude overflow') + zeros = amplitude == 0 + count = amplitude.size + zero_count += int(zeros.sum(dtype=np.uint64)) + total_count += count + antenna_zeros += zeros.sum(axis=(1, 2), dtype=np.uint64) + frame_nonzero[start:stop] = (~zeros).mean(axis=(0, 1)) + local_index = int(np.argmin(frame_nonzero[start:stop])) + local_min = float(frame_nonzero[start + local_index]) + # Strict comparison keeps the earliest frame when minima tie, including + # across chunk boundaries. Copy only one bounded antenna vector. + if local_min < worst_fraction: + worst_fraction = local_min + worst_index = start + local_index + worst_antenna_nonzero = (~zeros[:, :, local_index]).mean(axis=1).tolist() + amplitude_min = min(amplitude_min, float(amplitude.min())) + amplitude_max = max(amplitude_max, float(amplitude.max())) + chunks_read += 1 + per_antenna_denominator = data.shape[1] * frames + return { + 'inspected_frames': frames, + 'decoded_complex_values': total_count, + 'chunks_read': chunks_read, + 'frame_chunk_limit': FRAME_CHUNK, + 'finite_fraction': 1.0, + 'zero_amplitude_fraction': zero_count / total_count, + 'zero_amplitude_fraction_by_antenna': [ + int(value) / per_antenna_denominator for value in antenna_zeros], + 'frame_nonzero_fraction_min': float(frame_nonzero.min()), + 'worst_frame_index_zero_based': worst_index, + 'worst_frame_tie_count': int((frame_nonzero == worst_fraction).sum()), + 'worst_frame_nonzero_fraction_by_antenna': worst_antenna_nonzero, + 'frame_nonzero_fraction_p05': float(np.quantile(frame_nonzero, .05)), + 'frame_nonzero_fraction_median': float(np.median(frame_nonzero)), + 'frame_nonzero_fraction_mean': float(frame_nonzero.mean()), + 'all_zero_frames': int((frame_nonzero == 0).sum()), + 'amplitude_min': amplitude_min, + 'amplitude_max': amplitude_max, + } + + +def inspect(path, expected_sha256, all_frames=False): + """Inspect the first chunk or whole recording after identity/metadata checks. + + Run in a resource-limited process for hostile files: these checks do not + sandbox the native HDF5 parser. Parent directories must be trusted. + """ + if type(all_frames) is not bool: + raise ValueError('all_frames must be boolean') + if (not isinstance(expected_sha256, str) or len(expected_sha256) != 64 + or any(c not in '0123456789abcdef' for c in expected_sha256)): + raise ValueError('Expected SHA256 required') + with _open_regular(path, MAX_MEMBER) as (stream, size): + raw = stream.read(MAX_MEMBER + 1) + if len(raw) != size or hashlib.sha256(raw).hexdigest() != expected_sha256: + raise ValueError('Sample size or hash mismatch') + with h5py.File(io.BytesIO(raw), 'r') as source: + if not isinstance(source.get('csi', getlink=True), h5py.HardLink): + raise ValueError('CSI must be a local hard link') + data = source['csi'] + if not isinstance(data, h5py.Dataset): + raise ValueError('CSI dataset required') + shape = data.shape + if (shape is None or len(shape) != 3 or not 1 <= shape[0] <= 8 + or not 1 <= shape[1] <= 1024 or not 1 <= shape[2] <= 100000 + or data.dtype != np.dtype([('real', ' MAX_MEMBER: + raise ValueError('Oversized decompression chunk') + # MATLAB v7.3 reverses dimension order: antenna, subcarrier, frame. + frames = shape[2] if all_frames else min(FRAME_CHUNK, shape[2]) + report = _diagnostics(data, frames) + report.update({'sha256': expected_sha256, + 'source_shape_antenna_subcarrier_frame': list(shape), + 'all_csi_frames_decoded': frames == shape[2], + 'validation_scope': 'csi_dataset_values_only', + 'training_eligible': False, 'promotionAuthorized': False, + 'occupancy_inferred': False}) + return report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('path') + parser.add_argument('--sha256', required=True) + parser.add_argument('--all-frames', action='store_true') + args = parser.parse_args() + print(json.dumps(inspect(args.path, args.sha256, args.all_frames), indent=2, allow_nan=False)) + + +if __name__ == '__main__': + main() diff --git a/scripts/csi_training/zenodo_sample.py b/scripts/csi_training/zenodo_sample.py new file mode 100644 index 0000000000..4851b4413e --- /dev/null +++ b/scripts/csi_training/zenodo_sample.py @@ -0,0 +1,149 @@ +"""Bounded research-only sample intake from Zenodo 18924941; no archive extraction.""" +import argparse +import hashlib +import io +import json +from pathlib import Path +import stat +import time +import urllib.request +import zipfile + +URL = 'https://zenodo.org/api/records/18924941/files/Zenodo.zip/content' +ARCHIVE_BYTES = 16125799937 +MAX_REQUEST = 4 * 1024 * 1024 +MAX_TRANSFER = 12 * 1024 * 1024 +MAX_MEMBER = 4 * 1024 * 1024 +SESSIONS = { + 'empty': 'E - Empty/2025-07-23_19-54-37', + 'sitting': 'S - Sitting/2025-07-23_18-06-23', + 'walking': 'W - Walking/2025-07-23_19-28-30', +} + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise ValueError('Unexpected redirect from pinned source') + + +class HttpRanges: + def __init__(self): + self.opener = urllib.request.build_opener(NoRedirect) + self.etag = None + self.seen_response = False + + def __call__(self, start, count): + headers = {'Range': f'bytes={start}-{start+count-1}', 'Accept-Encoding': 'identity'} + if self.etag is not None: headers['If-Match'] = self.etag + request = urllib.request.Request(URL, headers=headers) + with self.opener.open(request, timeout=20) as response: + if (response.status != 206 or response.geturl() != URL + or response.headers.get('Content-Range') != f'bytes {start}-{start+count-1}/{ARCHIVE_BYTES}' + or response.headers.get('Content-Encoding', 'identity') != 'identity'): + raise ValueError('Server did not honor exact bounded range') + etag = response.headers.get('ETag') + if etag and etag.startswith('W/'): + raise ValueError('Weak ETag is not supported') + if self.seen_response and etag != self.etag: + raise ValueError('Source changed between range reads') + self.etag = etag + self.seen_response = True + result = response.read(count + 1) + if len(result) != count: raise ValueError('Range length mismatch') + return result + + +class RangeReader(io.RawIOBase): + def __init__(self, fetch, size=ARCHIVE_BYTES): + super().__init__() + if type(size) is not int or not 1 <= size <= ARCHIVE_BYTES: + raise ValueError('Invalid archive size') + self.fetch, self.size, self.position = fetch, size, 0 + self.transferred = self.requests = 0 + self.started = time.monotonic() + + def readable(self): return True + def seekable(self): return True + def tell(self): return self.position + + def seek(self, offset, whence=0): + if type(offset) is not int or type(whence) is not int or whence not in (0, 1, 2): + raise ValueError('Invalid seek') + position = offset + (0 if whence == 0 else self.position if whence == 1 else self.size) + if not 0 <= position <= self.size: raise ValueError('Seek outside archive') + self.position = position + return position + + def read(self, count=-1): + if type(count) is not int or count < -1: raise ValueError('Invalid read count') + count = self.size-self.position if count == -1 else min(count, self.size-self.position) + if not count: return b'' + if (count > MAX_REQUEST or self.transferred+count > MAX_TRANSFER + or self.requests >= 128 or time.monotonic()-self.started > 120): + raise ValueError('Archive read budget exceeded') + self.requests += 1;self.transferred += count + result = self.fetch(self.position, count) + if type(result) is not bytes or len(result) != count: + raise ValueError('Invalid range response') + self.position += count + return result + + +def member_bytes(archive, name): + matches = [info for info in archive.infolist() if info.filename == name] + if len(matches) != 1: raise ValueError('Missing or duplicate selected member') + info = matches[0] + mode = info.external_attr >> 16 + if (info.is_dir() or stat.S_ISLNK(mode) or info.flag_bits & 1 + or info.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED) + or not 0 < info.file_size <= MAX_MEMBER or info.compress_size > MAX_MEMBER): + raise ValueError('Unsupported or oversized selected member') + with archive.open(info) as handle: + value = handle.read(info.file_size + 1) + if len(value) != info.file_size: raise ValueError('Expanded member length mismatch') + return value + + +def intake(output, fetch=None): + root = Path(output);root.mkdir(parents=True, exist_ok=False) + status = root/'status.json' + status.write_text('{"status":"running","promotionAuthorized":false}\n') + reader = RangeReader(fetch if fetch is not None else HttpRanges()) + try: + artifacts = [] + with zipfile.ZipFile(reader) as archive: + if len(archive.infolist()) > 4096: raise ValueError('Too many archive members') + for label, session in SESSIONS.items(): + prefix = 'Zenodo/NoShield/beacon/' + session + '/' + for source_name, suffix in [('config.json', 'json'), ('capture1_extracted.mat', 'mat')]: + member = prefix + source_name + value = member_bytes(archive, member) + destination = label + '.' + suffix + with (root/destination).open('xb') as handle: handle.write(value) + artifacts.append({'label': label, 'member': member, 'file': destination, + 'bytes': len(value), 'sha256': hashlib.sha256(value).hexdigest()}) + report = {'status': 'complete', 'evidence': 'PUBLIC_SAMPLE_INTAKE' if fetch is None else 'SYNTHETIC', + 'source': URL, 'record': '10.5281/zenodo.18924941', 'archive_bytes': ARCHIVE_BYTES, + 'archive_md5_publisher': '0eacb8856c2f37d43bda98a988d07a56', 'full_archive_hash_verified': False, + 'snapshot_verified': False, + 'integrity': 'HTTPS exact ranges, ZIP CRC and local member SHA256; not full archive or snapshot verification', + 'license_catalog': 'CC-BY-4.0', 'license_readme': 'CC-BY-SA-4.0', 'license_conflict': True, + 'usage': 'Private intake research only; retain both notices; no redistribution or model promotion', + 'transferred_bytes': reader.transferred, 'requests': reader.requests, + 'artifacts': artifacts, 'elapsed_seconds': time.monotonic()-reader.started, + 'promotionAuthorized': False, 'training_performed': False} + (root/'receipt.json').write_text(json.dumps(report, indent=2)+'\n') + status.write_text('{"status":"complete","promotionAuthorized":false}\n') + return report + except Exception: + status.write_text('{"status":"failed","promotionAuthorized":false}\n') + raise + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', required=True) + args = parser.parse_args() + print(json.dumps(intake(args.output), indent=2)) + +if __name__ == '__main__': main() diff --git a/scripts/evaluate-active-evidence.py b/scripts/evaluate-active-evidence.py new file mode 100644 index 0000000000..35ab146e32 --- /dev/null +++ b/scripts/evaluate-active-evidence.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Evaluate paired room intervals; prints aggregate metrics, never observations.""" +import argparse +import json +from csi_training.active_evaluation import evaluate, load + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("manifest") + parser.add_argument( + "--require-pass", action="store_true", + help="exit 3 unless the valid evaluation overallGate is pass") + args = parser.parse_args(argv) + try: + result = evaluate(load(args.manifest)) + except (ValueError, OSError, UnicodeError) as error: + parser.exit(2, f"invalid evaluation input: {type(error).__name__}\n") + print(json.dumps(result, indent=2, allow_nan=False), flush=True) + if args.require_pass and result["overallGate"] != "pass": + parser.exit(3, f"active evaluation gate: {result['overallGate']}\n") + return result + + +if __name__ == "__main__": + main() diff --git a/scripts/field-context-probe/README.md b/scripts/field-context-probe/README.md index 7c72bd6a52..41aa7d7801 100644 --- a/scripts/field-context-probe/README.md +++ b/scripts/field-context-probe/README.md @@ -92,3 +92,67 @@ adapter: required numeric timestamp and amplitude array, finite nonnegative values, nanoseconds below u64 maximum, amplitudes within f32 range, 1–2,048 channels, fixed channel width and strictly increasing integer nanosecond times. These format/representation guards do not certify capture provenance or labels. + +## Active evidence replay and bounded request proposals + +```sh +cargo run --release --locked --quiet -- --active-loop +cargo run --release --locked --quiet -- --active-loop --ranking ranking.json +cat ranking.json | cargo run --release --locked --quiet -- --active-loop --ranking - +cargo run --release --locked --quiet -- --active-benchmark +``` + +`--active-loop` runs an authored **SYNTHETIC** sequence: explicit empty, +occupied, contradictory sources causing unknown, agreeing occupied sources, +and expired observations returning to unknown. Missing updates or motionlessness +never create empty evidence. Each accepted root is signed and verified with the +actual pinned RuField implementation; actual WorldGraph evidence nodes and +`DerivedFrom` relationships record the supporting roots. A wrapper recomputes +expiry using a monotonic authored clock. Persisted WorldGraph states are history, +not an occupancy authorization interface: callers must consult the wrapper's +current snapshot, never treat an old graph node as still valid. + +Only the latest fresh root per logical source participates. Root IDs and hashes +are immutable and unique within a run; nonempty dependency declarations are +rejected instead of treating derived feedback as independent evidence. Source +timestamps must increase, and the 32-root or 32-belief capacity limit latches a +fail-closed unknown state. Capacity exhaustion is an intentional sticky safety +mutation; other rejected ingestion leaves both evidence and the accepted clock +unchanged. Independent sources are assumed by the fixture; +this does not establish physical statistical independence. Wifi, radar and door +are logical source names using signed WiFi feature fixtures, not production +radar or door adapters. Presence values, thresholds and costs are authored. +Native fusion scores are reported as uncalibrated heuristics, not probabilities. + +The optional KGE sidecar input has this exact schema: + +```json +{"schemaVersion":1,"evidence":"SYNTHETIC","candidates":[{"sensorId":"radar","score":2.3},{"sensorId":"door","score":1.1}]} +``` + +Input is limited to 4,096 bytes and one to three unique allowlisted sensor IDs +(`wifi`, `radar`, `door`). Unknown fields, duplicate object keys, repeated sources, +nonfinite scores and nonregular file inputs are rejected. On Linux x86_64 and +AArch64 the file reader opens with `O_NOFOLLOW | O_NONBLOCK`, then validates the +opened descriptor as a bounded regular file. Unsupported platforms reject file +paths; bounded stdin remains portable. Higher scores only +change proposal ordering, with lexical source order breaking ties. They cannot +modify evidence, belief state or fusion confidence and cannot trigger hardware +or external actions. This is a sidecar contract, not a claim that a KGE model was +trained. Rank scores can be outside zero to one and are never probabilities. + +The initial proposal reservation budget is 2 packets, 2,200 microseconds of +airtime and 220 microjoules. Authored per-request costs are respectively: +WiFi `(2, 1000, 100)`, radar `(1, 2000, 200)`, and door `(1, 100, 10)`. +Every reservation must fit all three remaining limits atomically; an unaffordable +candidate can be skipped for an affordable suggestion. Reservations are for +proposals only; no packets are transmitted and no energy use is measured. + +The active benchmark reports three rounds of 1,000 alternating baseline/active +pairs after 100 warmups per round, including p50 and p95 for each path and their +paired difference. The baseline verifies, ingests and fuses one signed root. +The active path additionally checks root policy, selects current sources, +fuses selected evidence into an engine, publishes WorldGraph ancestry and creates a +receipt. Both construct fresh engines. Signing, physical sensing, ranking file +I/O and persistent-stream throughput are excluded. Release mode is required for +reported comparisons. Timing establishes software cost only, not sensing uplift. diff --git a/scripts/field-context-probe/src/active.rs b/scripts/field-context-probe/src/active.rs new file mode 100644 index 0000000000..a637d7fa6e --- /dev/null +++ b/scripts/field-context-probe/src/active.rs @@ -0,0 +1,359 @@ +//! SYNTHETIC active evidence replay. All authority lives in this bounded wrapper. +use super::*; +use std::io::Read; + +const SOURCES: [&str; 3] = ["wifi", "radar", "door"]; +const RANK_BYTES: u64 = 4096; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum State { Empty, Occupied, Unknown } +impl State { + fn name(self) -> &'static str { match self { Self::Empty => "empty", Self::Occupied => "occupied", Self::Unknown => "unknown" } } +} +#[derive(Clone)] +struct Root { event: FieldEvent, node: WorldId, state: State, expires: u64 } +struct Active { + world: WorldGraph, + roots: BTreeMap, + hashes: BTreeSet, + latest: BTreeMap, + clock: u64, + exhausted: bool, + beliefs: usize, + last_published: Option, // Historical handle, never a current-state read. + budget: Budget, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Budget { packets: u32, airtime_us: u32, energy_uj: u32 } +impl Budget { + fn json(self) -> Value { json!({"packets":self.packets,"airtime_us":self.airtime_us,"energy_uj":self.energy_uj}) } + fn reserve(&mut self, cost: Self) -> bool { + if cost.packets > self.packets || cost.airtime_us > self.airtime_us || cost.energy_uj > self.energy_uj { return false; } + self.packets -= cost.packets; self.airtime_us -= cost.airtime_us; self.energy_uj -= cost.energy_uj; + true + } +} +fn cost(source: &str) -> Budget { + match source { + "wifi" => Budget { packets: 2, airtime_us: 1000, energy_uj: 100 }, + "radar" => Budget { packets: 1, airtime_us: 2000, energy_uj: 200 }, + "door" => Budget { packets: 1, airtime_us: 100, energy_uj: 10 }, + _ => unreachable!("source is checked before costing"), + } +} +fn active_fixture(id: &str, source: &str, now: u64, state: State) -> FieldEvent { + let mut event = fixture(id, now); + event.sensor.device_id = source.into(); + // Modalities remain wifi_csi to exercise the pinned fixture rule for every + // logical source; these are NOT real radar or door device adapters. + let feature = match state { State::Empty => 0.1, State::Occupied => 0.9, State::Unknown => 0.5 }; + event.observation.features.insert("presence".into(), feature); + event.provenance.raw_hash = sha256_hex(format!("SYNTHETIC active {id} {source} {now} {}", state.name()).as_bytes()); + Signer::from_seed(&[17;32]).sign_event(&mut event).unwrap(); + event +} +impl Active { + fn new() -> Self { + Self { world: WorldGraph::new(Default::default()), roots: BTreeMap::new(), hashes: BTreeSet::new(), + latest: BTreeMap::new(), clock: 0, exhausted: false, beliefs: 0, last_published: None, + budget: Budget { packets: 2, airtime_us: 2200, energy_uj: 220 } } + } + fn tick(&mut self, now: u64) -> Result<(), String> { + if now < self.clock { return Err("clock_regression".into()); } + self.clock = now; Ok(()) + } + fn ingest(&mut self, event: FieldEvent, dependencies: &[String], now: u64) -> Result<(), String> { + if now < self.clock { return Err("clock_regression".into()); } + if self.exhausted { return Err("capacity_fail_closed".into()); } + if !dependencies.is_empty() { return Err("derived_evidence_not_an_independent_root".into()); } + if !event.provenance.synthetic { return Err("synthetic_only".into()); } + let source = event.sensor.device_id.clone(); + if !SOURCES.contains(&source.as_str()) { return Err("unknown_source".into()); } + if event.timestamp_ns > now || now - event.timestamp_ns >= MAX_AGE { return Err("stale_or_future".into()); } + if self.roots.contains_key(&event.event_id) || self.hashes.contains(&event.provenance.raw_hash) { return Err("duplicate_root".into()); } + if let Some(previous) = self.latest.get(&source) { + if event.timestamp_ns <= self.roots[previous].event.timestamp_ns { return Err("source_clock_nonmonotonic".into()); } + } + if self.roots.len() >= MAX_EVENTS { self.exhausted = true; return Err("capacity_fail_closed".into()); } + let value = event.observation.features.get("presence").copied().ok_or("missing_explicit_presence")?; + if !value.is_finite() || !(0.0..=1.0).contains(&value) { return Err("invalid_presence_feature".into()); } + let state = if value >= 0.7 { State::Occupied } else if value <= 0.3 { State::Empty } else { State::Unknown }; + let expires = event.timestamp_ns.checked_add(MAX_AGE).ok_or("expiry_overflow")?; + verify_event(&event).map_err(|_| "signature_invalid")?; + // Real pinned library verification/ingestion before mutating WorldGraph. + let mut fusion = RuFieldFusion::with_rules(RuleSet::from_toml(RULES).unwrap()); + fusion.ingest_at(event.clone(), now).map_err(|e| e.to_string())?; + let node = self.world.upsert_node(WorldNode::Event { id: WorldId::UNASSIGNED, + event_type: format!("SYNTHETIC immutable root {}", event.event_id), + at_unix_ms: i64::try_from(event.timestamp_ns / 1_000_000).map_err(|_| "timestamp_overflow")?, located_in: None }); + self.latest.insert(source, event.event_id.clone()); + self.hashes.insert(event.provenance.raw_hash.clone()); + self.roots.insert(event.event_id.clone(), Root { event, node, state, expires }); + self.clock = now; + Ok(()) + } + fn snapshot(&mut self, now: u64, publish: bool) -> Result { + self.tick(now)?; + let live: Vec<_> = self.latest.values().filter_map(|id| { + let root = &self.roots[id]; (root.expires > now).then_some(root.clone()) + }).collect(); + let occupied = live.iter().any(|r| r.state == State::Occupied); + let empty = live.iter().any(|r| r.state == State::Empty); + let uncertain = live.iter().any(|r| r.state == State::Unknown); + let contradiction = occupied && empty; + let state = if self.exhausted || live.is_empty() || contradiction || uncertain { State::Unknown } + else if occupied { State::Occupied } else { State::Empty }; + let expires = live.iter().map(|r| r.expires).min().unwrap_or(now); + // Fuse one latest root per source, never repeated correlated samples. + let mut ordered = live.clone(); ordered.sort_by_key(|r| r.event.timestamp_ns); + let mut fusion = RuFieldFusion::with_rules(RuleSet::from_toml(RULES).unwrap()); + for root in &ordered { fusion.ingest_at(root.event.clone(), now).map_err(|e| e.to_string())?; } + let inference = if ordered.is_empty() { None } else { fusion.infer(&InferenceQuery::all()).map_err(|e| e.to_string())?.into_iter().next() }; + let heuristic = inference.map(|i| i.confidence).filter(|s| s.is_finite() && (0.0..=1.0).contains(s)); + let handles: Vec<_> = live.iter().map(|r| r.event.event_id.clone()).collect(); + let mut belief = None; + if publish && !live.is_empty() && !self.exhausted { + if self.beliefs >= MAX_EVENTS { self.exhausted = true; return self.snapshot(now, false); } + let nodes: Vec<_> = live.iter().map(|r| r.node).collect(); + let id = self.world.add_semantic_state(format!("SYNTHETIC {}; wrapper expiry {expires}", state.name()), + heuristic.unwrap_or(0.0), (now / 1_000_000) as i64, + SemanticProvenance { evidence: handles.clone(), model_version: "synthetic_active_wrapper_v1".into(), + calibration_version: "uncalibrated".into(), privacy_decision: "SYNTHETIC local replay only".into() }, &nodes); + self.beliefs += 1; + self.last_published = Some(id); + belief = Some(json!({"ancestryEdges":self.world.neighbors(id).len(),"expiresNs":expires})); + } + Ok(json!({"atNs":now,"state":state.name(),"contradiction":contradiction,"expiresNs":expires, + "liveSourceCount":live.len(),"rootIds":handles,"fusionHeuristicScore":heuristic, + "scoreIsProbability":false,"capacityExhausted":self.exhausted,"belief":belief})) + } + fn propose(&mut self, ranking: &[(String, f64)], state: &Value) -> Value { + if state["state"] != "unknown" || self.exhausted { return json!({"requested":false,"reason":"not_needed_or_capacity","actuated":false}); } + for (source, score) in ranking { + let required = cost(source); + if self.budget.reserve(required) { + return json!({"requested":true,"sensorId":source,"rankingScore":score,"scoreIsProbability":false, + "proposalOnly":true,"actuated":false,"reservedAuthoredCost":required.json(),"remaining":self.budget.json()}); + } + } + json!({"requested":false,"reason":"budget_exhausted","actuated":false,"remaining":self.budget.json()}) + } +} + +// serde_json::Value accepts duplicate object keys; reject them before advice admission. +fn reject_duplicate_keys(text: &str) -> Result<(), String> { + let bytes=text.as_bytes(); let mut objects: Vec>=Vec::new(); let mut i=0; + while i { objects.push(BTreeSet::new()); i+=1; }, + b'}' => { objects.pop(); i+=1; }, + b'"' => { + let start=i; i+=1; + while i i+=1, + } + } + Ok(()) +} +pub fn parse_ranking(text: &str) -> Result, String> { + if text.len() as u64 > RANK_BYTES { return Err("ranking_byte_limit".into()); } + let value: Value = serde_json::from_str(text).map_err(|_| "invalid_ranking_json")?; + reject_duplicate_keys(text)?; + let object = value.as_object().ok_or("ranking_object_required")?; + if object.len() != 3 || value["schemaVersion"] != 1 || value["evidence"] != "SYNTHETIC" { return Err("ranking_contract".into()); } + let candidates = value["candidates"].as_array().ok_or("candidates_array_required")?; + if candidates.is_empty() || candidates.len() > SOURCES.len() { return Err("ranking_candidate_limit".into()); } + let mut seen = BTreeSet::new(); let mut result = Vec::new(); + for candidate in candidates { + let fields = candidate.as_object().ok_or("candidate_object_required")?; + if fields.len() != 2 { return Err("candidate_fields".into()); } + let source = candidate["sensorId"].as_str().ok_or("sensor_id_required")?; + let score = candidate["score"].as_f64().ok_or("score_required")?; + if !SOURCES.contains(&source) || !seen.insert(source.to_string()) || !score.is_finite() { return Err("invalid_ranking_candidate".into()); } + result.push((source.to_string(), score)); + } + result.sort_by(|a,b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0))); Ok(result) +} +// Linux x86_64/aarch64 UAPI fcntl flags. No fallback that follows symlinks +// or blocks opening a FIFO. The opened descriptor is checked before reading. +#[cfg(all(target_os="linux", any(target_arch="x86_64",target_arch="aarch64")))] +fn ranking_file(path: &str) -> Result { + use std::os::unix::fs::OpenOptionsExt; + const O_NOFOLLOW: i32=0x20000; + const O_NONBLOCK: i32=0x800; + let file=std::fs::OpenOptions::new().read(true).custom_flags(O_NOFOLLOW|O_NONBLOCK) + .open(path).map_err(|_| "ranking_file_open")?; + let metadata=file.metadata().map_err(|_| "ranking_file_metadata")?; + if !metadata.is_file() || metadata.len()>RANK_BYTES { return Err("ranking_file_limit".into()); } + Ok(file) +} +#[cfg(not(all(target_os="linux", any(target_arch="x86_64",target_arch="aarch64"))))] +fn ranking_file(_path: &str) -> Result { + Err("ranking_file_platform_unsupported_use_stdin".into()) +} +pub fn read_ranking(path: &str) -> Result, String> { + let mut data = Vec::new(); + if path == "-" { std::io::stdin().take(RANK_BYTES+1).read_to_end(&mut data).map_err(|_| "ranking_read")?; } + else { + ranking_file(path)?.take(RANK_BYTES+1).read_to_end(&mut data).map_err(|_| "ranking_read")?; + } + parse_ranking(std::str::from_utf8(&data).map_err(|_| "ranking_utf8")?) +} +pub fn demo(ranking: Option>) -> Value { + let ranking = ranking.unwrap_or_else(|| vec![("radar".into(),3.0),("door".into(),2.0),("wifi".into(),1.0)]); + let mut runtime = Active::new(); let mut steps = Vec::new(); + for (label, source, state, delta) in [ + ("explicit_empty", "wifi", State::Empty, 0), + ("person_observed", "wifi", State::Occupied, 100_000_000), + ("conflicting_source", "radar", State::Empty, 200_000_000), + ("radar_agrees", "radar", State::Occupied, 300_000_000), + ] { + runtime.ingest(active_fixture(label,source,NOW+delta,state), &[], NOW+delta).unwrap(); + let mut snapshot = runtime.snapshot(NOW+delta,true).unwrap(); + snapshot["scenario"] = json!(label); snapshot["proposal"] = runtime.propose(&ranking,&snapshot); steps.push(snapshot); + } + let mut expired = runtime.snapshot(NOW+MAX_AGE+300_000_000,true).unwrap(); + expired["scenario"] = json!("motionless_or_missing_updates_expire_to_unknown"); + expired["proposal"] = runtime.propose(&ranking,&expired); steps.push(expired); + let final_state = runtime.snapshot(NOW+MAX_AGE+300_000_001,false).unwrap(); + let budget_stop = runtime.propose(&ranking,&final_state); + let wire = runtime.world.to_json().unwrap(); + let restored = WorldGraph::from_json(&wire).unwrap(); + json!({"schemaVersion":1,"evidence":"SYNTHETIC","steps":steps,"budgetStop":budget_stop, + "worldGraph":{"nodes":runtime.world.node_count(),"edges":runtime.world.edge_count(), + "roundTripPreserved":restored.node_count()==runtime.world.node_count() && restored.edge_count()==runtime.world.edge_count()}, + "authority":{"rankingChangesEvidence":false,"actuation":false,"promotion":false}, + "scope":"Logical wifi/radar/door sources use signed WiFi feature fixtures, not physical modality adapters. Scores and costs are authored, not calibrated or measured sensing values.", + "expiryPolicy":"Wrapper recomputes live beliefs; persisted WorldGraph history is not a current authorization or occupancy API.", + "ruFieldRevision":"b74bc035e4a3d4023665aa5479b41e938facd113","worldGraphRevision":"9b1c79c836cdacfb7b44f058c593157bac4c1dab"}) +} + +pub fn benchmark_active() -> Value { + let event = active_fixture("benchmark-active","wifi",NOW,State::Occupied); + let active_once = || { let mut runtime=Active::new(); runtime.ingest(event.clone(), &[], NOW).unwrap(); std::hint::black_box(runtime.snapshot(NOW,true).unwrap()); }; + let mut rounds = Vec::new(); + for round in 0..3 { + for _ in 0..100 { native_once(&event); active_once(); } + let mut baseline=Vec::new(); let mut active=Vec::new(); + for iteration in 0..1000 { + let mut measure = |full: bool| { let start=std::time::Instant::now(); if full { active_once(); } else { native_once(&event); } + let ms=start.elapsed().as_secs_f64()*1000.; if full { active.push(ms); } else { baseline.push(ms); } }; + if iteration%2==0 { measure(false); measure(true); } else { measure(true); measure(false); } + } + let extra: Vec<_> = active.iter().zip(&baseline).map(|(a,b)|a-b).collect(); + rounds.push(json!({"round":round+1,"baseline_ms":{"p50":quantile(&baseline,0.5),"p95":quantile(&baseline,0.95)}, + "active_ms":{"p50":quantile(&active,0.5),"p95":quantile(&active,0.95)},"paired_extra_ms":{"p50":quantile(&extra,0.5),"p95":quantile(&extra,0.95)}})); + } + json!({"evidence":"MEASURED software timing on SYNTHETIC fixture","rounds":rounds,"iterationsPerRound":1000,"warmupsPerRound":100, + "scope":"Fresh engines per iteration; baseline verifies, ingests and fuses one signed root. Active adds bounded root intake, latest-source selection, refusion, WorldGraph evidence/belief ancestry and receipt. Excludes signing, sensing, ranking I/O and hardware. One logical WiFi source, not persistent-stream throughput.","profile":"release required","scoreIsProbability":false}) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn evolving_states_and_expiry() { + let report=demo(None); + let states: Vec<_>=report["steps"].as_array().unwrap().iter().map(|s|s["state"].as_str().unwrap()).collect(); + assert_eq!(states,vec!["empty","occupied","unknown","occupied","unknown"]); + assert_eq!(report["worldGraph"]["roundTripPreserved"],true); + assert_eq!(report["budgetStop"]["requested"],false); + assert_eq!(report["steps"][1]["liveSourceCount"],1); + } + #[test] + fn ancestry_and_no_evidence_feedback() { + let mut active=Active::new(); active.ingest(active_fixture("a","wifi",NOW,State::Occupied),&[],NOW).unwrap(); + assert!(active.ingest(active_fixture("b","radar",NOW+1,State::Occupied),&["a".into()],NOW+1).is_err()); + let snapshot=active.snapshot(NOW+1,true).unwrap(); assert_eq!(snapshot["belief"]["ancestryEdges"],1); + assert_eq!(active.world.edge_count(),1); + assert_eq!(active.world.node_count(),2); + let ancestry=active.world.neighbors(active.last_published.unwrap()); + assert_eq!(ancestry[0].0,active.roots["a"].node); + assert!(matches!(&ancestry[0].1,WorldEdge::DerivedFrom { evidence } if evidence=="a")); + } + #[test] + fn guards_capacity_clock_signature_replay() { + let mut active=Active::new(); let event=active_fixture("a","wifi",NOW,State::Occupied); + active.ingest(event.clone(),&[],NOW).unwrap(); let before=active.world.node_count(); + assert!(active.ingest(event,&[],NOW).is_err()); assert!(active.snapshot(NOW-1,false).is_err()); + let mut tampered=active_fixture("b","wifi",NOW+1,State::Empty); tampered.tensor.values[0]=99.; + assert!(active.ingest(tampered,&[],NOW+1).is_err()); assert_eq!(active.world.node_count(),before); + for i in 1..MAX_EVENTS { active.ingest(active_fixture(&format!("root{i}"),"wifi",NOW+i as u64,State::Occupied),&[],NOW+i as u64).unwrap(); } + assert!(active.ingest(active_fixture("overflow","wifi",NOW+100,State::Occupied),&[],NOW+100).is_err()); + assert_eq!(active.snapshot(NOW+100,false).unwrap()["state"],"unknown"); + } + #[test] + fn ranking_is_bounded_untrusted_advice() { + let good=r#"{"schemaVersion":1,"evidence":"SYNTHETIC","candidates":[{"sensorId":"wifi","score":100.0}]}"#; + assert_eq!(parse_ranking(good).unwrap()[0].1,100.0); + for bad in [good.replace("wifi","shell"),good.replace("100.0","1e999"),good.replace("100.0","null"), + good.replace("SYNTHETIC","REAL"),good.replace("\"score\":100.0","\"score\":1,\"score\":100.0"),good.replace("\"score\":100.0","\"score\":100.0,\"actuate\":true"), + good.replace("[{", "[{\"sensorId\":\"wifi\",\"score\":1},{")," ".repeat(4097)] { assert!(parse_ranking(&bad).is_err(),"{bad}"); } + let default=demo(None); let ranked=demo(Some(parse_ranking(good).unwrap())); + for (a,b) in default["steps"].as_array().unwrap().iter().zip(ranked["steps"].as_array().unwrap()) { assert_eq!(a["state"],b["state"]); assert_eq!(a["fusionHeuristicScore"],b["fusionHeuristicScore"]); } + } + #[test] + fn freshness_unknown_and_hash_alias_rejected() { + let mut active=Active::new(); + assert!(active.ingest(active_fixture("stale","wifi",NOW-MAX_AGE,State::Empty),&[],NOW).is_err()); + assert!(active.ingest(active_fixture("future","wifi",NOW+1,State::Empty),&[],NOW).is_err()); + let original=active_fixture("original","wifi",NOW,State::Unknown); + active.ingest(original.clone(),&[],NOW).unwrap(); + let before=active.world.to_json().unwrap(); + let mut alias=active_fixture("alias","radar",NOW+1,State::Occupied); + alias.provenance.raw_hash=original.provenance.raw_hash.clone(); + Signer::from_seed(&[17;32]).sign_event(&mut alias).unwrap(); + assert!(active.ingest(alias,&[],NOW+1).is_err()); + assert_eq!(active.world.to_json().unwrap(),before); + assert_eq!(active.snapshot(NOW+1,false).unwrap()["state"],"unknown"); + assert_eq!(active.snapshot(NOW+MAX_AGE,false).unwrap()["liveSourceCount"],0); + } + #[test] + fn rejected_ingest_does_not_advance_clock() { + let mut active=Active::new(); + active.ingest(active_fixture("accepted","wifi",NOW,State::Occupied),&[],NOW).unwrap(); + let graph=active.world.to_json().unwrap(); + let mut malformed=active_fixture("bad","radar",NOW+MAX_AGE,State::Empty); + malformed.tensor.values[0]=999.; + assert!(active.ingest(malformed,&[],NOW+MAX_AGE).is_err()); + assert_eq!(active.clock,NOW); + assert_eq!(active.world.to_json().unwrap(),graph); + assert_eq!(active.snapshot(NOW,false).unwrap()["state"],"occupied"); + } + #[cfg(all(target_os="linux",any(target_arch="x86_64",target_arch="aarch64")))] + #[test] + fn ranking_opened_descriptor_guards() { + use std::os::unix::fs::symlink; + let dir=std::env::temp_dir().join(format!("ruview-ranking-{}-{}",std::process::id(),NOW)); + std::fs::create_dir(&dir).unwrap(); + let file=dir.join("ranking.json"); let link=dir.join("link"); let fifo=dir.join("fifo"); + std::fs::write(&file,br#"{"schemaVersion":1,"evidence":"SYNTHETIC","candidates":[{"sensorId":"wifi","score":1}]}"#).unwrap(); + symlink(&file,&link).unwrap(); + assert!(read_ranking(file.to_str().unwrap()).is_ok()); + assert!(read_ranking(link.to_str().unwrap()).is_err()); + assert!(read_ranking(dir.to_str().unwrap()).is_err()); + // mkfifo is only a local test fixture; the production reader spawns nothing. + assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success()); + assert!(read_ranking(fifo.to_str().unwrap()).is_err()); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] + fn budget_atomic_in_all_units() { + let initial=Budget{packets:2,airtime_us:2,energy_uj:2}; + for bad in [Budget{packets:3,..initial},Budget{airtime_us:3,..initial},Budget{energy_uj:3,..initial}] { + let mut budget=initial; assert!(!budget.reserve(bad)); assert_eq!(budget,initial); + } + let mut budget=initial; assert!(budget.reserve(initial)); assert_eq!(budget,Budget{packets:0,airtime_us:0,energy_uj:0}); + } +} diff --git a/scripts/field-context-probe/src/main.rs b/scripts/field-context-probe/src/main.rs index 5fd7564a4a..99a0bc3b44 100644 --- a/scripts/field-context-probe/src/main.rs +++ b/scripts/field-context-probe/src/main.rs @@ -1,5 +1,6 @@ //! Synthetic reliability probe using actual pinned RuField and WorldGraph. //! No activity model, physical capture, confidence calibration or person track. +mod active; use std::collections::{BTreeMap, BTreeSet}; use rufield_core::{FieldEvent, FieldTensor, FieldAxis, Modality, Observation, PrivacyClass, ProvenanceRef, SensorDescriptor, FusionEngine, InferenceQuery, FieldInference}; @@ -279,6 +280,18 @@ fn inspect_path(path: &str) -> Result> { } fn main() { let args: Vec<_> = std::env::args().skip(1).collect(); + if args.first().map(String::as_str) == Some("--active-loop") { + let ranking = if args.len() == 1 { None } + else if args.len() == 3 && args[1] == "--ranking" { + match active::read_ranking(&args[2]) { Ok(value) => Some(value), Err(reason) => { + eprintln!("Active ranking rejected: {reason}"); std::process::exit(2); + }} + } else { eprintln!("Usage: --active-loop [--ranking PATH|-]"); std::process::exit(2); }; + println!("{}", serde_json::to_string_pretty(&active::demo(ranking)).unwrap()); return; + } + if args == ["--active-benchmark"] { + println!("{}", serde_json::to_string_pretty(&active::benchmark_active()).unwrap()); return; + } if args.len() == 2 && args[0] == "--inspect-jsonl" { match inspect_path(&args[1]) { Ok(report) => { println!("{}", serde_json::to_string_pretty(&report).unwrap()); @@ -289,7 +302,7 @@ fn main() { } let mut report = probe(); if args == ["--benchmark"] { report["benchmark"] = benchmark(); } - else if !args.is_empty() { eprintln!("Usage: ruview-field-context-probe [--benchmark | --inspect-jsonl PATH]"); std::process::exit(2); } + else if !args.is_empty() { eprintln!("Usage: ruview-field-context-probe [--benchmark | --inspect-jsonl PATH | --active-loop [--ranking PATH|-] | --active-benchmark]"); std::process::exit(2); } println!("{}", serde_json::to_string_pretty(&report).unwrap()); } diff --git a/scripts/pretrain-csi-predictive.py b/scripts/pretrain-csi-predictive.py new file mode 100644 index 0000000000..9ea98067f2 --- /dev/null +++ b/scripts/pretrain-csi-predictive.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +"""Explicit experimental masked latent pretraining entrypoint.""" +from csi_training.predictive import main +if __name__ == '__main__': + main() diff --git a/scripts/pretrain-csi-transfer.py b/scripts/pretrain-csi-transfer.py new file mode 100644 index 0000000000..58a57b7a2f --- /dev/null +++ b/scripts/pretrain-csi-transfer.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python3 +"""Explicit fixed two-arm representation-transfer research.""" +from csi_training.predictive_transfer import main +if __name__=='__main__':main() diff --git a/scripts/requirements-zenodo-intake.txt b/scripts/requirements-zenodo-intake.txt new file mode 100644 index 0000000000..0e3b70836d --- /dev/null +++ b/scripts/requirements-zenodo-intake.txt @@ -0,0 +1,3 @@ +# Optional native MATLAB v7.3 diagnostic, separate from training runtime. +h5py==3.14.0 +numpy==2.4.4 diff --git a/scripts/tests/test_csi_training_active_evaluation.py b/scripts/tests/test_csi_training_active_evaluation.py new file mode 100644 index 0000000000..fb41672584 --- /dev/null +++ b/scripts/tests/test_csi_training_active_evaluation.py @@ -0,0 +1,192 @@ +import copy +import json +from pathlib import Path +import tempfile +import unittest + +from csi_training.active_evaluation import evaluate, load + + +def fixture(): + budget = dict(packets=10, airtime_ms=20, energy_mj=30, available_sensors=["wifi", "radar"]) + sessions = [] + for i in range(4): + sessions.append(dict(room=f"room{i}", session=f"session{i}", + budget=dict(baseline=budget.copy(), candidate=budget.copy()), intervals=[ + dict(start_ms=0, end_ms=1000, truth="empty", baseline="occupied", candidate="empty"), + dict(start_ms=1000, end_ms=2000, truth="occupied", baseline="occupied", candidate="occupied")])) + return dict(schemaVersion=1, evidence="SYNTHETIC", sessions=sessions) + + +def transition_fixture(): + budget = dict(packets=10, airtime_ms=20, energy_mj=30, + available_sensors=["wifi", "radar"]) + sessions = [] + for i in range(4): + sessions.append(dict(room=f"room{i}", session=f"transition{i}", + budget=dict(baseline=budget.copy(), + candidate=budget.copy()), intervals=[ + dict(start_ms=0, end_ms=1000, truth="empty", + baseline="occupied", candidate="empty"), + dict(start_ms=1000, end_ms=2000, truth="occupied", + baseline="unknown", candidate="occupied"), + dict(start_ms=2000, end_ms=3000, truth="occupied", + baseline="occupied", candidate="occupied"), + dict(start_ms=3000, end_ms=4000, truth="empty", + baseline="occupied", candidate="empty"), + dict(start_ms=4000, end_ms=5000, truth="empty", + baseline="empty", candidate="empty")])) + return dict(schemaVersion=1, evidence="SYNTHETIC", sessions=sessions) + + +class ActiveEvaluationTests(unittest.TestCase): + def test_duration_and_gate_never_authorize_promotion(self): + result = evaluate(fixture()) + self.assertEqual(result["metrics"]["false_duration_relative_reduction"], 1) + self.assertEqual(result["numericalGate"], "pass") + self.assertEqual(result["fieldAcceptance"], "not_established") + self.assertFalse(result["promotionAuthorized"]) + + def test_abstention_cannot_hide_recall_loss(self): + doc = fixture() + for session in doc["sessions"]: + session["intervals"][1]["candidate"] = "unknown" + result = evaluate(doc) + self.assertEqual(result["metrics"]["candidate_recall"], 0) + self.assertEqual(result["numericalGate"], "fail") + + def test_zero_baseline_or_one_room_is_inconclusive(self): + doc = fixture() + for session in doc["sessions"]: + session["intervals"][0]["baseline"] = "empty" + self.assertEqual(evaluate(doc)["numericalGate"], "inconclusive") + self.assertEqual(evaluate({**fixture(), "sessions": fixture()["sessions"][:1]})["numericalGate"], "inconclusive") + + def test_abstention_on_empty_cannot_game_false_occupied_duration(self): + doc = fixture() + for session in doc["sessions"]: + session["intervals"][0]["candidate"] = "unknown" + result = evaluate(doc) + self.assertEqual(result["metrics"]["false_duration_relative_reduction"], 1) + self.assertEqual(result["metrics"]["unknown_empty_delta"], 1) + self.assertEqual(result["numericalGate"], "fail") + + def test_recall_interval_not_conditioned_on_false_events(self): + doc = fixture() + for session in doc["sessions"]: + session["intervals"][0]["baseline"] = "empty" + result = evaluate(doc) + self.assertEqual(result["bootstrap"]["valid"], 0) + self.assertEqual(result["bootstrap"]["recall_valid"], 1000) + self.assertEqual(result["bootstrap"]["recall_delta_interval"], [0, 0]) + + def test_transition_gate_rewards_faster_resolution_in_both_directions(self): + result = evaluate(transition_fixture()) + self.assertEqual(result["numericalGate"], "pass") + self.assertEqual(result["transitionGate"], "pass") + self.assertEqual(result["overallGate"], "pass") + self.assertEqual( + result["transitionMetrics"]["empty_to_occupied"]["delay_delta_ms"], + -1000) + self.assertEqual( + result["transitionMetrics"]["occupied_to_empty"]["delay_delta_ms"], + -1000) + self.assertFalse(result["promotionAuthorized"]) + self.assertEqual(result["fieldAcceptance"], "not_established") + + def test_equal_duration_cannot_hide_later_occupied_transition(self): + doc = transition_fixture() + for session in doc["sessions"]: + session["intervals"][1].update(baseline="occupied", + candidate="unknown") + session["intervals"][2].update(baseline="unknown", + candidate="occupied") + result = evaluate(doc) + self.assertEqual(result["numericalGate"], "pass") + self.assertGreater( + result["transitionMetrics"]["empty_to_occupied"]["delay_delta_ms"], + 0) + self.assertEqual(result["transitionGate"], "fail") + self.assertEqual(result["overallGate"], "fail") + + def test_unresolved_unknown_is_counted_and_fails(self): + doc = transition_fixture() + for session in doc["sessions"]: + session["intervals"][1]["candidate"] = "unknown" + session["intervals"][2]["candidate"] = "unknown" + result = evaluate(doc) + metrics = result["transitionMetrics"]["empty_to_occupied"] + self.assertEqual(metrics["candidate_unresolved_fraction"], 1) + self.assertEqual(metrics["candidate_mean_delay_ms"], 2000) + self.assertEqual(result["transitionGate"], "fail") + + def test_false_duration_fragmentation_cannot_hide_extra_episodes(self): + doc = transition_fixture() + for session in doc["sessions"]: + session["intervals"] = [ + dict(start_ms=0, end_ms=1000, truth="empty", + baseline="empty", candidate="empty"), + dict(start_ms=1000, end_ms=2000, truth="occupied", + baseline="occupied", candidate="occupied"), + dict(start_ms=2000, end_ms=2500, truth="empty", + baseline="occupied", candidate="occupied"), + dict(start_ms=2500, end_ms=3000, truth="empty", + baseline="occupied", candidate="empty"), + dict(start_ms=3000, end_ms=3500, truth="empty", + baseline="empty", candidate="occupied"), + dict(start_ms=3500, end_ms=4000, truth="empty", + baseline="empty", candidate="empty")] + result = evaluate(doc) + self.assertEqual( + result["metrics"]["baseline_false_seconds_per_empty_hour"], + result["metrics"]["candidate_false_seconds_per_empty_hour"]) + self.assertGreater( + result["transitionMetrics"]["false_episode_delta_per_empty_hour"], + 0) + self.assertEqual(result["transitionGate"], "fail") + + def test_transition_gate_requires_two_rooms_and_both_directions(self): + one_room = transition_fixture() + one_room["sessions"] = one_room["sessions"][:1] + self.assertEqual(evaluate(one_room)["transitionGate"], "inconclusive") + one_direction = transition_fixture() + for session in one_direction["sessions"]: + session["intervals"] = session["intervals"][:3] + self.assertEqual( + evaluate(one_direction)["transitionGate"], "inconclusive") + + def test_room_clustering_keeps_sessions_together(self): + doc = fixture() + doc["sessions"][1]["room"] = "room0" + self.assertEqual(evaluate(doc)["rooms"], 3) + + def test_invalid_budget_timestamp_schema_and_truth(self): + mutations = [lambda d: d["sessions"][0]["budget"]["candidate"].update(packets=11), + lambda d: d["sessions"][0]["intervals"][1].update(start_ms=500), + lambda d: d["sessions"][0]["intervals"][1].update(end_ms=float("nan")), + lambda d: d["sessions"][0]["intervals"][1].update(truth="unknown"), + lambda d: d["sessions"][0]["budget"]["candidate"].update(available_sensors=["extra"]), + lambda d: d.update(schemaVersion=True), + lambda d: d["sessions"].append(copy.deepcopy(d["sessions"][0]))] + for mutation in mutations: + doc = fixture() + mutation(doc) + with self.assertRaises(ValueError): + evaluate(doc) + + def test_strict_file_reader(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "manifest.json" + path.write_text(json.dumps(fixture())) + self.assertEqual(evaluate(load(path))["numericalGate"], "pass") + path.write_text('{"schemaVersion":1,"schemaVersion":2}') + with self.assertRaises(ValueError): + load(path) + link = Path(directory) / "link" + link.symlink_to(path) + with self.assertRaises(OSError): + load(link) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_csi_training_active_evaluation_cli.py b/scripts/tests/test_csi_training_active_evaluation_cli.py new file mode 100644 index 0000000000..5c9552f80f --- /dev/null +++ b/scripts/tests/test_csi_training_active_evaluation_cli.py @@ -0,0 +1,95 @@ +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +SCRIPT = Path(__file__).parents[1] / "evaluate-active-evidence.py" + + +def fixture(): + budget = dict(packets=10, airtime_ms=20, energy_mj=30, + available_sensors=["wifi", "radar"]) + sessions = [] + for index in range(4): + sessions.append({ + "room": f"room{index}", + "session": f"session{index}", + "budget": {"baseline": budget.copy(), + "candidate": budget.copy()}, + "intervals": [ + {"start_ms": 0, "end_ms": 1000, "truth": "empty", + "baseline": "occupied", "candidate": "empty"}, + {"start_ms": 1000, "end_ms": 2000, + "truth": "occupied", "baseline": "unknown", + "candidate": "occupied"}, + {"start_ms": 2000, "end_ms": 3000, + "truth": "occupied", "baseline": "occupied", + "candidate": "occupied"}, + {"start_ms": 3000, "end_ms": 4000, "truth": "empty", + "baseline": "occupied", "candidate": "empty"}, + {"start_ms": 4000, "end_ms": 5000, "truth": "empty", + "baseline": "empty", "candidate": "empty"}, + ], + }) + return {"schemaVersion": 1, "evidence": "SYNTHETIC", + "sessions": sessions} + + +class ActiveEvaluationCliTests(unittest.TestCase): + def run_cli(self, document, *arguments): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "evaluation.json" + if isinstance(document, str): + path.write_text(document) + else: + path.write_text(json.dumps(document)) + environment = os.environ.copy() + environment["PYTHONPATH"] = str(SCRIPT.parent) + return subprocess.run( + [sys.executable, str(SCRIPT), str(path), *arguments], + text=True, capture_output=True, env=environment, timeout=10, + check=False) + + def test_require_pass_returns_zero_only_for_overall_pass(self): + process = self.run_cli(fixture(), "--require-pass") + self.assertEqual(process.returncode, 0, process.stderr) + report = json.loads(process.stdout) + self.assertEqual(report["overallGate"], "pass") + self.assertFalse(report["promotionAuthorized"]) + + def test_require_pass_rejects_fail_after_printing_report(self): + document = fixture() + for session in document["sessions"]: + session["intervals"][1]["candidate"] = "unknown" + session["intervals"][2]["candidate"] = "unknown" + process = self.run_cli(document, "--require-pass") + self.assertEqual(process.returncode, 3) + self.assertEqual(json.loads(process.stdout)["overallGate"], "fail") + self.assertIn("active evaluation gate: fail", process.stderr) + + def test_require_pass_rejects_inconclusive(self): + document = fixture() + document["sessions"] = document["sessions"][:1] + process = self.run_cli(document, "--require-pass") + self.assertEqual(process.returncode, 3) + self.assertEqual(json.loads(process.stdout)["overallGate"], + "inconclusive") + + def test_default_remains_report_only_and_invalid_input_is_two(self): + document = fixture() + document["sessions"] = document["sessions"][:1] + process = self.run_cli(document) + self.assertEqual(process.returncode, 0, process.stderr) + self.assertEqual(json.loads(process.stdout)["overallGate"], + "inconclusive") + invalid = self.run_cli("{not json", "--require-pass") + self.assertEqual(invalid.returncode, 2) + self.assertEqual(invalid.stdout, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_csi_training_confirmation.py b/scripts/tests/test_csi_training_confirmation.py new file mode 100644 index 0000000000..2372efdbf6 --- /dev/null +++ b/scripts/tests/test_csi_training_confirmation.py @@ -0,0 +1,208 @@ +import copy +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from csi_training.confirmation import evaluate, read_json, regular_sha256, training_gate, KNOWN_EXPOSED + + +def fixture(): + records = [] + for split in ('train', 'development', 'confirmation'): + for activity in range(7): + for trial in range(10 if split == 'confirmation' else 1): + name = f'{split}:{activity}:{trial}' + group = {'train': 1, 'development': 2}.get(split, 3 + trial % 2) + records.append(dict(id=name, signal_sha256=hashlib.sha256(name.encode()).hexdigest(), + source={'dataset':'synthetic', 'revision':'1'}, split=split, + room=f'room:{group}', participant=f'participant:{group}', + session=f'{split}:session:{trial%2}', device=f'{split}:device', activity=activity)) + return {'schema_version':1, 'protocol_sha256':'a'*64, + 'claim':'new_room_person_device', 'records':records}, {'schema_version':1,'exposed_sources':[]} + + +def dataset_fixture(manifest): + split_map = {'train':'train', 'development':'validation', 'confirmation':'test'} + return {'schema_version':1, 'dataset':'synthetic', 'source_revision':'1', + 'records':[{'signal_sha256':row['signal_sha256'], + 'split':split_map[row['split']], + 'identity':[int(row['room'].split(':')[1]), + int(row['participant'].split(':')[1]), + row['activity'] + 1, index + 1], + 'session_id':row['session'], 'device_id':row['device']} + for index,row in enumerate(manifest['records'])]} + + +class ConfirmationTests(unittest.TestCase): + def test_success_is_only_metadata(self): + m,l=fixture(); r=evaluate(m,l) + self.assertTrue(r['preflight_passed']);self.assertFalse(r['promotionAuthorized']) + self.assertEqual(r['evidence'],'METADATA_ONLY') + self.assertEqual(r,evaluate(copy.deepcopy(m),copy.deepcopy(l))) + self.assertNotIn('records',r) + + def test_each_leakage_axis(self): + for axis in ('room','participant','session','device'): + with self.subTest(axis=axis): + m,l=fixture();m['records'][-1][axis]=m['records'][0][axis] + self.assertIn('overlapping_'+axis,evaluate(m,l)['reasons']) + + def test_duplicates_even_with_renamed_record(self): + for key,reason in [('id','duplicate_record_id'),('signal_sha256','duplicate_signal_digest')]: + m,l=fixture();m['records'][-1][key]=m['records'][0][key] + self.assertIn(reason,evaluate(m,l)['reasons']) + + def test_exposure_cannot_be_cleared_for_known_source(self): + m,l=fixture();dataset,revision=next(iter(KNOWN_EXPOSED)) + m['records'][-1]['source']={'dataset':dataset,'revision':revision} + self.assertIn('confirmation_source_previously_exposed',evaluate(m,l)['reasons']) + m,l=fixture();l['exposed_sources']=[m['records'][0]['source']] + self.assertIn('confirmation_source_previously_exposed',evaluate(m,l)['reasons']) + + def test_coverage_and_claim_scope(self): + m,l=fixture();m['records'].pop() + self.assertIn('confirmation_class_coverage',evaluate(m,l)['reasons']) + m,l=fixture() + for row in m['records']: + if row['split']=='confirmation': row['room']='onlyroom' + self.assertIn('insufficient_confirmation_room_coverage',evaluate(m,l)['reasons']) + m,l=fixture();m['claim']='new_room_person' + for row in m['records']:row['device']='same_device' + self.assertTrue(evaluate(m,l)['preflight_passed']) + + def test_strict_contract(self): + for field,value in [('schema_version',True),('protocol_sha256','bad'),('claim','occupancy'),('records',[])]: + m,l=fixture();m[field]=value + with self.assertRaises(ValueError):evaluate(m,l) + m,l=fixture();m['records'][0]['activity']=True + with self.assertRaises(ValueError):evaluate(m,l) + m,l=fixture();m['records']*=50 + with self.assertRaises(ValueError):evaluate(m,l) + + def test_unsafe_json_files(self): + with tempfile.TemporaryDirectory() as d: + p=Path(d)/'input.json' + for content in ['{"x":1,"x":2}', '{"x":NaN}', ' '* (2*1024*1024+1)]: + p.write_text(content) + with self.assertRaises(ValueError):read_json(p) + q=Path(d)/'link';q.symlink_to(p) + with self.assertRaises(OSError):read_json(q) + q.unlink();os.mkfifo(q) + with self.assertRaises(ValueError):read_json(q) + + def test_cli_exit_codes(self): + with tempfile.TemporaryDirectory() as d: + m,l=fixture();p=Path(d)/'m.json';q=Path(d)/'l.json';q.write_text(json.dumps(l)) + for expected in (0,2,1): + if expected==2:m['records'][-1]['room']=m['records'][0]['room'] + if expected==1:m['protocol_sha256']='bad' + p.write_text(json.dumps(m)) + r=subprocess.run([sys.executable,'-m','csi_training.confirmation','--manifest',str(p),'--ledger',str(q)],capture_output=True,text=True) + self.assertEqual(r.returncode,expected,r.stderr) + self.assertFalse(json.loads(r.stdout)['promotionAuthorized']) + + def test_training_gate_binds_frozen_protocol_and_dataset(self): + with tempfile.TemporaryDirectory() as d: + m,l=fixture();root=Path(d);dataset=root/'dataset.json' + p=root/'m.json';q=root/'l.json' + dataset.write_text(json.dumps(dataset_fixture(m))) + p.write_text(json.dumps(m));q.write_text(json.dumps(l)) + frozen=regular_sha256(dataset) + report=training_gate(p,q,dataset,'a'*64,frozen) + self.assertTrue(report['preflight_passed']) + self.assertFalse(report['promotionAuthorized']) + self.assertNotIn('records',report) + self.assertIn('protocol_digest_mismatch',training_gate(p,q,dataset,'b'*64,frozen)['reasons']) + self.assertIn('dataset_manifest_digest_mismatch',training_gate(p,q,dataset,'a'*64,'0'*64)['reasons']) + + def test_training_gate_binds_each_dataset_record_field(self): + changes = [('signal_sha256','dataset_record_set_mismatch'), + ('source','dataset_source_mismatch'), + ('split','dataset_split_mismatch'), + ('activity','dataset_activity_mismatch'), + ('room','dataset_room_mismatch'), + ('participant','dataset_participant_mismatch'), + ('session','dataset_session_mismatch'), + ('device','dataset_device_mismatch')] + with tempfile.TemporaryDirectory() as d: + root=Path(d) + for field,reason in changes: + with self.subTest(field=field): + m,l=fixture();dataset=dataset_fixture(m) + if field == 'signal_sha256': + dataset['records'][0]['signal_sha256']='f'*64 + elif field == 'source': + m['records'][0]['source']={'dataset':'other','revision':'1'} + elif field == 'split': + dataset['records'][0]['split']='validation' + elif field == 'activity': + dataset['records'][0]['identity'][2]=2 if m['records'][0]['activity'] != 1 else 3 + elif field == 'room': + dataset['records'][0]['identity'][0]=8 + elif field == 'participant': + dataset['records'][0]['identity'][1]=8 + elif field == 'session': + dataset['records'][0]['session_id']='other:session' + else: + dataset['records'][0]['device_id']='other:device' + p=root/'m.json';q=root/'l.json';data=root/'dataset.json' + p.write_text(json.dumps(m));q.write_text(json.dumps(l));data.write_text(json.dumps(dataset)) + report=training_gate(p,q,data,'a'*64,regular_sha256(data)) + self.assertIn(reason,report['reasons']) + self.assertFalse(report['preflight_passed']) + + def test_training_gate_rejects_duplicate_retained_signal(self): + with tempfile.TemporaryDirectory() as d: + m,l=fixture();dataset=dataset_fixture(m) + dataset['records'][1]['signal_sha256']=dataset['records'][0]['signal_sha256'] + root=Path(d);p=root/'m';q=root/'l';data=root/'data' + p.write_text(json.dumps(m));q.write_text(json.dumps(l));data.write_text(json.dumps(dataset)) + reasons=training_gate(p,q,data,'a'*64,regular_sha256(data))['reasons'] + self.assertIn('dataset_duplicate_signal_digest',reasons) + self.assertIn('dataset_record_set_mismatch',reasons) + + def test_training_gate_ignores_excluded_dataset_record(self): + with tempfile.TemporaryDirectory() as d: + m,l=fixture();dataset=dataset_fixture(m) + dataset['records'].append({'signal_sha256':'f'*64, 'split':None, + 'identity':[9,9,1,1]}) + root=Path(d);p=root/'m';q=root/'l';data=root/'data' + p.write_text(json.dumps(m));q.write_text(json.dumps(l));data.write_text(json.dumps(dataset)) + report=training_gate(p,q,data,'a'*64,regular_sha256(data)) + self.assertTrue(report['preflight_passed'],report['reasons']) + self.assertTrue(report['dataset_binding']['exact_record_set']) + + def test_training_gate_rejects_malformed_dataset_binding(self): + with tempfile.TemporaryDirectory() as d: + m,l=fixture();dataset=dataset_fixture(m);dataset['records'][0]['identity'][2]=True + root=Path(d);p=root/'m';q=root/'l';data=root/'data' + p.write_text(json.dumps(m));q.write_text(json.dumps(l));data.write_text(json.dumps(dataset)) + with self.assertRaises(ValueError): + training_gate(p,q,data,'a'*64,regular_sha256(data)) + + def test_training_gate_fails_closed_without_session_or_device_identity(self): + with tempfile.TemporaryDirectory() as d: + m,l=fixture();dataset=dataset_fixture(m) + for row in dataset['records']: + row.pop('session_id');row.pop('device_id') + root=Path(d);p=root/'m';q=root/'l';data=root/'data' + p.write_text(json.dumps(m));q.write_text(json.dumps(l));data.write_text(json.dumps(dataset)) + reasons=training_gate(p,q,data,'a'*64,regular_sha256(data))['reasons'] + self.assertIn('dataset_session_identity_unbound',reasons) + self.assertIn('dataset_device_identity_unbound',reasons) + + def test_regular_sha256_rejects_unsafe_files(self): + with tempfile.TemporaryDirectory() as d: + root=Path(d);target=root/'target';target.write_bytes(b'ok') + link=root/'link';link.symlink_to(target) + with self.assertRaises(OSError):regular_sha256(link) + fifo=root/'fifo';os.mkfifo(fifo) + with self.assertRaises(ValueError):regular_sha256(fifo) + oversized=root/'oversized';oversized.write_bytes(b'x'*33) + with self.assertRaises(ValueError):regular_sha256(oversized,32) + +if __name__=='__main__':unittest.main() diff --git a/scripts/tests/test_csi_training_intake_races.py b/scripts/tests/test_csi_training_intake_races.py new file mode 100644 index 0000000000..00dbf831a7 --- /dev/null +++ b/scripts/tests/test_csi_training_intake_races.py @@ -0,0 +1,133 @@ +"""Synthetic mutation probes for the production training intake boundary.""" +import hashlib +import json +import os +import subprocess +import sys +from contextlib import contextmanager +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from csi_training import data + + +class IntakeRaceTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.manifest = self.root / 'manifest.json' + self.contents = [] + entries = [] + for i, split in enumerate(data.SPLITS): + content = b''.join((json.dumps({'timestamp': t, 'node_id': i, + 'features': [i + t + k / 10 for k in range(8)]})+'\n').encode() + for t in range(3)) + self.contents.append(content) + (self.root / f'{i}.jsonl').write_bytes(content) + entries.append({'path': f'{i}.jsonl', 'sha256': hashlib.sha256(content).hexdigest(), + 'captured_at': '2026-10-13T00:00:00Z', 'room_id': str(i), + 'session_id': str(i), 'split': split, 'evidence': 'synthetic', + 'use_permitted': True}) + self.manifest.write_text(json.dumps({'schema_version': 1, 'files': entries})) + + def after_inventory(self, change): + original = data._manifest + def wrapped(path): + result = original(path) + change() + return result + return patch.object(data, '_manifest', wrapped) + + def load(self): + return data.load_manifest(self.manifest, allow_synthetic=True) + + def test_symlink_swap_after_inventory(self): + def swap(): + p = self.root / '0.jsonl'; target = self.root / 'moved.jsonl' + p.rename(target); p.symlink_to(target) + with self.after_inventory(swap): + with self.assertRaises(data.DataError): self.load() + + def test_aggregate_bytes_rechecked_during_read(self): + for i in range(3): (self.root / f'{i}.jsonl').write_bytes(b'') + def grow(): + for i, content in enumerate(self.contents): + (self.root / f'{i}.jsonl').write_bytes(content) + with patch.object(data, 'MAX_TOTAL_BYTES', sum(map(len, self.contents))-1): + with self.after_inventory(grow): + with self.assertRaisesRegex(data.DataError, 'total dataset'): + self.load() + + def test_fifo_swap_rejected_without_blocking(self): + # Subprocess timeout makes a regression to blocking open fail safely. + code = """ +from test_csi_training_intake_races import IntakeRaceTests +from csi_training import data +import os +case=IntakeRaceTests();case.setUp() +def swap(): + p=case.root/'0.jsonl';p.unlink();os.mkfifo(p) +try: + with case.after_inventory(swap): + try:case.load() + except data.DataError:pass + else:raise AssertionError('FIFO accepted') +finally:case.doCleanups() +""" + env = dict(os.environ, PYTHONPATH=os.pathsep.join([ + str(Path(__file__).resolve().parents[1]), str(Path(__file__).resolve().parent)])) + result = subprocess.run([sys.executable, '-c', code], env=env, + capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_descriptor_stays_bound_after_path_replacement(self): + original = data._open_regular + replaced = False + @contextmanager + def wrapped(path, maximum): + nonlocal replaced + with original(path, maximum) as opened: + # First encounter is the inventory check. Replace only after + # inventory, on the descriptor that will actually be parsed. + if Path(path).name == '0.jsonl' and replaced: + target = self.root/'0.jsonl' + target.rename(self.root/'old.jsonl') + target.write_bytes(b'not the opened data') + yield opened + def mark(): + nonlocal replaced + replaced = True + with self.after_inventory(mark), patch.object(data, '_open_regular', wrapped): + self.assertEqual(len(self.load().partitions['train'].features), 3) + + def test_manifest_symlink_and_bounded_read(self): + moved = self.root/'manifest-original.json' + self.manifest.rename(moved);self.manifest.symlink_to(moved) + with self.assertRaises(data.DataError):self.load() + self.manifest.unlink();moved.rename(self.manifest) + with patch.object(data, 'MAX_MANIFEST_BYTES', 10): + with self.assertRaises(data.DataError):self.load() + + def test_manifest_growth_after_descriptor_check(self): + original = data._open_regular + @contextmanager + def wrapped(path, maximum): + with original(path, maximum) as opened: + if Path(path) == self.manifest: + with self.manifest.open('ab') as writer:writer.write(b' ') + yield opened + with patch.object(data, '_open_regular', wrapped): + with self.assertRaisesRegex(data.DataError, 'manifest changed'): + self.load() + + def test_valid_reload_preserves_all_partitions(self): + first, second = self.load(), self.load() + self.assertEqual(first.manifest_sha256, second.manifest_sha256) + self.assertEqual(first.inventory, second.inventory) + for split in data.SPLITS: + self.assertTrue((first.partitions[split].features == second.partitions[split].features).all()) + +if __name__ == '__main__': unittest.main() diff --git a/scripts/tests/test_csi_training_predictive.py b/scripts/tests/test_csi_training_predictive.py new file mode 100644 index 0000000000..5575edae0d --- /dev/null +++ b/scripts/tests/test_csi_training_predictive.py @@ -0,0 +1,107 @@ +"""Synthetic learning/lineage checks; no occupancy or public-task accuracy.""" +import copy +import json +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch +import numpy as np +import torch +sys.path.insert(0,str(Path(__file__).resolve().parents[1])) +from csi_training.predictive import (Predictor, PatchEncoder, make_mask, objective, + ema_update, synthetic, train, FrozenEncoder, split_training, validate_parts, normalize, _read, _json) + + +class PredictiveTests(unittest.TestCase): + def test_mask_exclusion_gradient_and_stopped_ema(self): + torch.manual_seed(42);torch.set_num_threads(1) + student=Predictor();teacher=copy.deepcopy(student.encoder).eval().requires_grad_(False) + x=torch.randn(3,52,190);mask=make_mask(3,torch.Generator().manual_seed(7)) + self.assertTrue(torch.equal(mask.sum(1),torch.full((3,),52))) + self.assertFalse(mask.all()) + # Perturb only masked target patch values; context predictions cannot see them. + patch_mask=mask.reshape(3,13,10)[:,:,None,:,None].expand(3,13,4,10,19).reshape(3,52,190) + altered=x.clone();altered[patch_mask]+=100 + np.testing.assert_array_equal(student(x,mask).detach().numpy(),student(altered,mask).detach().numpy()) + loss,diagnostics,target=objective(student,teacher,x,mask) + self.assertFalse(target.requires_grad);self.assertTrue(torch.isfinite(loss)) + loss.backward() + self.assertTrue(any(p.grad is not None and p.grad.abs().sum()>0 for p in student.parameters())) + self.assertTrue(all(p.grad is None for p in teacher.parameters())) + before=next(teacher.parameters()).clone() + with torch.no_grad():next(student.encoder.parameters()).add_(.1) + ema_update(teacher,student) + torch.testing.assert_close(next(teacher.parameters()),before+.001) + self.assertTrue(torch.isfinite(diagnostics['sample_std'])) + + def test_group_leakage_duplicates_and_original_train_only(self): + parts,_=synthetic();validate_parts(parts) + leaking=copy.deepcopy(parts);leaking['heldout']['groups'][:,0]=1 + with self.assertRaisesRegex(ValueError,'leakage'):validate_parts(leaking) + duplicate=copy.deepcopy(parts);duplicate['heldout']['x'][0]=duplicate['train']['x'][0] + with self.assertRaisesRegex(ValueError,'Duplicate'):validate_parts(duplicate) + with self.assertRaises(ValueError):validate_parts(dict(parts,test=parts['heldout'])) + x=np.concatenate([parts['train']['x'],parts['heldout']['x']]) + ids=np.column_stack([np.concatenate([parts['train']['groups'],parts['heldout']['groups']]),np.ones((len(x),2),dtype=int)]) + derived=split_training(x,ids) + self.assertEqual(len(derived['train']['x']),24);self.assertEqual(len(derived['heldout']['x']),8) + ids[0,0]=3 + with self.assertRaisesRegex(ValueError,'original training'):split_training(x,ids) + + def test_train_save_load_baselines_and_no_heldout_influence(self): + parts,lineage=synthetic() + # Keep CPU verification bounded; no outcome requirement on synthetic loss. + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp);a=train(parts,lineage,root/'a',epochs=1,batch_size=8,max_seconds=120) + changed=copy.deepcopy(parts);changed['heldout']['x']*=3 + b=train(changed,lineage,root/'b',epochs=1,batch_size=8,max_seconds=120) + self.assertEqual(a['model_sha256'],b['model_sha256']) + self.assertEqual(a['history'],b['history']) + self.assertEqual(set(a['heldout_latent_mse']),{'trained','untrained','constant','position_constant'}) + self.assertTrue(all(np.isfinite(v) for v in a['heldout_latent_mse'].values())) + self.assertEqual(len(a['inference_latency']['rounds']),3) + self.assertEqual(a['inference_latency']['calls_per_round'],100) + self.assertIn('collapse_warning',a['anti_collapse']);self.assertFalse(a['promotionAuthorized']) + bundle=FrozenEncoder(root/'a');second=FrozenEncoder(root/'a') + values=parts['heldout']['x'];np.testing.assert_array_equal(bundle.encode(values),second.encode(values)) + self.assertEqual(bundle.encode(values[:1]).shape,(1,32)) + logged=np.log(np.maximum(parts['train']['x'].astype(np.float64),1e-6)) + np.testing.assert_array_equal(bundle.mean,logged.mean((0,2))) + self.assertTrue(all(not p.requires_grad for p in bundle.encoder.parameters())) + adapter=bundle.new_adapter(3);adapter(values).sum().backward() + self.assertTrue(all(p.grad is None for p in bundle.encoder.parameters())) + self.assertIsNotNone(adapter.head.weight.grad) + path=root/'a'/'model.safetensors';path.write_bytes(path.read_bytes()+b'corrupt') + with self.assertRaisesRegex(ValueError,'hash mismatch'):FrozenEncoder(root/'a') + with self.assertRaises(FileExistsError):train(parts,lineage,root/'b',epochs=1) + + def test_failure_lifecycle_and_bounds(self): + parts,lineage=synthetic() + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp)/'failed' + with patch('csi_training.predictive.objective',side_effect=RuntimeError('injected')): + with self.assertRaises(RuntimeError):train(parts,lineage,root,epochs=1) + self.assertEqual(json.loads((root/'status.json').read_text())['status'],'failed') + with self.assertRaises(ValueError):FrozenEncoder(root) + for bad in [True,False,float('nan'),float('inf')]: + with self.assertRaises(ValueError):train(parts,lineage,'unused',max_seconds=bad) + for x in [np.ones((1,52,189)),np.full((2,52,190),np.nan),-np.ones((2,52,190))]: + with self.assertRaises(ValueError):normalize(x,np.zeros(52),np.ones(52)) + + def test_strict_json_and_opened_file_security(self): + for text in ['{"a":1,"a":2}','{"a":NaN}','{"a":Infinity}','{"a":1e999}']: + with self.assertRaises(ValueError):_json(text) + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp);regular=root/'regular';regular.write_bytes(b'123') + self.assertEqual(_read(regular,3),b'123') + with self.assertRaises(ValueError):_read(regular,2) + link=root/'link';link.symlink_to(regular) + with self.assertRaises(ValueError):_read(link,100) + if hasattr(os,'mkfifo'): + fifo=root/'fifo';os.mkfifo(fifo) + with self.assertRaises(ValueError):_read(fifo,100) + with self.assertRaises(ValueError):_read(root,100) + +if __name__=='__main__':unittest.main() diff --git a/scripts/tests/test_csi_training_predictive_probe.py b/scripts/tests/test_csi_training_predictive_probe.py new file mode 100644 index 0000000000..161395a1b8 --- /dev/null +++ b/scripts/tests/test_csi_training_predictive_probe.py @@ -0,0 +1,102 @@ +"""Synthetic probe mathematics and train-only information boundaries.""" +from pathlib import Path +import sys +import unittest + +import numpy as np +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from csi_training.predictive_probe import NAMES, _fit, metrics, run_probes + + +def fixture(): + train = np.tile(np.eye(7), (2, 1)) + dev = np.eye(7) + return {name: {'train': train.copy(), 'dev': dev.copy()} for name in NAMES}, np.tile(np.arange(7), 2), np.arange(7) + + +class ProbeTests(unittest.TestCase): + def test_independent_hand_computed_metrics_and_ridge(self): + result = metrics(np.array([0, 0, 1, 1]), np.array([0, 1, 1, 2])) + self.assertEqual(result['accuracy'], .5) + self.assertAlmostEqual(result['macro_f1'], 1/6) + np.testing.assert_allclose(result['per_class_f1'], [2/3, .5, 0, 0, 0, 0, 0]) + expected = np.zeros((7, 7), dtype=int) + expected[0, 0] = expected[0, 1] = expected[1, 1] = expected[1, 2] = 1 + np.testing.assert_array_equal(result['confusion'], expected) + state = _fit(np.array([[-1.], [1.]]), np.array([0, 1])) + np.testing.assert_allclose(state['weights'], [[-1/3, 1/3, 0, 0, 0, 0, 0]]) + np.testing.assert_allclose(state['bias'], [.5, .5, 0, 0, 0, 0, 0]) + + def test_dev_mutation_does_not_fit_and_train_labels_do(self): + parts, yt, yd = fixture() + first = run_probes(parts, yt, yd) + for part in parts.values(): + part['dev'] = part['dev'] * 23 + 11 + second = run_probes(parts, yt, (yd+1) % 7) + for name in NAMES: + self.assertEqual(first['probes'][name]['artifact_state_sha256'], second['probes'][name]['artifact_state_sha256']) + third = run_probes(parts, (yt+1) % 7, yd) + self.assertNotEqual(first['probes']['baseline']['artifact_state_sha256'], third['probes']['baseline']['artifact_state_sha256']) + self.assertFalse(first['gate']['passed']) # Equal perfect probes do not demonstrate lift. + self.assertEqual(first['majority']['predicted_activity'], 0) + + def test_zero_variance_and_dual_solution(self): + parts, yt, yd = fixture() + for part in parts.values(): + part['train'][:] = 5. + part['dev'][:] = 8. + result = run_probes(parts, yt, yd) + self.assertEqual(result['probes']['candidate']['accuracy'], 1/7) + state = _fit(np.ones((2, 4)), np.array([1, 1])) + np.testing.assert_array_equal(state['weights'], np.zeros((4, 7))) + np.testing.assert_array_equal(state['scale'], np.ones(4)) + self.assertEqual(state['bias'][1], 1.) + + def test_new_zero_class_blocks_otherwise_improved_candidate(self): + parts, yt, yd = fixture() + for name in ('baseline', 'untrained', 'statistics'): + parts[name]['dev'][:] = np.eye(7)[0] + parts[name]['dev'][1] = np.eye(7)[1] + parts['candidate']['dev'][1] = np.eye(7)[0] + result = run_probes(parts, yt, yd) + checks = result['gate']['checks'] + self.assertTrue(checks['macro_f1_at_least_best_plus_002']) + self.assertTrue(checks['accuracy_within_001_of_best']) + self.assertFalse(checks['no_new_zero_f1_class']) + self.assertFalse(result['gate']['passed']) + + def test_input_rejections(self): + invalid = [np.ones((14, 7), dtype=np.int64), np.ones((14, 7), dtype=bool), + np.ones((14, 7), dtype=np.complex128), np.ones((14, 7), dtype=object), + np.ones((4097, 7)), np.ones((14, 1025)), np.ones((0, 7)), + np.full((14, 7), np.nan), np.full((14, 7), np.inf), np.full((14, 7), 1e101)] + for value in invalid: + parts, yt, yd = fixture() + parts['candidate']['train'] = value + with self.subTest(shape=value.shape, dtype=value.dtype), self.assertRaises(ValueError): + run_probes(parts, yt, yd) + for labels in [np.arange(14, dtype=float), np.ones(14, dtype=bool), np.full(14, 7), + np.full(14, -1), np.zeros(13, dtype=int)]: + parts, _, yd = fixture() + with self.assertRaises(ValueError): run_probes(parts, labels, yd) + parts, yt, yd = fixture() + parts['statistics']['dev'] = np.ones((7, 8)) + with self.assertRaises(ValueError): run_probes(parts, yt, yd) + with self.assertRaises(ValueError): run_probes({}, yt, yd) + + def test_candidate_lift_gate_and_no_input_mutation(self): + parts, yt, yd = fixture() + for name in ('baseline', 'untrained', 'statistics'): + parts[name]['train'][:] = 1. + parts[name]['dev'][:] = 1. + before = {name: {key: value.copy() for key, value in part.items()} for name, part in parts.items()} + result = run_probes(parts, yt, yd) + self.assertTrue(result['gate']['passed']) + self.assertEqual(result['probes']['candidate']['accuracy'], 1.) + for name in NAMES: + for split in ('train', 'dev'): + np.testing.assert_array_equal(parts[name][split], before[name][split]) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/tests/test_csi_training_predictive_transfer.py b/scripts/tests/test_csi_training_predictive_transfer.py new file mode 100644 index 0000000000..d9b7b8fe25 --- /dev/null +++ b/scripts/tests/test_csi_training_predictive_transfer.py @@ -0,0 +1,84 @@ +"""Synthetic fixed-arm, gradient, baseline reproduction and artifact contracts.""" +import copy +import json +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch +import numpy as np +import torch +sys.path.insert(0,str(Path(__file__).resolve().parents[1])) +from csi_training.predictive import synthetic, train, Predictor, make_mask +from csi_training.predictive_transfer import (SPEC, record_penalties, transfer_objective, + TransferEncoder, run) + + +def fixture(): + parts,lineage=synthetic() + for part in parts.values():part['y']=np.arange(len(part['x']),dtype=np.int64)%7 + return parts,lineage + + +class TransferTests(unittest.TestCase): + def test_record_regularization_gradient_and_exact_baseline_objective(self): + from csi_training.predictive import objective + torch.manual_seed(42);torch.set_num_threads(1) + student=Predictor();teacher=copy.deepcopy(student.encoder).eval().requires_grad_(False) + x=torch.randn(4,52,190);mask=make_mask(4,torch.Generator().manual_seed(42)) + baseline,_,_=transfer_objective(student,teacher,x,mask,'baseline') + expected,_,_=objective(student,teacher,x,mask) + torch.testing.assert_close(baseline,expected,rtol=0,atol=0) + candidate,diagnostic,target=transfer_objective(student,teacher,x,mask,'candidate') + variance,covariance=record_penalties(student.encoder(x).mean(1)) + torch.testing.assert_close(candidate,expected+variance+.1*covariance) + candidate.backward() + self.assertFalse(target.requires_grad) + self.assertTrue(all(p.grad is None for p in teacher.parameters())) + self.assertTrue(any(p.grad is not None and p.grad.abs().sum()>0 for p in student.encoder.parameters())) + collapsed=torch.zeros(4,32);v,c=record_penalties(collapsed) + self.assertAlmostEqual(float(v),.99,places=6);self.assertEqual(float(c),0.) + self.assertIn('record_covariance',diagnostic) + + def test_fixed_campaign_reproduces_original_and_ignores_dev_updates(self): + parts,lineage=fixture() + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp) + original=train(parts,lineage,root/'original',epochs=5,batch_size=8,max_seconds=120) + reference=root/'original'/'model.safetensors' + first=run(parts,lineage,root/'first',max_seconds=240,baseline_artifact=reference) + changed=copy.deepcopy(parts);changed['heldout']['x']*=1.2 + changed['heldout']['y']=(changed['heldout']['y']+1)%7 + second=run(changed,lineage,root/'second',max_seconds=240,baseline_artifact=reference) + self.assertTrue(first['original_baseline_exact']) + self.assertEqual(first['models']['baseline']['model_sha256'],original['model_sha256']) + self.assertEqual(len(set(first['mask_order_sha256'].values())),1) + for name in ('baseline','candidate','untrained'): + self.assertEqual(first['models'][name]['model_sha256'],second['models'][name]['model_sha256']) + loaded=TransferEncoder(root/'first'/name) + self.assertEqual(loaded.encode(parts['heldout']['x']).shape,(8,32)) + self.assertTrue(all(not p.requires_grad for p in loaded.encoder.parameters())) + self.assertEqual(len(first['inference_latency'][name]),3) + self.assertEqual(first['histories'],second['histories']) + self.assertEqual(first['spec'],SPEC);self.assertFalse(first['promotionAuthorized']) + self.assertEqual(set(first['probes']['probes']),{'baseline','candidate','untrained','statistics'}) + frozen=json.loads((root/'first'/'representation_frozen.json').read_text()) + self.assertNotIn('probes',frozen) + model=root/'first'/'candidate'/'model.safetensors';model.write_bytes(model.read_bytes()+b'bad') + with self.assertRaisesRegex(ValueError,'hash mismatch'):TransferEncoder(model.parent) + + def test_failure_provenance_budget_and_no_overwrite(self): + parts,lineage=fixture() + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp) + for budget in [True,float('nan'),float('inf'),0]: + with self.assertRaises(ValueError):run(parts,lineage,root/'invalid',max_seconds=budget) + with self.assertRaisesRegex(ValueError,'required'): + run(parts,dict(lineage,evidence='PUBLIC_TRAIN_ONLY'),root/'missing') + with patch('csi_training.predictive_transfer.transfer_objective',side_effect=RuntimeError('injected')): + with self.assertRaises(RuntimeError):run(parts,lineage,root/'failed') + self.assertEqual(json.loads((root/'failed'/'status.json').read_text())['status'],'failed') + with self.assertRaises(FileExistsError):run(parts,lineage,root/'failed') + with self.assertRaises(ValueError):run(dict(parts,test=parts['heldout']),lineage,root/'test') + +if __name__=='__main__':unittest.main() diff --git a/scripts/tests/test_csi_training_zenodo.py b/scripts/tests/test_csi_training_zenodo.py new file mode 100644 index 0000000000..38a6a99d8e --- /dev/null +++ b/scripts/tests/test_csi_training_zenodo.py @@ -0,0 +1,232 @@ +"""Offline authored ZIP/HTTP/HDF fixtures; never public sensing accuracy.""" +import hashlib +import io +import json +from pathlib import Path +import stat +import tempfile +import unittest +from unittest.mock import patch +import warnings +import zipfile + +import h5py +import numpy as np + +from csi_training import zenodo_sample as z +from csi_training.zenodo_mat import inspect + + +class Response(io.BytesIO): + status = 206 + def __init__(self, value=b'ab', **headers): + super().__init__(value) + self.headers = {'Content-Range': f'bytes 0-1/{z.ARCHIVE_BYTES}', **headers} + def geturl(self): return z.URL + + +class ZenodoTests(unittest.TestCase): + def test_http_requires_exact_range_and_stable_etag(self): + ranges = z.HttpRanges() + with patch.object(ranges.opener, 'open', return_value=Response()): + self.assertEqual(ranges(0, 2), b'ab') + for response in [Response(b'a'), Response(b'abc'), Response(**{'ETag': '"new"'}), + Response(**{'Content-Encoding': 'gzip'}), + Response(**{'Content-Range': 'bytes 0-1/3'})]: + with self.subTest(headers=response.headers), patch.object(ranges.opener, 'open', return_value=response): + with self.assertRaises(ValueError): ranges(0, 2) + response = Response(); response.status = 200 + with patch.object(ranges.opener, 'open', return_value=response): + with self.assertRaises(ValueError): ranges(0, 2) + with self.assertRaises(ValueError): + z.NoRedirect().redirect_request(None, None, 302, '', {}, 'https://elsewhere/') + + def test_strong_etag_is_sent_on_followup(self): + ranges = z.HttpRanges() + for _ in range(2): + with patch.object(ranges.opener, 'open', return_value=Response(ETag='"fixed"')) as opened: + ranges(0, 2) + self.assertEqual(opened.call_args.args[0].get_header('If-match'), '"fixed"') + weak = z.HttpRanges() + with patch.object(weak.opener, 'open', return_value=Response(ETag='W/"weak"')): + with self.assertRaises(ValueError): weak(0, 2) + + def test_range_budgets_and_failed_response(self): + reader = z.RangeReader(lambda start, count: b'x' * count) + for args in [(-1,), (True,), (0, 3), (z.ARCHIVE_BYTES + 1,)]: + with self.assertRaises(ValueError): reader.seek(*args) + with self.assertRaises(ValueError): reader.read(z.MAX_REQUEST + 1) + for _ in range(3): self.assertEqual(len(reader.read(z.MAX_REQUEST)), z.MAX_REQUEST) + with self.assertRaises(ValueError): reader.read(1) + reader = z.RangeReader(lambda start, count: b'') + with self.assertRaises(ValueError): reader.read(2) + self.assertEqual(reader.tell(), 0) + reader.started -= 121 + with self.assertRaises(ValueError): reader.read(1) + reader = z.RangeReader(lambda start, count: b'x' * count) + reader.requests = 128 + with self.assertRaises(ValueError): reader.read(1) + + def test_zip_duplicates_symlinks_crc_and_size(self): + for kind in ('duplicate', 'symlink', 'oversized', 'crc'): + buf = io.BytesIO() + with warnings.catch_warnings(), zipfile.ZipFile(buf, 'w') as archive: + warnings.simplefilter('ignore', UserWarning) + info = zipfile.ZipInfo('selected') + if kind == 'symlink': info.external_attr = (stat.S_IFLNK | 0o777) << 16 + archive.writestr(info, b'fixture') + if kind == 'duplicate': archive.writestr('selected', b'duplicate') + value = buf.getvalue() + if kind == 'crc': value = value.replace(b'fixture', b'changed') + with zipfile.ZipFile(io.BytesIO(value)) as archive: + if kind == 'oversized': archive.getinfo('selected').file_size = z.MAX_MEMBER + 1 + with self.subTest(kind=kind), self.assertRaises((ValueError, zipfile.BadZipFile)): + z.member_bytes(archive, 'selected') + + def test_complete_intake_is_synthetic_and_never_overwrites(self): + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as archive: + for session in z.SESSIONS.values(): + for name in ('config.json', 'capture1_extracted.mat'): + archive.writestr('Zenodo/NoShield/beacon/' + session + '/' + name, b'fixture') + value = buf.getvalue() + original = z.RangeReader + with tempfile.TemporaryDirectory() as tmp, patch.object(z, 'RangeReader', side_effect=lambda f: original(f, len(value))): + path = Path(tmp) / 'new' + result = z.intake(path, lambda start, count: value[start:start+count]) + self.assertEqual(result['evidence'], 'SYNTHETIC') + self.assertEqual(len(result['artifacts']), 6) + self.assertFalse(result['snapshot_verified']) + self.assertFalse(result['promotionAuthorized']) + with self.assertRaises(FileExistsError): z.intake(path) + with self.assertRaises(ValueError): z.intake(Path(tmp)/'bad', lambda s, c: b'') + self.assertEqual(json.loads((Path(tmp)/'bad/status.json').read_text())['status'], 'failed') + + def fixture(self, path, mode='normal'): + with h5py.File(path, 'w') as f: + if mode == 'link': + f['csi'] = h5py.ExternalLink('never-open.mat', '/csi'); return + if mode == 'soft': + f['csi'] = h5py.SoftLink('/never-open'); return + dtype = np.dtype([('real', '