diff --git a/NeuralComposeEEG/ROADMAP.md b/NeuralComposeEEG/ROADMAP.md index 31181ee..4c93a11 100644 --- a/NeuralComposeEEG/ROADMAP.md +++ b/NeuralComposeEEG/ROADMAP.md @@ -22,6 +22,36 @@ The supporting [mathematics, physics, and methods scope](../docs/scoping/eeg-mathematics-physics-methods-scope.md) separates the linear-algebra, signal-processing, and evaluation foundations needed now from later electroquasistatic, sensor-fusion, optimization, and shadow-policy work. +The [SVD decision memo](../docs/research/svd-four-channel-eeg-decision-memo_v1.md) +and [proposed SVD experiment roadmap](../docs/scoping/svd-eeg-experiment-roadmap.md) +classify singular-spectrum diagnostics and train-only feature reduction as +separate, non-runtime studies. At D0 they are foundational only: they do not +modify the fixed encoder conditions or authorize a physical-data claim. +Their contracts are [SVD diagnostics](experiments/EXP-NC-SVD-DIAG-001.md), +[M0 feature reduction](experiments/EXP-NC-SVD-M0-001.md), +[representation analysis](experiments/EXP-NC-SVD-REP-001.md), and the deferred +[synthetic inverse study](experiments/EXP-NC-SVD-INV-001.md). + +The separate [`EXP-FUNC-SYN-000` decision +memo](../docs/research/function-space-foundations-decision-memo-v0.md) and +[contract](../configs/function-space-foundations-v0.json) define a +synthetic-only D0 mathematical-foundations package. Its empirical-measure, +derivative, Sobolev, convergence, operator, and mixed-integration fixtures do +not read EEG, alter preprocessing, change M0-M4, or delay physical Muse +capture. Every result remains `insufficient_evidence` and `not_eligible`. + +The [JEPA decision +memo](../docs/research/jepa-four-channel-eeg-decision-memo-v0.md) and proposed +[`EXP-NC-EEG-JEPA-001`](experiments/EXP-NC-EEG-JEPA-001.md) define a separate +Pass 1.5 encoder-objective comparison. At D0 this is documentation and +deterministic diagnostic-contract work only. It does not alter M0-M4, authorize +Laya download or training, or create a live JEPA path. D2 may permit only a +separately preregistered tiny pipeline-evidence run; D3 is the first scientific +comparison and still requires an approved pretraining-data gate. The registered +[collapse diagnostics](../docs/scoping/jepa-collapse-diagnostics-v0.json) +measure representation geometry directly, but cannot support an encoder +without the existing grouped predictive, calibration, artifact, and robustness +evidence. Gate to encoder confirmation: protocol-complete sessions on multiple days, then M0, M1, M2, M3, and eventually M4 on the same session-grouped folds. @@ -33,6 +63,34 @@ without the shared fixed-compute configuration hash and retains runtime evidence separately from predictive outcomes. Core ML conversion is deferred until a compact candidate is selected by this offline evidence. +## EXP-NC-EEG-JEPA-001 + +Proposed Pass 1.5 work only. It compares matched reconstruction, latent +prediction, anti-collapse, and bounded hybrid objectives after the ordinary +encoder program reaches the applicable gate. D2 can establish pipeline +execution only under a separate preregistration. A scientific comparison waits +until D3 and a separately reviewed corpus is adequate for self-supervised +learning. Short pilot captures are not enough merely because their labels can +be hidden. Every local pretraining fit remains inside its outer +training-session partition. + +This is not the existing action-conditioned `WorldModel/` JEPA spike. An +encoder representation does not become a transition model, MPC objective, or +dialogue controller. Every JEPA result remains `insufficient_evidence`, +`promotion_status: not_eligible`, and `runtime_change: none`. + +### Synthetic JEPA rehearsal + +[`EXP-NC-EEG-JEPA-SYN-000`](experiments/EXP-NC-EEG-JEPA-SYN-000.md) is the +separate executable path for deterministic S0-S6 objective, control, collapse, +grouping, and artifact checks. Its +[generator contract](../docs/scoping/jepa-synthetic-generators-v0.json) +prohibits fallback acquisition data and physical claims. The optional +[mode-stratified extension](experiments/EXP-NC-EEG-JEPA-SYN-MODE-000.md) +treats mirror, focus, reflective, and contemplative only as externally assigned +synthetic regimes. Neither rehearsal changes the D0 status of the physical +JEPA experiment or sets physical-study thresholds. + ## EXP-NC-ARC-XFER-001 Deferred until the encoder experiment selects a *fixed* shadow-only state diff --git a/NeuralComposeEEG/configs/jepa-synthetic-v0.json b/NeuralComposeEEG/configs/jepa-synthetic-v0.json new file mode 100644 index 0000000..99a6e8c --- /dev/null +++ b/NeuralComposeEEG/configs/jepa-synthetic-v0.json @@ -0,0 +1,140 @@ +{ + "schema_version": "nc-eeg-jepa-synthetic-config-v0", + "experiment_id": "EXP-NC-EEG-JEPA-SYN-000", + "mode_experiment_id": "EXP-NC-EEG-JEPA-SYN-MODE-000", + "seed": 42, + "device": "cpu", + "source": { + "schema_version": "nc-eeg-jepa-synthetic-source-v0", + "source_type": "deterministic_synthetic_fixture", + "device_id": "synthetic-generator", + "participant_id": null, + "physical_capture_eligible": false, + "fallback_capture_allowed": false + }, + "data": { + "sample_rate_hz": 64, + "channel_count": 4, + "window_samples": 64, + "windows_per_session": 24, + "sessions_per_generator": 6, + "generator_ids": ["S0", "S1", "S2", "S3", "S4", "S5", "S6"] + }, + "split": { + "unit": "complete_synthetic_session", + "outer_test_sessions": 2, + "inner_validation_sessions": 1, + "outer_fold_count": 2, + "normalization": "fit_training_sessions_only", + "adjacent_window_split_allowed": false + }, + "mask": { + "fraction": 0.25, + "geometry": "contiguous_temporal_span", + "same_schedule_across_conditions": true + }, + "model": { + "architecture_id": "synthetic-four-channel-jepa-v0", + "encoder_hidden_dim": 16, + "latent_dim": 8, + "predictor_hidden_dim": 24, + "mode_context_dim": 4, + "decoder_present_in_every_condition": true + }, + "training": { + "optimizer": "adamw", + "learning_rate": 0.003, + "weight_decay": 0.0001, + "batch_size": 32, + "steps": 12, + "ema_tau": 0.95, + "gradient_clip_norm": 5.0, + "conditions": ["T0", "T1", "T2", "T3", "T4", "T5"], + "matched_fields": [ + "encoder_architecture", + "parameter_count", + "generated_samples", + "batch_size", + "steps", + "optimizer", + "seed", + "context_target_geometry", + "mask_schedule", + "probe_budget" + ] + }, + "loss": { + "T0": { + "objective": "seeded_random_encoder", + "train": false + }, + "T1": { + "objective": "masked_reconstruction", + "reconstruction_weight": 1.0 + }, + "T2": { + "objective": "latent_prediction", + "prediction_weight": 1.0 + }, + "T3": { + "objective": "latent_prediction_plus_sigreg", + "prediction_weight": 1.0, + "sigreg_weight": 0.2, + "regularization_scope": "projector_embedding" + }, + "T4": { + "objective": "latent_prediction_plus_vicreg", + "prediction_weight": 1.0, + "vicreg_variance_weight": 0.2, + "vicreg_covariance_weight": 0.02, + "regularization_scope": "projector_embedding" + }, + "T5": { + "objective": "latent_prediction_plus_bounded_reconstruction", + "prediction_weight": 1.0, + "reconstruction_weight": 0.2, + "maximum_reconstruction_fraction": 0.25 + } + }, + "controls": [ + "temporal_target_permutation", + "different_sequence_target", + "channel_order_shuffle", + "channel_coordinate_shuffle", + "mask_location_shuffle", + "constant_embedding", + "nonfinite_input" + ], + "probe": { + "type": "logistic_regression", + "regularization_c": 1.0, + "max_iterations": 200, + "fit_scope": "training_sessions_only" + }, + "diagnostics": { + "variance_floor": 1e-06, + "singular_value_floor": 1e-08, + "energy_rank_threshold": 0.99, + "constant_output_tolerance": 1e-07, + "pairwise_sample_limit": 256 + }, + "mode_extension": { + "enabled": true, + "modes": ["mirror", "focus", "reflective", "contemplative"], + "sessions_per_mode": 4, + "controls": ["C0", "C1", "C2", "C3", "C4", "C5"], + "training_condition": "T3", + "split_unit": "complete_synthetic_session", + "leave_one_mode_out": true, + "mode_is_externally_assigned": true, + "cognitive_mode_inference_allowed": false + }, + "artifact": { + "schema_version": "nc-eeg-jepa-synthetic-rehearsal-v0", + "decision": "pipeline_evidence_only", + "promotion_status": "not_eligible", + "runtime_change": "none", + "physical_eeg_used": false, + "scientific_transfer_claim_allowed": false + } +} diff --git a/NeuralComposeEEG/experiments/EXP-NC-EEG-JEPA-001.md b/NeuralComposeEEG/experiments/EXP-NC-EEG-JEPA-001.md new file mode 100644 index 0000000..af44f99 --- /dev/null +++ b/NeuralComposeEEG/experiments/EXP-NC-EEG-JEPA-001.md @@ -0,0 +1,239 @@ +# EXP-NC-EEG-JEPA-001: Four-Channel Predictive Representation Learning + +**Status:** scope complete, not executed +**Classification:** Pass 1.5 encoder-objective comparison +**Current gate:** D0, foundational study only +**Earliest pipeline-evidence run:** D2, only under a separate preregistration +**Earliest scientific comparison:** D3, plus an approved pretraining-data gate +**Promotion status:** not_eligible +**Runtime dependency:** prohibited + +## Question + +> Does masked latent prediction produce more transferable, non-collapsed +> four-channel EEG representations than matched reconstruction and supervised +> baselines under complete-session held-out evaluation? + +## Fixed Boundary + +This experiment is separate from +[`EXP-NC-EEG-ENC-001`](EXP-NC-EEG-ENC-001.md). It does not rename, replace, or +modify M0-M4, `experiment-v0.json`, the acquisition protocol, four-channel +montage, sample rate, window geometry, labels, preprocessing, optimization +budgets, or pilot interpretation. + +It is an offline encoder study. It does not authorize: + +- a live JEPA process or checkpoint in NeuralCompose; +- raw EEG input to an LLM; +- thought, semantic, intention, or emotion inference; +- new protocol labels or dialogue-derived labels; +- Core ML or ANE deployment; +- action-conditioned world modeling, latent MPC, or generation control; or +- promotion from a pilot result. + +The estimand is the held-out difference in protocol-observable state +discrimination, calibration, artifact behavior, and robustness attributable to +the preregistered training objective under matched conditions. + +## Entry Gates + +At D0, only this contract, the +[`jepa-collapse-diagnostics-v0`](../../docs/scoping/jepa-collapse-diagnostics-v0.json) +schema, and deterministic diagnostic fixtures may be reviewed. + +Executable synthetic training belongs exclusively to +[`EXP-NC-EEG-JEPA-SYN-000`](EXP-NC-EEG-JEPA-SYN-000.md). Its generated +fixtures and mode-stratified extension are pipeline evidence only, cannot set +physical thresholds, and do not advance this experiment beyond D0. + +D1 remains one integrity-valid physical capture with no encoder training. D2 +remains the multi-day M0/M1 pipeline cohort. A tiny JEPA run at D2 is permitted +only by a separate preregistration and can establish pipeline execution only. +It remains `insufficient_evidence` and cannot support confirmation, encoder +selection, construct language, or promotion. + +D3 is the first gate for a defensible session-grouped JEPA comparison. It +requires ordinary grouped encoder evidence and a separate amendment approving +adequate self-supervised pretraining data. Post-encoder latent-transition, +world-model, or MPC work requires another action-conditioned experiment and is +not a continuation of this contract. + +Before execution, the amendment must pin the corpus and licenses, participant +and session overlap audit, minimum independent data support, context/target +geometry, masking distributions, architecture, parameter-count tolerance, +optimization budget, seeds, probe protocol, grouped splits, collapse +thresholds, and compute ceiling. + +## Conditions + +| ID | Condition | Role | +| --- | --- | --- | +| J0 | unchanged EEGNet reference | supervised reference | +| J1 | matched encoder plus masked raw-signal reconstruction | reconstruction baseline | +| J2 | matched encoder plus latent prediction, no SIGReg or VICReg term | no-regularization ablation | +| J3 | matched encoder plus latent prediction and SIGReg | LeJEPA-style condition | +| J4 | matched encoder plus latent prediction and VICReg-style regularization | alternate anti-collapse condition | +| J5 | latent prediction plus bounded reconstruction auxiliary | hybrid objective | +| J6 | frozen Laya-compatible transfer condition | optional external transfer comparator | + +J1-J5 use the same encoder capacity, parameter-count tolerance, train +partitions, batch exposure, optimization steps, mask schedule, and downstream +probe budget wherever the objective itself does not require a difference. +Every mismatch is reported and prevents a causal claim about the objective. +For J3 and J4, the preregistration names the encoder or projector tensor that +receives anti-collapse pressure. Applying SIGReg or VICReg-style terms only to +the predictor output invalidates the condition. + +J6 may run only when an official checkpoint and reproducible implementation +are available, their license permits local evaluation, model and code revisions +are pinned, and the input adapter is explicit. J6 is not included in the +matched-objective attribution claim. + +## Data Contract + +The downstream input remains four-channel Muse EEG at `TP9`, `AF7`, `AF8`, +and `TP10`, sampled at 256 Hz in four-second windows with one-second stride. +Labels remain the protocol-observable blocks already admitted by the canonical +source manifest. + +Short local pilot captures are not assumed sufficient for self-supervised +pretraining. A later execution must use one of: + +1. licensed external public EEG for pretraining, followed by an explicit + four-channel adapter and complete-session local probing; or +2. a substantial local unlabeled cohort with the outer grouped folds created + before pretraining and a separate encoder fit for every training partition. + +For local pretraining, no held-out session may contribute raw windows, +normalization, target views, mask statistics, early stopping, architecture +selection, or collapse thresholds. Hiding its labels is insufficient. + +## Controls + +All applicable conditions retain: + +- temporal-target permutation; +- context and target drawn from different sessions; +- channel-coordinate shuffle; +- channel-order shuffle; +- mask-location shuffle; +- J2 no-regularization ablation; +- constant-embedding detection; +- seeded random encoder; and +- J1 matched reconstruction. + +The different-session target tests whether a model can succeed through global +session identity rather than temporal structure. Channel and coordinate +controls test whether claimed montage awareness depends on the correct Muse +topology. + +## Representation Diagnostics + +At every epoch of a trainable representation condition, use training and +inner-validation groups only to record: + +- embedding variance by dimension; +- covariance off-diagonal magnitude; +- complete singular-value spectrum; +- entropy effective rank and pinned energy rank; +- condition number under a pinned singular-value floor; +- mean pairwise distance; +- nearest-neighbor session-identity rate; +- constant-output and feature-utilization checks; +- predictor loss; +- regularization loss; and +- frozen-protocol linear-probe performance. + +The exact fields and interpretation constraints are in +[`jepa-collapse-diagnostics-v0.json`](../../docs/scoping/jepa-collapse-diagnostics-v0.json). +Representation geometry cannot independently support a model. + +After the epoch, thresholds, and condition are frozen, compute the same +diagnostic bundle once on the outer held-out group. Outer-group diagnostics or +linear-probe scores never select an epoch, threshold, mask schedule, +architecture, or condition. A frozen external J6 condition has one +post-adaptation diagnostic report rather than a fictitious epoch series. + +## Scientific Outcomes + +Primary and required downstream outcomes are: + +- balanced accuracy; +- macro F1; +- Brier score; +- expected calibration error; +- artifact sensitivity and specificity; +- cross-session degradation; +- data-efficiency curves; and +- robustness to channel dropout, EMG-like noise, and movement artifacts. + +Report per-session, participant, recording-date, device, and headset-fit +results whenever the cohort supports those groups. Report training/evaluation +time, peak memory, checkpoint size, and latency as resource evidence, never as +a substitute for scientific performance. + +## Grouped Evaluation and Leakage + +Build complete-session outer folds before any data-derived fit. Use participant +or recording-date grouping when it is stricter and feasible. Within each fold: + +1. fit normalization, encoder, adapter, class weights, probe, and selection + rules from training data only; +2. choose early stopping and any hyperparameters from nested training groups; +3. freeze the complete condition before evaluating the outer held-out group; +4. bind every result to dataset, source-manifest, preprocessing, split, + architecture, checkpoint, adapter, mask, seed, and diagnostic hashes; and +5. keep the same eligible windows and outer folds across J0-J6. + +A globally pretrained local encoder that has seen the outer test sessions is +not a held-out condition, even when their labels were hidden. + +## Decision Rules and Falsification + +The preregistration amendment must name one primary downstream metric and its +minimum effect before physical execution. No D0 threshold is inferred from +synthetic fixtures. + +The latent-prediction hypothesis is rejected or suspended when: + +- J3-J5 fail to improve preregistered held-out outcomes over J0 and J1; +- gains disappear under complete-session or stricter grouping; +- correctly paired targets do not beat temporal permutation or + different-session targets; +- representation variance, covariance, spectrum, effective rank, or + pairwise-distance checks show collapse or threshold instability; +- nearest-neighbor session identity indicates that nuisance identity explains + the representation; +- gains are isolated to one session, participant, model seed, or artifact; +- robustness and data-efficiency evidence do not survive the fixed controls; + or +- a simpler matched baseline performs equivalently at lower complexity. + +Survival of only a visually coherent projection, training-loss improvement, or +one collapse metric is insufficient. + +## Three-Dimensional Visualization + +A learned embedding is not assumed to be three-dimensional. Any workspace view +uses a fixed random projection, a training-fold-fitted PCA projection, or a +separately frozen projection artifact. Held-out sessions never fit the display +projection. The view is exploratory and is not an evaluation outcome. + +## Artifacts + +An executed study must emit: + +- `nc-eeg-jepa-pretraining-provenance-v0`; +- `nc-eeg-jepa-collapse-report-v0`; +- `nc-eeg-jepa-fold-predictions-v0`; and +- `nc-eeg-jepa-evaluation-v0`. + +Every artifact records `status: insufficient_evidence`, +`promotion_status: not_eligible`, `runtime_change: none`, and the immutable +provenance described above unless a later confirmation contract explicitly +authorizes another interpretation. + +The rationale and evidence review are in the +[JEPA four-channel EEG decision +memo](../../docs/research/jepa-four-channel-eeg-decision-memo-v0.md). diff --git a/NeuralComposeEEG/experiments/EXP-NC-EEG-JEPA-SYN-000.md b/NeuralComposeEEG/experiments/EXP-NC-EEG-JEPA-SYN-000.md new file mode 100644 index 0000000..74b81f6 --- /dev/null +++ b/NeuralComposeEEG/experiments/EXP-NC-EEG-JEPA-SYN-000.md @@ -0,0 +1,122 @@ +# EXP-NC-EEG-JEPA-SYN-000: Synthetic JEPA Pipeline Rehearsal + +**Status:** executable +**Classification:** deterministic synthetic pipeline rehearsal +**Source type:** `deterministic_synthetic_fixture` +**Physical EEG used:** false +**Decision:** `pipeline_evidence_only` +**Promotion status:** `not_eligible` +**Runtime change:** none + +## Question + +> Does the offline objective, masking, collapse-diagnostic, grouped-evaluation, +> and artifact pipeline distinguish preregistered synthetic structures and +> fail in expected ways under controlled negative cases? + +This is not an execution of +[`EXP-NC-EEG-JEPA-001`](EXP-NC-EEG-JEPA-001.md). It cannot establish that +JEPA learns transferable Muse EEG representations and cannot set a threshold +for the later physical study. + +## Source Boundary + +The only admitted source is the dedicated deterministic generator specified by +[`jepa-synthetic-generators-v0`](../../docs/scoping/jepa-synthetic-generators-v0.json). +Its manifest uses `nc-eeg-jepa-synthetic-source-v0`, synthetic session IDs are +prefixed with `synthetic:`, `device_id` is `synthetic-generator`, +`participant_id` is null, and `physical_capture_eligible` is false. + +NeuralCompose fallback-degraded or synthetic acquisition output is prohibited. +It remains evidence that live acquisition failed and must not enter this +experiment, the canonical physical source manifest, or a valid Muse session. + +## Generators + +| ID | Controlled structure | Expected use | +| --- | --- | --- | +| S0 | two mixed latent oscillators | low-rank and temporal-prediction checks | +| S1 | four independent noise channels | no-predictability negative control | +| S2 | shared common-mode transient | artifact-dominance check | +| S3 | session-specific nuisance signature | session-shortcut check | +| S4 | copied, flat, or missing channel | rank and provenance check | +| S5 | latent state-transition process | paired-target and probe check | +| S6 | weak-signal collapse trap | anti-collapse attachment check | + +Every expected invariant is preregistered in the generator specification. A +failure identifies a pipeline defect or an inadequately specified synthetic +generator. It is not evidence against JEPA on EEG. + +## Conditions + +| ID | Condition | +| --- | --- | +| T0 | seeded random encoder | +| T1 | masked reconstruction | +| T2 | latent prediction without anti-collapse regularization | +| T3 | latent prediction plus SIGReg-style spectral pressure | +| T4 | latent prediction plus VICReg-style variance/covariance pressure | +| T5 | latent prediction plus bounded masked reconstruction | + +T0-T5 instantiate the same encoder, projector, predictor, and decoder so total +parameter count is identical. T1-T5 use the same generated samples, complete +session folds, batch schedule, optimization steps, optimizer, seed, +context/target geometry, mask schedule, and probe budget. Objective-specific +inactive modules remain present but do not receive an invented loss. + +For T3 and T4, anti-collapse pressure is applied to the named +`projector_embedding`. Predictor-output-only regularization invalidates the +condition. The EMA target is stop-gradient. + +## Evaluation + +Complete synthetic sessions are the split unit. Normalization, probes, and +every trained model are fit inside the training sessions of each outer fold. +Adjacent windows from one session cannot be split across train and test. + +The rehearsal records: + +- correct, temporally permuted, and different-session target losses; +- channel-order, coordinate, and mask-location controls; +- per-dimension variance and covariance; +- complete singular spectra, effective rank, energy rank, and condition number; +- pairwise distance, constant-output, and feature-utilization checks; +- nearest-neighbor session identity; +- predictor and regularization losses; +- a fixed-budget synthetic-state linear probe; +- non-finite rejection; +- checkpoint, source, configuration, code, and report hashes; and +- expected-invariant pass/fail results. + +Synthetic thresholds are generator checks only. They cannot tune or justify +physical-study thresholds. + +## Artifacts + +The runner emits local ignored artifacts using: + +```yaml +experiment_id: EXP-NC-EEG-JEPA-SYN-000 +source_type: deterministic_synthetic_fixture +physical_eeg_used: false +scientific_transfer_claim_allowed: false +decision: pipeline_evidence_only +promotion_status: not_eligible +runtime_change: none +``` + +The synthetic source manifest is intentionally incompatible with the physical +Muse source schema. + +Run the pinned rehearsal from the repository root: + +```bash +PYTHONPATH=NeuralComposeEEG/src python3 -m neuralcompose_eeg.jepa_synthetic +``` + +## Prohibited Interpretations + +This experiment cannot support physical artifact metrics, cross-session Muse +generalization, participant or headset robustness, physical data-efficiency +claims, encoder selection, Core ML conversion, app integration, or promotion. +It does not alter M0-M4, acquisition, preprocessing, or runtime behavior. diff --git a/NeuralComposeEEG/experiments/EXP-NC-EEG-JEPA-SYN-MODE-000.md b/NeuralComposeEEG/experiments/EXP-NC-EEG-JEPA-SYN-MODE-000.md new file mode 100644 index 0000000..b29d1f3 --- /dev/null +++ b/NeuralComposeEEG/experiments/EXP-NC-EEG-JEPA-SYN-MODE-000.md @@ -0,0 +1,68 @@ +# EXP-NC-EEG-JEPA-SYN-MODE-000: Mode-Stratified Synthetic JEPA Rehearsal + +**Status:** executable +**Parent:** [`EXP-NC-EEG-JEPA-SYN-000`](EXP-NC-EEG-JEPA-SYN-000.md) +**Decision:** `pipeline_evidence_only` +**Promotion status:** `not_eligible` +**Physical EEG claims:** prohibited +**Cognitive-mode inference:** prohibited + +## Question + +> Does the synthetic JEPA preserve predictable dynamics across four externally +> assigned generator regimes, use correct mode context rather than a shortcut, +> and generalize under leave-one-mode-out controls? + +The labels `mirror`, `focus`, `reflective`, and `contemplative` mean only that +the deterministic generator was configured in that regime. They do not name +mental states and are not inferred from EEG, dialogue, or user behavior. + +## Generator Regimes + +| Mode | Controlled synthetic dynamics | Prohibited interpretation | +| --- | --- | --- | +| mirror | faster state tracking and short-range continuity | self-recognition | +| focus | lower transition rate and stronger persistence | attention detection | +| reflective | delayed transitions and longer dependence | reflection decoding | +| contemplative | slower low-frequency latent dynamics | meditation classification | + +Noise, artifacts, mixing matrices, offsets, dropout patterns, frequencies, and +latent ranks are crossed independently of mode. No mode is one fixed waveform, +amplitude, session, block length, mask schedule, or nuisance family. + +## Controls + +| ID | Condition | +| --- | --- | +| C0 | mode-blind latent predictor | +| C1 | predictor receives the correct assigned mode | +| C2 | predictor receives a seeded shuffled mode | +| C3 | predictor receives one constant uninformative mode | +| C4 | mode-blind leave-one-mode-out evaluation | +| C5 | mode is available only to the downstream probe | + +C0-C3 and C5 share complete-session folds. C4 trains on complete sessions from +three modes and evaluates complete sessions from the fourth, repeated for all +four modes. The model always contains the same mode-context input parameters; +blind and constant controls zero or replace values rather than changing model +capacity. + +## Required Diagnostics + +Collapse diagnostics are reported globally, per mode, on mode transitions when +present, and for every held-out-mode fold. Mode-neighbor identity is reported +alongside session-neighbor identity. Near-perfect mode clustering is a possible +shortcut, not independent evidence of a useful representation. + +The extension can test whether correct explicit context improves synthetic +prediction, whether shuffled context removes that benefit, and whether a +mode-blind representation transfers to an unseen generator regime. It cannot +support a claim about human cognition or physical EEG. + +## Physical Boundary + +Adding real application modes to a physical acquisition protocol would require +a new protocol revision and a separate experiment with immutable mode events +and explicit speech, silence, audio, EMG, pacing, and block-timing controls. +This rehearsal does not relabel or amend the current observable-state encoder +experiment. diff --git a/NeuralComposeEEG/experiments/EXP-NC-SVD-DIAG-001.md b/NeuralComposeEEG/experiments/EXP-NC-SVD-DIAG-001.md new file mode 100644 index 0000000..b84050a --- /dev/null +++ b/NeuralComposeEEG/experiments/EXP-NC-SVD-DIAG-001.md @@ -0,0 +1,59 @@ +# EXP-NC-SVD-DIAG-001: Read-Only SVD Diagnostics + +**Status:** proposed, not executed +**Classification:** B. Optional Pass 1 controlled experiment +**Earliest gate:** D0 for synthetic fixtures; D1 for a physical capture report +**Promotion status:** not_eligible +**Runtime dependency:** prohibited + +## Question + +> Do read-only singular-spectrum diagnostics identify capture or feature-matrix +> defects not already identified by simpler deterministic checks? + +## Fixed Boundary + +This proposal does not change capture eligibility, preprocessing, windows, +labels, M0-M4, or the application. It never receives dialogue data. A D1 +report describes an integrity-valid capture only; it cannot establish a +physical-data model claim. + +## Inputs + +The spatial diagnostic uses a channel-centered four-channel window X with +shape 4 by 1024. It records the complete four-value singular spectrum, energy +fractions, entropy effective rank, and condition number only under a pinned +numerical floor. It labels raw-amplitude and calibration-z-scored matrices +separately. + +The first baseline is existing direct evidence: channel variance, amplitude +range, pairwise correlation, difference RMS where defined, packet loss, +signal-quality fields, and missing-channel masks. + +## Design + +D0 synthetic fixtures have fixed truth for a flat/disconnected channel, +duplicated channel, near-collinear channel, common-mode contamination, +full-rank noise, and nonfinite/missing channel. Nonfinite/missing values are +rejected before SVD. The same fixture is evaluated in float32 and float64 with +a preregistered threshold sensitivity table. + +A D1 report is read-only. It has no learned threshold, does not exclude a +capture, and does not feed an encoder or a feature extractor. + +## Outcomes and Falsification + +Primary outcomes are deterministic reproducibility, defect sensitivity, +false-positive rate, and incremental detections beyond direct checks. Retire the +report when direct checks detect the same defects with equal or lower +false-positive rate, or when conclusions change materially by dtype or +threshold. + +## Artifact + +Emit nc-eeg-svd-diagnostic-report-v0 with fixture/capture hash, matrix +definition, backend/dtype, spectrum, thresholds, direct-check comparison, and +capture_eligibility_changed: false. + +The detailed shared controls are in the +[SVD experiment roadmap](../../docs/scoping/svd-eeg-experiment-roadmap.md). diff --git a/NeuralComposeEEG/experiments/EXP-NC-SVD-INV-001.md b/NeuralComposeEEG/experiments/EXP-NC-SVD-INV-001.md new file mode 100644 index 0000000..f8ce452 --- /dev/null +++ b/NeuralComposeEEG/experiments/EXP-NC-SVD-INV-001.md @@ -0,0 +1,50 @@ +# EXP-NC-SVD-INV-001: Synthetic Inverse Regularization + +**Status:** proposed, deferred Pass 2 study +**Classification:** C. Pass 2 inverse/forward-model method +**Earliest gate:** D3, with a separately approved forward-model hypothesis +**Promotion status:** not_eligible +**Physical Muse source-localization claim:** false +**Runtime dependency:** prohibited + +## Question + +> In a synthetic forward model with known source and noise structure, do TSVD +> or Tikhonov regularizers improve reconstruction error and stability relative +> to an unregularized pseudoinverse? + +## Required Preregistration + +Before execution, specify the discretized forward operator G, sensor and source +geometry, conductivity model, boundary assumptions, source target, noise +distribution/covariance, penalty operator L, generator seeds, regularization +selection rule, metrics, and held-out synthetic draws. + +The proposal must explain why the synthetic system is not a claim that four +Muse channels locate human cortical sources. It uses no physical Muse inverse +target. + +## Conditions + +Compare the unregularized pseudoinverse with training/simulation-selected TSVD +and identity-Tikhonov. Use generalized SVD only when a real nonidentity penalty +operator L exists and is justified. Evaluate on unseen synthetic source/noise +draws, with a fixed noise-level and geometry-perturbation sensitivity grid. + +## Outcomes and Falsification + +Report source error, forward residual, stability under noise and geometry +perturbation, resolution/null-space summaries, regularization parameter, +precision, and complete singular-value filter. Retire a regularizer when it +does not improve preregistered metrics over pseudoinverse across unseen draws, +works only at a hand-picked noise level, or has no geometry robustness. + +## Artifact + +Emit nc-eeg-svd-inverse-synthetic-v0 with operator and generator hashes, +geometry/noise/penalty contract, numerical provenance, and +physical_muse_claim: false. A result does not authorize app input, live +control, production code, or a physical source-localization claim. + +See the [SVD experiment roadmap](../../docs/scoping/svd-eeg-experiment-roadmap.md) +for broader scope and controls. diff --git a/NeuralComposeEEG/experiments/EXP-NC-SVD-M0-001.md b/NeuralComposeEEG/experiments/EXP-NC-SVD-M0-001.md new file mode 100644 index 0000000..90d8327 --- /dev/null +++ b/NeuralComposeEEG/experiments/EXP-NC-SVD-M0-001.md @@ -0,0 +1,58 @@ +# EXP-NC-SVD-M0-001: Train-Only M0 Feature Reduction + +**Status:** proposed, deferred until D2 +**Classification:** B. Optional Pass 1 controlled experiment +**Earliest gate:** D2, source-manifest-eligible multi-day cohort +**Promotion status:** not_eligible +**Runtime dependency:** prohibited + +## Question + +> Under identical complete-session splits, does a preregistered train-only +> truncated-SVD feature path improve grouped calibration or generalization over +> unchanged M0? + +## Fixed Boundary + +This is a separate sensitivity study. It does not modify +EXP-NC-EEG-ENC-001, experiment-v0.json, feature extraction, labels, or model +budget. The unchanged M0 is training-fold standardization followed by the +existing L2 logistic regression with C = 1. + +## Conditions + +| ID | Condition | +| --- | --- | +| B0 | unchanged M0 | +| B1 | train-only non-whitened PCA/truncated-SVD features then unchanged L2 logistic head | +| B2 | train-only whitened PCA features then unchanged L2 logistic head | +| B3 | seeded random orthogonal projection at B1's per-fold dimension then unchanged head | +| B4 | preregistered no-SVD feature-count-matched deterministic selector, if available | + +For each fold, rank is the smallest training-only rank reaching 0.95 cumulative +explained variance, bounded by 1 through min(p, n_train - 1). The 0.90 and +0.99 rules are reported as sensitivity analyses. Means, scales, components, +rank, and whitening floor are fit from the training partition only; held-out +sessions are transformed only by those stored fold artifacts. + +A pseudoinverse/least-squares score may appear only as a numerical teaching +control. It is not mathematically equivalent to, and cannot replace, multiclass +logistic M0. + +## Outcomes and Falsification + +Use the existing grouped metrics: balanced accuracy where defined, macro F1, +Brier score, expected calibration error, artifact metrics, per-session +outcomes, runtime, and model size. Retire B1/B2 if they do not improve grouped +results over B0, lose to matched B3, destabilize retained rank, worsen artifact +behavior, or need test-informed selection. + +## Artifact + +Emit nc-eeg-svd-m0-evaluation-v0 with dataset/preprocessing/split hashes, +train-window digest, fitted-transform hashes, retained rank, singular-value +floor, held-out predictions, and result status. Any positive result is +supported_for_further_science only. + +See the [SVD experiment roadmap](../../docs/scoping/svd-eeg-experiment-roadmap.md) +for shared controls and artifact rules. diff --git a/NeuralComposeEEG/experiments/EXP-NC-SVD-REP-001.md b/NeuralComposeEEG/experiments/EXP-NC-SVD-REP-001.md new file mode 100644 index 0000000..68e0ad4 --- /dev/null +++ b/NeuralComposeEEG/experiments/EXP-NC-SVD-REP-001.md @@ -0,0 +1,48 @@ +# EXP-NC-SVD-REP-001: Encoder Representation Rank + +**Status:** proposed, deferred until D3 +**Classification:** D. Pass 3 computational or analysis tooling +**Earliest gate:** D3, after encoder evidence and a frozen representation contract +**Promotion status:** not_eligible +**Runtime dependency:** prohibited + +## Question + +> Do selected-model EEGPT/BENDR representations show more stable effective +> rank across held-out sessions than random-init, shuffled-mapping, and +> zero-fill controls? + +## Fixed Boundary + +This is a descriptive representation study, not an encoder-selection criterion. +It does not alter a trained EEGNet, EEGPT, or BENDR condition. It does not fit a +global PCA or whitening map across sessions, and it never turns a rank plot +into evidence of transfer. + +## Inputs and Controls + +For a completed fold, collect named activations or adapter outputs only after +the model is frozen. Build a per-session matrix with a fixed seeded number of +held-out window rows and one named-layer dimension per column. Bind each report +to the canonical dataset, grouped split, model/checkpoint, adapter, and +missing-channel-mask provenance. + +Compare M2 random initialization, M3 EEGPT, M4 BENDR where eligible, shuffled +mapping, and zero-fill controls. Existing M0/M1 predictive and calibration +reports remain independent evidence. + +## Outcomes and Falsification + +Report complete spectra, entropy effective rank, preregistered energy ranks, +reconstruction curves, and per-session stability. Reject an interpretation when +rank differences vanish under controls, are threshold-sensitive, are restricted +to one session, or lack the existing grouped predictive/calibration evidence. + +## Artifact + +Emit nc-eeg-svd-representation-report-v0 with the fold/group identifier, +activation shape, fixed subsampling seed, dtype, singular-spectrum summary, +thresholds, and all controls. The result can only motivate further science. + +Shared leakage controls are in the +[SVD experiment roadmap](../../docs/scoping/svd-eeg-experiment-roadmap.md). diff --git a/NeuralComposeEEG/src/neuralcompose_eeg/jepa_synthetic.py b/NeuralComposeEEG/src/neuralcompose_eeg/jepa_synthetic.py new file mode 100644 index 0000000..312fc05 --- /dev/null +++ b/NeuralComposeEEG/src/neuralcompose_eeg/jepa_synthetic.py @@ -0,0 +1,1728 @@ +"""Deterministic synthetic rehearsal for the offline four-channel JEPA pipeline. + +This module cannot consume physical capture manifests. It generates its own +fully reproducible fixtures, trains only the separately preregistered synthetic +conditions, and emits pipeline-evidence artifacts under a distinct schema. +""" + +from __future__ import annotations + +import argparse +import copy +import hashlib +import json +import math +import random +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Sequence + +import numpy as np +import torch +import torch.nn as nn +import torch.nn.functional as F +from sklearn.linear_model import LogisticRegression +from sklearn.pipeline import make_pipeline +from sklearn.preprocessing import StandardScaler + +from .contracts import ContractError, SOURCE_MANIFEST_SCHEMA, load_source_manifest +from .provenance import package_versions, runtime_provenance, sha256_file, sha256_json + + +CONFIG_SCHEMA = "nc-eeg-jepa-synthetic-config-v0" +GENERATOR_SCHEMA = "nc-eeg-jepa-synthetic-generators-v0" +SOURCE_SCHEMA = "nc-eeg-jepa-synthetic-source-v0" +REPORT_SCHEMA = "nc-eeg-jepa-synthetic-rehearsal-v0" +MODE_REPORT_SCHEMA = "nc-eeg-jepa-synthetic-mode-rehearsal-v0" +EXPERIMENT_ID = "EXP-NC-EEG-JEPA-SYN-000" +MODE_EXPERIMENT_ID = "EXP-NC-EEG-JEPA-SYN-MODE-000" +CONDITIONS = ("T0", "T1", "T2", "T3", "T4", "T5") +MODE_CONTROLS = ("C0", "C1", "C2", "C3", "C4", "C5") +MODES = ("mirror", "focus", "reflective", "contemplative") + + +@dataclass(frozen=True) +class SyntheticSession: + session_id: str + generator_id: str + windows: np.ndarray + state_labels: np.ndarray + mode: str | None + nuisance: dict[str, Any] + missing_channel_provenance: str | None = None + + def validate(self, *, channels: int, window_samples: int) -> None: + if not self.session_id.startswith("synthetic:"): + raise ContractError("synthetic session IDs must begin with synthetic:") + if self.windows.ndim != 3 or self.windows.shape[1:] != ( + channels, + window_samples, + ): + raise ContractError( + f"{self.session_id}: expected windows [window,{channels},{window_samples}]" + ) + if len(self.windows) != len(self.state_labels): + raise ContractError(f"{self.session_id}: state-label count mismatch") + if len(self.windows) < 2: + raise ContractError(f"{self.session_id}: at least two windows are required") + if not np.all(np.isfinite(self.windows)): + raise ContractError(f"{self.session_id}: synthetic windows contain nonfinite values") + if not np.all(np.isfinite(self.state_labels)): + raise ContractError(f"{self.session_id}: synthetic labels contain nonfinite values") + if self.mode is not None and self.mode not in MODES: + raise ContractError(f"{self.session_id}: unknown synthetic mode {self.mode}") + + +@dataclass(frozen=True) +class PairSet: + context: np.ndarray + target: np.ndarray + labels: np.ndarray + session_ids: np.ndarray + modes: np.ndarray + pair_ids: np.ndarray + + def subset(self, session_ids: set[str]) -> "PairSet": + indices = np.flatnonzero(np.isin(self.session_ids, sorted(session_ids))) + return PairSet( + context=self.context[indices], + target=self.target[indices], + labels=self.labels[indices], + session_ids=self.session_ids[indices], + modes=self.modes[indices], + pair_ids=self.pair_ids[indices], + ) + + +@dataclass(frozen=True) +class Fold: + fold_id: str + train_sessions: tuple[str, ...] + validation_sessions: tuple[str, ...] + test_sessions: tuple[str, ...] + + +def _read_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as exc: + raise ContractError(f"cannot read JSON contract {path}: {exc}") from exc + if not isinstance(value, dict): + raise ContractError(f"{path} must contain an object") + return value + + +def load_contracts(config_path: Path, generator_path: Path) -> tuple[dict[str, Any], dict[str, Any]]: + config = _read_json(config_path) + generators = _read_json(generator_path) + if config.get("schema_version") != CONFIG_SCHEMA: + raise ContractError(f"expected config schema {CONFIG_SCHEMA}") + if generators.get("schema_version") != GENERATOR_SCHEMA: + raise ContractError(f"expected generator schema {GENERATOR_SCHEMA}") + if config.get("experiment_id") != EXPERIMENT_ID: + raise ContractError(f"synthetic config must use {EXPERIMENT_ID}") + if generators.get("experiment_id") != EXPERIMENT_ID: + raise ContractError(f"generator contract must use {EXPERIMENT_ID}") + if config.get("mode_experiment_id") != MODE_EXPERIMENT_ID: + raise ContractError(f"mode extension must use {MODE_EXPERIMENT_ID}") + source = config.get("source", {}) + if source.get("schema_version") != SOURCE_SCHEMA: + raise ContractError(f"synthetic source must use {SOURCE_SCHEMA}") + if source.get("source_type") != "deterministic_synthetic_fixture": + raise ContractError("only deterministic_synthetic_fixture is admitted") + if source.get("physical_capture_eligible") is not False: + raise ContractError("synthetic fixtures must be physically ineligible") + if source.get("fallback_capture_allowed") is not False: + raise ContractError("fallback acquisition must remain prohibited") + if tuple(config["training"]["conditions"]) != CONDITIONS: + raise ContractError(f"conditions must be {list(CONDITIONS)}") + if tuple(config["mode_extension"]["controls"]) != MODE_CONTROLS: + raise ContractError(f"mode controls must be {list(MODE_CONTROLS)}") + if tuple(config["mode_extension"]["modes"]) != MODES: + raise ContractError(f"modes must be {list(MODES)}") + if config.get("device") != "cpu": + raise ContractError("v0 pins CPU execution for deterministic rehearsal") + if generators["fallback_acquisition_stream"].get("accepted") is not False: + raise ContractError("generator contract must reject fallback acquisition") + registered = {entry["id"] for entry in generators.get("generators", [])} + if registered != set(config["data"]["generator_ids"]): + raise ContractError("config and generator registry disagree") + return config, generators + + +def _seed_everything(seed: int) -> None: + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + torch.use_deterministic_algorithms(True) + torch.set_num_threads(1) + + +def _mixing_matrix(rng: np.random.Generator, channels: int, latent_dim: int) -> np.ndarray: + matrix = rng.normal(size=(channels, latent_dim)) + q, _ = np.linalg.qr(matrix) + return q[:, :latent_dim].astype(np.float32) + + +def _session_hash(session: SyntheticSession) -> str: + digest = hashlib.sha256() + digest.update(session.session_id.encode("ascii")) + digest.update(session.generator_id.encode("ascii")) + digest.update((session.mode or "").encode("ascii")) + digest.update(np.ascontiguousarray(session.windows).tobytes()) + digest.update(np.ascontiguousarray(session.state_labels).tobytes()) + digest.update(json.dumps(session.nuisance, sort_keys=True, separators=(",", ":")).encode("ascii")) + digest.update((session.missing_channel_provenance or "").encode("ascii")) + return digest.hexdigest() + + +def _window_time(window_index: int, window_samples: int, sample_rate: int) -> np.ndarray: + start = window_index * window_samples + return (start + np.arange(window_samples, dtype=np.float64)) / sample_rate + + +def _generate_base_session( + generator_id: str, + session_index: int, + *, + windows_per_session: int, + channels: int, + window_samples: int, + sample_rate: int, + seed: int, +) -> SyntheticSession: + rng = np.random.default_rng(seed + 1009 * session_index) + session_id = f"synthetic:{generator_id}:session-{session_index:02d}" + values = np.zeros((windows_per_session, channels, window_samples), dtype=np.float32) + labels = np.zeros(windows_per_session, dtype=np.int64) + nuisance: dict[str, Any] = { + "seed": seed + 1009 * session_index, + "mixing_family": session_index % 4, + "noise_family": session_index % 3, + } + missing: str | None = None + + if generator_id == "S0": + # S0 registers a globally known rank-two observation process. Session + # phases and noise vary, but the mixing basis is fixed by the generator + # seed so pooling complete sessions cannot manufacture extra rank. + mixing = _mixing_matrix(np.random.default_rng(seed), channels, 2) + phase = rng.uniform(-math.pi, math.pi, size=2) + frequencies = np.asarray([4.3, 7.1]) + for index in range(windows_per_session): + t = _window_time(index, window_samples, sample_rate) + latent = np.vstack( + [np.sin(2 * np.pi * frequencies[k] * t + phase[k]) for k in range(2)] + ) + values[index] = mixing @ latent + rng.normal( + scale=0.015, size=(channels, window_samples) + ) + labels[index] = int(np.sin(2 * np.pi * 0.17 * index + phase[0]) > 0) + nuisance["latent_rank"] = 2 + elif generator_id == "S1": + values[:] = rng.normal(size=values.shape) + labels[:] = rng.integers(0, 2, size=windows_per_session) + nuisance["latent_rank"] = 4 + elif generator_id == "S2": + mixing = _mixing_matrix(rng, channels, 2) + common_vector = np.ones((channels, 1), dtype=np.float64) + for index in range(windows_per_session): + t = _window_time(index, window_samples, sample_rate) + latent = np.vstack( + [ + np.sin(2 * np.pi * 5.2 * t), + 0.6 * np.cos(2 * np.pi * 8.4 * t + 0.2 * session_index), + ] + ) + signal = 0.25 * (mixing @ latent) + center = (13 * index + 7 * session_index) % window_samples + sample_axis = np.arange(window_samples) + transient = 10.0 * np.exp(-0.5 * ((sample_axis - center) / 2.2) ** 2) + values[index] = signal + common_vector @ transient[None, :] + rng.normal( + scale=0.03, size=(channels, window_samples) + ) + labels[index] = index % 2 + nuisance["artifact_probability"] = 1.0 + elif generator_id == "S3": + signature_frequency = 2.2 + 0.8 * session_index + offsets = rng.normal(scale=4.0, size=(channels, 1)) + signature_amplitude = rng.uniform(2.0, 4.0, size=(channels, 1)) + for index in range(windows_per_session): + local_t = np.arange(window_samples, dtype=np.float64) / sample_rate + signature = signature_amplitude * np.sin( + 2 * np.pi * signature_frequency * local_t[None, :] + + np.arange(channels)[:, None] * 0.3 + ) + values[index] = offsets + signature + rng.normal( + scale=0.25, size=(channels, window_samples) + ) + labels[index] = rng.integers(0, 2) + nuisance["session_offset_norm"] = float(np.linalg.norm(offsets)) + elif generator_id == "S4": + for index in range(windows_per_session): + local_t = np.arange(window_samples, dtype=np.float64) / sample_rate + base = np.vstack( + [ + np.sin(2 * np.pi * (3.1 + channel) * local_t + 0.1 * index) + for channel in range(channels) + ] + ) + values[index] = base + rng.normal(scale=0.01, size=base.shape) + labels[index] = index % 2 + pattern = session_index % 3 + if pattern == 0: + values[:, 3, :] = values[:, 2, :] + missing = "TP10_copied_from_AF8" + elif pattern == 1: + values[:, 3, :] = 0.0 + missing = "TP10_flat_zero" + else: + values[:, 1, :] = values[:, 0, :] + missing = "AF7_copied_from_TP9" + nuisance["missing_channel_pattern"] = missing + elif generator_id == "S5": + mixing = _mixing_matrix(rng, channels, 2) + state = int(session_index % 2) + for index in range(windows_per_session): + if rng.random() > 0.86: + state = 1 - state + local_t = np.arange(window_samples, dtype=np.float64) / sample_rate + frequency = 4.0 if state == 0 else 9.0 + latent = np.vstack( + [ + np.sin(2 * np.pi * frequency * local_t + 0.15 * index), + (2 * state - 1) * np.cos(2 * np.pi * 2.5 * local_t), + ] + ) + values[index] = mixing @ latent + rng.normal( + scale=0.05, size=(channels, window_samples) + ) + labels[index] = state + nuisance["state_stay_probability"] = 0.86 + elif generator_id == "S6": + mixing = _mixing_matrix(rng, channels, 2) + latent_state = rng.normal(scale=0.05) + for index in range(windows_per_session): + latent_state = 0.97 * latent_state + rng.normal(scale=0.015) + local_t = np.arange(window_samples, dtype=np.float64) / sample_rate + common = np.sin(2 * np.pi * 5.5 * local_t) + weak = latent_state * np.cos(2 * np.pi * 1.7 * local_t + 0.2 * index) + latent = np.vstack([common, weak]) + values[index] = mixing @ latent + rng.normal( + scale=0.002, size=(channels, window_samples) + ) + labels[index] = int(latent_state > 0) + nuisance["collapse_trap_signal_scale"] = 0.015 + else: + raise ContractError(f"unknown generator {generator_id}") + + session = SyntheticSession( + session_id=session_id, + generator_id=generator_id, + windows=values, + state_labels=labels, + mode=None, + nuisance=nuisance, + missing_channel_provenance=missing, + ) + session.validate(channels=channels, window_samples=window_samples) + return session + + +def generate_base_sessions(config: dict[str, Any]) -> dict[str, list[SyntheticSession]]: + data = config["data"] + output: dict[str, list[SyntheticSession]] = {} + for generator_position, generator_id in enumerate(data["generator_ids"]): + generator_seed = int(config["seed"]) + 10_000 * generator_position + output[generator_id] = [ + _generate_base_session( + generator_id, + session_index, + windows_per_session=int(data["windows_per_session"]), + channels=int(data["channel_count"]), + window_samples=int(data["window_samples"]), + sample_rate=int(data["sample_rate_hz"]), + seed=generator_seed, + ) + for session_index in range(int(data["sessions_per_generator"])) + ] + return output + + +def _mode_dynamics(mode: str, history: list[float], rng: np.random.Generator, index: int) -> float: + previous = history[-1] if history else rng.normal(scale=0.2) + if mode == "mirror": + return 0.55 * previous + 0.45 * math.sin(0.55 * index) + rng.normal(scale=0.08) + if mode == "focus": + return 0.96 * previous + rng.normal(scale=0.035) + if mode == "reflective": + delayed = history[-3] if len(history) >= 3 else previous + return 0.25 * previous + 0.68 * delayed + rng.normal(scale=0.05) + if mode == "contemplative": + return 0.985 * previous + 0.08 * math.sin(0.12 * index) + rng.normal(scale=0.02) + raise ContractError(f"unknown mode {mode}") + + +def generate_mode_sessions(config: dict[str, Any]) -> list[SyntheticSession]: + data = config["data"] + mode_config = config["mode_extension"] + sessions: list[SyntheticSession] = [] + nuisance_grid = [ + {"latent_rank": 2, "noise_strength": 0.03, "frequency": 4.1, "artifact_probability": 0.00}, + {"latent_rank": 3, "noise_strength": 0.07, "frequency": 6.3, "artifact_probability": 0.08}, + {"latent_rank": 2, "noise_strength": 0.12, "frequency": 8.2, "artifact_probability": 0.03}, + {"latent_rank": 3, "noise_strength": 0.05, "frequency": 5.4, "artifact_probability": 0.12}, + ] + for mode_index, mode in enumerate(mode_config["modes"]): + for session_index in range(int(mode_config["sessions_per_mode"])): + seed = int(config["seed"]) + 200_000 + mode_index * 10_000 + session_index + rng = np.random.default_rng(seed) + nuisance = dict(nuisance_grid[session_index % len(nuisance_grid)]) + nuisance.update( + { + "seed": seed, + "mixing_family": session_index, + "session_offset": float(rng.normal(scale=0.15)), + "missing_channel_pattern": "none", + } + ) + mixing = _mixing_matrix( + rng, + int(data["channel_count"]), + int(nuisance["latent_rank"]), + ) + windows = np.zeros( + ( + int(data["windows_per_session"]), + int(data["channel_count"]), + int(data["window_samples"]), + ), + dtype=np.float32, + ) + labels = np.zeros(int(data["windows_per_session"]), dtype=np.int64) + history: list[float] = [] + for window_index in range(int(data["windows_per_session"])): + state = _mode_dynamics(mode, history, rng, window_index) + history.append(state) + local_t = np.arange(int(data["window_samples"])) / int(data["sample_rate_hz"]) + latent_rows = [ + state * np.sin(2 * np.pi * nuisance["frequency"] * local_t + 0.1 * window_index), + np.cos(2 * np.pi * (2.0 + 0.2 * session_index) * local_t + state), + ] + if int(nuisance["latent_rank"]) == 3: + latent_rows.append(0.5 * np.sin(2 * np.pi * 10.0 * local_t + 0.3 * state)) + latent = np.vstack(latent_rows) + signal = mixing @ latent + nuisance["session_offset"] + signal += rng.normal(scale=nuisance["noise_strength"], size=signal.shape) + if rng.random() < nuisance["artifact_probability"]: + center = rng.integers(4, int(data["window_samples"]) - 4) + pulse = 2.0 * np.exp( + -0.5 + * ( + (np.arange(int(data["window_samples"])) - center) + / 1.5 + ) + ** 2 + ) + signal += pulse + windows[window_index] = signal + labels[window_index] = int(state > 0) + session = SyntheticSession( + session_id=f"synthetic:MODE:{mode}:session-{session_index:02d}", + generator_id="MODE", + windows=windows, + state_labels=labels, + mode=mode, + nuisance=nuisance, + ) + session.validate( + channels=int(data["channel_count"]), + window_samples=int(data["window_samples"]), + ) + sessions.append(session) + return sessions + + +def make_pairs(sessions: Sequence[SyntheticSession]) -> PairSet: + contexts: list[np.ndarray] = [] + targets: list[np.ndarray] = [] + labels: list[int] = [] + session_ids: list[str] = [] + modes: list[str] = [] + pair_ids: list[str] = [] + for session in sessions: + for index in range(len(session.windows) - 1): + contexts.append(session.windows[index]) + targets.append(session.windows[index + 1]) + labels.append(int(session.state_labels[index + 1])) + session_ids.append(session.session_id) + modes.append(session.mode or "") + pair_ids.append(f"{session.session_id}:pair-{index:03d}") + return PairSet( + context=np.asarray(contexts, dtype=np.float32), + target=np.asarray(targets, dtype=np.float32), + labels=np.asarray(labels, dtype=np.int64), + session_ids=np.asarray(session_ids, dtype="U96"), + modes=np.asarray(modes, dtype="U24"), + pair_ids=np.asarray(pair_ids, dtype="U128"), + ) + + +def grouped_folds(session_ids: Sequence[str], config: dict[str, Any]) -> list[Fold]: + ordered = tuple(sorted(set(session_ids))) + test_count = int(config["split"]["outer_test_sessions"]) + validation_count = int(config["split"]["inner_validation_sessions"]) + fold_count = int(config["split"]["outer_fold_count"]) + if len(ordered) < test_count + validation_count + 1: + raise ContractError("not enough complete synthetic sessions for grouped folds") + folds: list[Fold] = [] + for fold_index in range(fold_count): + rotated = ordered[fold_index * test_count :] + ordered[: fold_index * test_count] + test = rotated[:test_count] + validation = rotated[test_count : test_count + validation_count] + train = tuple(value for value in ordered if value not in set(test + validation)) + folds.append( + Fold( + fold_id=f"fold-{fold_index}", + train_sessions=train, + validation_sessions=validation, + test_sessions=test, + ) + ) + return folds + + +def mode_grouped_fold(sessions: Sequence[SyntheticSession]) -> Fold: + grouped = {mode: sorted(s.session_id for s in sessions if s.mode == mode) for mode in MODES} + if any(len(values) < 4 for values in grouped.values()): + raise ContractError("mode rehearsal requires at least four complete sessions per mode") + train = tuple(value for mode in MODES for value in grouped[mode][:2]) + validation = tuple(grouped[mode][2] for mode in MODES) + test = tuple(grouped[mode][3] for mode in MODES) + return Fold("mode-grouped", train, validation, test) + + +def leave_one_mode_out_folds(sessions: Sequence[SyntheticSession]) -> list[Fold]: + folds: list[Fold] = [] + for held_out in MODES: + test = tuple(sorted(s.session_id for s in sessions if s.mode == held_out)) + remaining = {mode: sorted(s.session_id for s in sessions if s.mode == mode) for mode in MODES if mode != held_out} + validation = tuple(values[-1] for values in remaining.values()) + train = tuple(value for values in remaining.values() for value in values[:-1]) + folds.append(Fold(f"leave-{held_out}-out", train, validation, test)) + return folds + + +def fit_normalization(train: PairSet) -> tuple[np.ndarray, np.ndarray]: + joined = np.concatenate([train.context, train.target], axis=0) + mean = joined.mean(axis=(0, 2), keepdims=True).astype(np.float32) + std = joined.std(axis=(0, 2), keepdims=True).astype(np.float32) + return mean, np.maximum(std, 1e-6) + + +def normalize_pairs(pairs: PairSet, mean: np.ndarray, std: np.ndarray) -> PairSet: + return PairSet( + context=((pairs.context - mean) / std).astype(np.float32), + target=((pairs.target - mean) / std).astype(np.float32), + labels=pairs.labels.copy(), + session_ids=pairs.session_ids.copy(), + modes=pairs.modes.copy(), + pair_ids=pairs.pair_ids.copy(), + ) + + +def _mask_for_indices( + pair_indices: np.ndarray, + *, + window_samples: int, + fraction: float, + seed: int, + offset: int = 0, +) -> np.ndarray: + span = max(1, int(round(window_samples * fraction))) + mask = np.zeros((len(pair_indices), 1, window_samples), dtype=bool) + available = window_samples - span + 1 + for row, pair_index in enumerate(pair_indices.tolist()): + start = (seed * 131 + int(pair_index) * 17 + offset * 29) % available + mask[row, 0, start : start + span] = True + return mask + + +def _mode_one_hot(modes: Sequence[str]) -> np.ndarray: + output = np.zeros((len(modes), len(MODES)), dtype=np.float32) + for index, mode in enumerate(modes): + if mode: + output[index, MODES.index(str(mode))] = 1.0 + return output + + +def mode_context(modes: np.ndarray, policy: str, seed: int) -> np.ndarray: + correct = _mode_one_hot(modes) + if policy in {"C0", "C5", "C4"}: + return np.zeros_like(correct) + if policy == "C1": + return correct + if policy == "C2": + rng = np.random.default_rng(seed) + shuffled = correct.copy() + rng.shuffle(shuffled, axis=0) + return shuffled + if policy == "C3": + output = np.zeros_like(correct) + output[:, 0] = 1.0 + return output + raise ContractError(f"unknown mode-context policy {policy}") + + +class SyntheticJEPA(nn.Module): + """One fixed-capacity model instantiated for every synthetic condition.""" + + def __init__( + self, + channels: int, + window_samples: int, + encoder_hidden_dim: int, + latent_dim: int, + predictor_hidden_dim: int, + mode_context_dim: int, + ): + super().__init__() + self.channels = channels + self.window_samples = window_samples + self.encoder = nn.Sequential( + nn.Conv1d(channels, encoder_hidden_dim, kernel_size=7, padding=3), + nn.GELU(), + nn.Conv1d(encoder_hidden_dim, encoder_hidden_dim, kernel_size=5, stride=2, padding=2), + nn.GELU(), + nn.Flatten(), + nn.Linear( + encoder_hidden_dim * math.ceil(window_samples / 2), + encoder_hidden_dim, + ), + nn.GELU(), + ) + self.projector = nn.Linear(encoder_hidden_dim, latent_dim) + self.predictor = nn.Sequential( + nn.Linear(latent_dim + mode_context_dim, predictor_hidden_dim), + nn.GELU(), + nn.Linear(predictor_hidden_dim, latent_dim), + ) + self.decoder = nn.Linear(latent_dim, channels * window_samples) + self.target_encoder = copy.deepcopy(self.encoder) + self.target_projector = copy.deepcopy(self.projector) + for parameter in list(self.target_encoder.parameters()) + list(self.target_projector.parameters()): + parameter.requires_grad = False + self.target_encoder.eval() + self.target_projector.eval() + + def encode(self, window: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + encoder_embedding = self.encoder(window) + return encoder_embedding, self.projector(encoder_embedding) + + @torch.no_grad() + def target(self, window: torch.Tensor) -> torch.Tensor: + return self.target_projector(self.target_encoder(window)) + + def predict(self, latent: torch.Tensor, mode: torch.Tensor) -> torch.Tensor: + return self.predictor(torch.cat([latent, mode], dim=1)) + + def reconstruct(self, latent: torch.Tensor) -> torch.Tensor: + return self.decoder(latent).reshape(-1, self.channels, self.window_samples) + + @torch.no_grad() + def update_target(self, tau: float) -> None: + online_modules = (self.encoder, self.projector) + target_modules = (self.target_encoder, self.target_projector) + for online_module, target_module in zip(online_modules, target_modules, strict=True): + for target, online in zip(target_module.parameters(), online_module.parameters(), strict=True): + target.mul_(tau).add_(online, alpha=1.0 - tau) + for target, online in zip(target_module.buffers(), online_module.buffers(), strict=True): + target.copy_(online) + + +def build_model(config: dict[str, Any]) -> SyntheticJEPA: + model_config = config["model"] + data = config["data"] + return SyntheticJEPA( + channels=int(data["channel_count"]), + window_samples=int(data["window_samples"]), + encoder_hidden_dim=int(model_config["encoder_hidden_dim"]), + latent_dim=int(model_config["latent_dim"]), + predictor_hidden_dim=int(model_config["predictor_hidden_dim"]), + mode_context_dim=int(model_config["mode_context_dim"]), + ) + + +def sigreg_loss(embedding: torch.Tensor) -> torch.Tensor: + """Spectral anti-collapse pressure on the registered projector tensor.""" + if embedding.ndim != 2 or embedding.shape[0] < 2: + raise ValueError("SIGReg requires a two-dimensional batch with at least two rows") + centered = embedding - embedding.mean(dim=0, keepdim=True) + singular_values = torch.linalg.svdvals(centered / math.sqrt(embedding.shape[0] - 1)) + return torch.mean((singular_values - 1.0) ** 2) + torch.mean(embedding.mean(dim=0) ** 2) + + +def vicreg_terms(embedding: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + if embedding.ndim != 2 or embedding.shape[0] < 2: + raise ValueError("VICReg requires a two-dimensional batch with at least two rows") + centered = embedding - embedding.mean(dim=0, keepdim=True) + std = torch.sqrt(embedding.var(dim=0, unbiased=False) + 1e-4) + variance = torch.mean(F.relu(1.0 - std)) + covariance = centered.T @ centered / (embedding.shape[0] - 1) + off_diagonal = covariance - torch.diag(torch.diag(covariance)) + covariance_loss = off_diagonal.pow(2).sum() / embedding.shape[1] + return variance, covariance_loss + + +def _masked_reconstruction_loss( + reconstruction: torch.Tensor, + original: torch.Tensor, + mask: torch.Tensor, +) -> torch.Tensor: + expanded = mask.expand_as(original) + return F.mse_loss(reconstruction[expanded], original[expanded]) + + +def _training_batches(length: int, steps: int, batch_size: int, seed: int) -> list[np.ndarray]: + if length < 2: + raise ContractError("training partition requires at least two pairs") + rng = np.random.default_rng(seed) + return [ + rng.choice(length, size=batch_size, replace=length < batch_size) + for _ in range(steps) + ] + + +def train_condition( + condition: str, + train: PairSet, + config: dict[str, Any], + *, + seed: int, + mode_policy: str = "C0", +) -> tuple[SyntheticJEPA, dict[str, Any]]: + if condition not in CONDITIONS: + raise ContractError(f"unknown condition {condition}") + _seed_everything(seed) + model = build_model(config) + parameter_count = sum(parameter.numel() for parameter in model.parameters()) + trainable_parameter_count = sum( + parameter.numel() for parameter in model.parameters() if parameter.requires_grad + ) + if condition == "T0": + return model, { + "steps_completed": 0, + "parameter_count": parameter_count, + "trainable_parameter_count": trainable_parameter_count, + "batch_schedule_sha256": None, + "mask_schedule_sha256": None, + "last_losses": {}, + } + + training = config["training"] + optimizer = torch.optim.AdamW( + [parameter for parameter in model.parameters() if parameter.requires_grad], + lr=float(training["learning_rate"]), + weight_decay=float(training["weight_decay"]), + ) + batches = _training_batches( + len(train.context), + int(training["steps"]), + int(training["batch_size"]), + seed, + ) + fraction = float(config["mask"]["fraction"]) + batch_schedule_sha256 = sha256_json( + [[int(value) for value in batch] for batch in batches] + ) + mask_digest = hashlib.sha256() + for indices in batches: + mask_digest.update( + _mask_for_indices( + indices, + window_samples=int(config["data"]["window_samples"]), + fraction=fraction, + seed=seed, + ).tobytes() + ) + loss_config = config["loss"][condition] + last_losses: dict[str, float] = {} + model.train() + model.target_encoder.eval() + model.target_projector.eval() + + for step, indices in enumerate(batches): + context = torch.from_numpy(train.context[indices]) + target = torch.from_numpy(train.target[indices]) + mask_np = _mask_for_indices( + indices, + window_samples=context.shape[2], + fraction=fraction, + seed=seed, + ) + mask = torch.from_numpy(mask_np) + masked_context = context.masked_fill(mask.expand_as(context), 0.0) + mode = torch.from_numpy(mode_context(train.modes[indices], mode_policy, seed + step)) + + encoder_embedding, projector_embedding = model.encode(masked_context) + prediction = model.predict(projector_embedding, mode) + target_embedding = model.target(target) + reconstruction = model.reconstruct(projector_embedding) + + prediction_loss = F.mse_loss(prediction, target_embedding) + reconstruction_loss = _masked_reconstruction_loss(reconstruction, context, mask) + zero = projector_embedding.sum() * 0.0 + regularization_loss = zero + bounded_reconstruction = zero + + if condition == "T1": + total = reconstruction_loss + elif condition == "T2": + total = prediction_loss + elif condition == "T3": + regularization_loss = sigreg_loss(projector_embedding) + total = prediction_loss + float(loss_config["sigreg_weight"]) * regularization_loss + elif condition == "T4": + variance_loss, covariance_loss = vicreg_terms(projector_embedding) + regularization_loss = ( + float(loss_config["vicreg_variance_weight"]) * variance_loss + + float(loss_config["vicreg_covariance_weight"]) * covariance_loss + ) + total = prediction_loss + regularization_loss + elif condition == "T5": + weighted = float(loss_config["reconstruction_weight"]) * reconstruction_loss + cap = ( + float(loss_config["maximum_reconstruction_fraction"]) + * prediction_loss.detach().clamp_min(1e-6) + ) + bounded_reconstruction = torch.minimum(weighted, cap) + total = prediction_loss + bounded_reconstruction + else: + raise AssertionError(condition) + + if not torch.isfinite(total): + raise ContractError(f"{condition}: nonfinite training loss at step {step}") + optimizer.zero_grad(set_to_none=True) + total.backward() + torch.nn.utils.clip_grad_norm_(model.parameters(), float(training["gradient_clip_norm"])) + optimizer.step() + model.update_target(float(training["ema_tau"])) + last_losses = { + "total": float(total.detach()), + "prediction": float(prediction_loss.detach()), + "reconstruction": float(reconstruction_loss.detach()), + "regularization": float(regularization_loss.detach()), + "bounded_reconstruction": float(bounded_reconstruction.detach()), + } + + return model, { + "steps_completed": len(batches), + "parameter_count": parameter_count, + "trainable_parameter_count": trainable_parameter_count, + "batch_schedule_sha256": batch_schedule_sha256, + "mask_schedule_sha256": mask_digest.hexdigest(), + "last_losses": last_losses, + } + + +def _checkpoint_content_sha256(model: nn.Module) -> str: + digest = hashlib.sha256() + for name, tensor in sorted(model.state_dict().items()): + array = tensor.detach().cpu().contiguous().numpy() + digest.update(name.encode("utf-8")) + digest.update(str(array.dtype).encode("ascii")) + digest.update(json.dumps(list(array.shape), separators=(",", ":")).encode("ascii")) + digest.update(array.tobytes()) + return digest.hexdigest() + + +def save_checkpoint(model: nn.Module, path: Path, metadata: dict[str, Any]) -> dict[str, str]: + path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "state_dict": model.state_dict(), + "metadata": metadata, + } + torch.save(payload, path) + return { + "checkpoint_sha256": sha256_file(path), + "checkpoint_content_sha256": _checkpoint_content_sha256(model), + } + + +def _pairwise_mean(values: np.ndarray, limit: int) -> float: + selected = values[:limit] + if len(selected) < 2: + return 0.0 + differences = selected[:, None, :] - selected[None, :, :] + distances = np.sqrt(np.sum(differences * differences, axis=2)) + upper = distances[np.triu_indices(len(selected), k=1)] + return float(upper.mean()) if len(upper) else 0.0 + + +def _nearest_neighbor_identity(values: np.ndarray, identities: np.ndarray) -> float | None: + if len(values) < 2 or len(set(identities.tolist())) < 2: + return None + differences = values[:, None, :] - values[None, :, :] + distances = np.sum(differences * differences, axis=2) + np.fill_diagonal(distances, np.inf) + nearest = np.argmin(distances, axis=1) + return float(np.mean(identities[nearest] == identities)) + + +def representation_diagnostics( + values: np.ndarray, + identities: np.ndarray, + config: dict[str, Any], + *, + modes: np.ndarray | None = None, +) -> dict[str, Any]: + if values.ndim != 2 or len(values) < 2: + raise ContractError("diagnostics require a two-dimensional representation matrix") + if not np.all(np.isfinite(values)): + raise ContractError("diagnostics reject nonfinite representation values") + settings = config["diagnostics"] + centered = values.astype(np.float64) - values.mean(axis=0, keepdims=True) + singular_values = np.linalg.svd(centered, full_matrices=False, compute_uv=False) + energy = singular_values**2 + total_energy = float(energy.sum()) + probabilities = energy / total_energy if total_energy > 0 else np.zeros_like(energy) + positive = probabilities[probabilities > 0] + effective_rank = float(np.exp(-np.sum(positive * np.log(positive)))) if len(positive) else 0.0 + cumulative = np.cumsum(probabilities) + energy_rank = ( + int(np.searchsorted(cumulative, float(settings["energy_rank_threshold"])) + 1) + if total_energy > 0 + else 0 + ) + floor = float(settings["singular_value_floor"]) + retained = singular_values[singular_values > floor] + condition_number = ( + float(retained[0] / retained[-1]) + if len(retained) == len(singular_values) and len(retained) + else None + ) + variances = values.var(axis=0) + covariance = np.cov(values, rowvar=False, bias=True) + covariance = np.atleast_2d(covariance) + off_diagonal = covariance - np.diag(np.diag(covariance)) + max_deviation = float(np.max(np.abs(values - values[0]))) + result: dict[str, Any] = { + "matrix_shape": list(values.shape), + "dtype": str(values.dtype), + "per_dimension_variance": [float(value) for value in variances], + "covariance_off_diagonal_magnitude": float(np.mean(np.abs(off_diagonal))), + "singular_value_spectrum": [float(value) for value in singular_values], + "entropy_effective_rank": effective_rank, + "energy_rank": energy_rank, + "condition_number": condition_number, + "rank_deficient": len(retained) < len(singular_values), + "mean_pairwise_distance": _pairwise_mean( + values, + int(settings["pairwise_sample_limit"]), + ), + "nearest_neighbor_session_identity": _nearest_neighbor_identity(values, identities), + "constant_output": max_deviation <= float(settings["constant_output_tolerance"]), + "constant_output_max_deviation": max_deviation, + "feature_utilization_count": int(np.sum(variances > float(settings["variance_floor"]))), + "feature_utilization_fraction": float( + np.mean(variances > float(settings["variance_floor"])) + ), + } + if modes is not None and any(str(mode) for mode in modes): + result["nearest_neighbor_mode_identity"] = _nearest_neighbor_identity(values, modes) + return result + + +def raw_generator_diagnostics( + sessions: Sequence[SyntheticSession], + config: dict[str, Any], +) -> dict[str, Any]: + windows = np.concatenate([session.windows for session in sessions], axis=0) + channel_matrix = windows.transpose(0, 2, 1).reshape(-1, windows.shape[1]).astype(np.float64) + centered = channel_matrix - channel_matrix.mean(axis=0, keepdims=True) + singular_values = np.linalg.svd(centered, full_matrices=False, compute_uv=False) + energy = singular_values**2 + probabilities = energy / energy.sum() if energy.sum() > 0 else np.zeros_like(energy) + positive = probabilities[probabilities > 0] + effective_rank = float(np.exp(-np.sum(positive * np.log(positive)))) if len(positive) else 0.0 + raw_features = windows.reshape(len(windows), -1) + identities = np.concatenate( + [np.repeat(session.session_id, len(session.windows)) for session in sessions] + ) + missing = { + session.session_id: session.missing_channel_provenance + for session in sessions + if session.missing_channel_provenance is not None + } + session_ranks = {} + for session in sessions: + matrix = session.windows.transpose(0, 2, 1).reshape( + -1, + session.windows.shape[1], + ) + matrix = matrix - matrix.mean(axis=0, keepdims=True) + session_ranks[session.session_id] = int(np.linalg.matrix_rank(matrix, tol=1e-7)) + return { + "channel_singular_values": [float(value) for value in singular_values], + "channel_matrix_rank": int(np.linalg.matrix_rank(centered, tol=1e-7)), + "per_session_channel_matrix_rank": session_ranks, + "maximum_session_channel_matrix_rank": max(session_ranks.values()), + "entropy_effective_rank": effective_rank, + "first_singular_energy": float(probabilities[0]) if len(probabilities) else 0.0, + "nearest_neighbor_session_identity": _nearest_neighbor_identity(raw_features, identities), + "missing_channel_provenance": missing, + } + + +def _different_session_indices(session_ids: np.ndarray) -> np.ndarray: + output = np.empty(len(session_ids), dtype=np.int64) + for index, session_id in enumerate(session_ids): + candidates = np.flatnonzero(session_ids != session_id) + if not len(candidates): + output[index] = index + else: + output[index] = candidates[index % len(candidates)] + return output + + +def _probe_accuracy( + train_values: np.ndarray, + train_labels: np.ndarray, + test_values: np.ndarray, + test_labels: np.ndarray, + config: dict[str, Any], +) -> float | None: + if len(set(train_labels.tolist())) < 2 or len(test_labels) == 0: + return None + probe_config = config["probe"] + probe = make_pipeline( + StandardScaler(), + LogisticRegression( + C=float(probe_config["regularization_c"]), + max_iter=int(probe_config["max_iterations"]), + random_state=int(config["seed"]), + ), + ) + probe.fit(train_values, train_labels) + return float(np.mean(probe.predict(test_values) == test_labels)) + + +def evaluate_condition( + model: SyntheticJEPA, + train: PairSet, + test: PairSet, + config: dict[str, Any], + *, + seed: int, + mode_policy: str = "C0", + probe_mode_only: bool = False, +) -> dict[str, Any]: + model.eval() + fraction = float(config["mask"]["fraction"]) + + def forward(pairs: PairSet, *, mask_offset: int = 0, channel_permutation: Sequence[int] | None = None) -> dict[str, np.ndarray]: + indices = np.arange(len(pairs.context)) + context_np = pairs.context.copy() + if channel_permutation is not None: + context_np = context_np[:, list(channel_permutation), :] + mask_np = _mask_for_indices( + indices, + window_samples=context_np.shape[2], + fraction=fraction, + seed=seed, + offset=mask_offset, + ) + context = torch.from_numpy(context_np) + target = torch.from_numpy(pairs.target) + mask = torch.from_numpy(mask_np) + masked = context.masked_fill(mask.expand_as(context), 0.0) + mode = torch.from_numpy(mode_context(pairs.modes, mode_policy, seed + mask_offset)) + with torch.no_grad(): + encoder_embedding, projector_embedding = model.encode(masked) + predictor_output = model.predict(projector_embedding, mode) + target_embedding = model.target(target) + return { + "encoder": encoder_embedding.numpy(), + "projector": projector_embedding.numpy(), + "predictor": predictor_output.numpy(), + "target": target_embedding.numpy(), + } + + train_outputs = forward(train) + outputs = forward(test) + permutation_rng = np.random.default_rng(seed + 91_337) + permutation = permutation_rng.permutation(len(outputs["target"])) + if len(permutation) > 1 and np.all(permutation == np.arange(len(permutation))): + permutation = np.roll(permutation, 1) + permuted_target = outputs["target"][permutation] + different_target = outputs["target"][_different_session_indices(test.session_ids)] + channel_order = forward(test, channel_permutation=(2, 0, 3, 1)) + coordinate_shuffle = forward(test, channel_permutation=(3, 2, 1, 0)) + mask_shuffle = forward(test, mask_offset=1) + + def mse(left: np.ndarray, right: np.ndarray) -> float: + return float(np.mean((left - right) ** 2)) + + probe_train = train_outputs["projector"] + probe_test = outputs["projector"] + if probe_mode_only: + probe_train = np.concatenate([probe_train, _mode_one_hot(train.modes)], axis=1) + probe_test = np.concatenate([probe_test, _mode_one_hot(test.modes)], axis=1) + state_probe = _probe_accuracy( + probe_train, + train.labels, + probe_test, + test.labels, + config, + ) + mode_probe = None + if any(str(mode) for mode in train.modes) and len(set(train.modes.tolist())) >= 2: + train_mode_labels = np.asarray([MODES.index(str(mode)) for mode in train.modes]) + test_mode_labels = np.asarray([MODES.index(str(mode)) for mode in test.modes]) + mode_probe = _probe_accuracy( + probe_train, + train_mode_labels, + probe_test, + test_mode_labels, + config, + ) + + diagnostics = { + scope: representation_diagnostics( + values, + test.session_ids, + config, + modes=test.modes, + ) + for scope, values in ( + ("encoder_embedding", outputs["encoder"]), + ("projector_embedding", outputs["projector"]), + ("predictor_output", outputs["predictor"]), + ("stopped_gradient_target_embedding", outputs["target"]), + ) + } + per_mode = {} + for mode in MODES: + indices = np.flatnonzero(test.modes == mode) + if len(indices) >= 2: + per_mode[mode] = representation_diagnostics( + outputs["projector"][indices], + test.session_ids[indices], + config, + modes=test.modes[indices], + ) + + return { + "control_losses": { + "correct_target": mse(outputs["predictor"], outputs["target"]), + "temporal_target_permutation": mse(outputs["predictor"], permuted_target), + "different_sequence_target": mse(outputs["predictor"], different_target), + "channel_order_shuffle": mse(channel_order["predictor"], outputs["target"]), + "channel_coordinate_shuffle": mse( + coordinate_shuffle["predictor"], outputs["target"] + ), + "mask_location_shuffle": mse(mask_shuffle["predictor"], outputs["target"]), + }, + "state_probe_accuracy": state_probe, + "mode_probe_accuracy": mode_probe, + "diagnostics": diagnostics, + "per_mode_diagnostics": per_mode, + } + + +def _mean_metric(folds: Sequence[dict[str, Any]], *path: str) -> float: + values: list[float] = [] + for fold in folds: + current: Any = fold + for key in path: + current = current[key] + if current is not None: + values.append(float(current)) + return float(np.mean(values)) if values else float("nan") + + +def evaluate_expected_invariants( + generator_contract: dict[str, Any], + raw: dict[str, Any], + condition_reports: dict[str, list[dict[str, Any]]], +) -> list[dict[str, Any]]: + generator_id = generator_contract["id"] + expected = generator_contract["expected_invariants"] + checks: list[dict[str, Any]] = [] + + def add(name: str, passed: bool, observed: Any, expected_value: Any) -> None: + checks.append( + { + "id": name, + "passed": bool(passed), + "observed": observed, + "expected": expected_value, + } + ) + + if "raw_entropy_effective_rank_min" in expected: + value = raw["entropy_effective_rank"] + add( + "raw_entropy_effective_rank_min", + value >= expected["raw_entropy_effective_rank_min"], + value, + expected["raw_entropy_effective_rank_min"], + ) + if "raw_entropy_effective_rank_max" in expected: + value = raw["entropy_effective_rank"] + add( + "raw_entropy_effective_rank_max", + value <= expected["raw_entropy_effective_rank_max"], + value, + expected["raw_entropy_effective_rank_max"], + ) + if "first_singular_energy_min" in expected: + value = raw["first_singular_energy"] + add( + "first_singular_energy_min", + value >= expected["first_singular_energy_min"], + value, + expected["first_singular_energy_min"], + ) + if "raw_nearest_neighbor_session_identity_min" in expected: + value = raw["nearest_neighbor_session_identity"] + add( + "raw_nearest_neighbor_session_identity_min", + value is not None + and value >= expected["raw_nearest_neighbor_session_identity_min"], + value, + expected["raw_nearest_neighbor_session_identity_min"], + ) + if "raw_matrix_rank_max" in expected: + value = raw["maximum_session_channel_matrix_rank"] + add( + "raw_matrix_rank_max", + value <= expected["raw_matrix_rank_max"], + value, + expected["raw_matrix_rank_max"], + ) + if expected.get("missing_channel_provenance_required"): + value = len(raw["missing_channel_provenance"]) + add("missing_channel_provenance_required", value > 0, value, "nonempty") + + if "correct_targets_should_beat_permutation" in expected: + correct = _mean_metric( + condition_reports["T3"], "evaluation", "control_losses", "correct_target" + ) + permuted = _mean_metric( + condition_reports["T3"], + "evaluation", + "control_losses", + "temporal_target_permutation", + ) + should_beat = bool(expected["correct_targets_should_beat_permutation"]) + ratio = correct / permuted if permuted > 0 else float("inf") + if should_beat: + threshold = float(expected["correct_to_permuted_ratio_max"]) + passed = ratio <= threshold + expectation = {"maximum_ratio": threshold} + else: + threshold = float(expected["correct_to_permuted_ratio_min"]) + passed = ratio >= threshold + expectation = {"minimum_ratio": threshold} + add( + "correct_targets_should_beat_permutation", + passed, + {"correct_to_permuted_ratio": ratio}, + expectation, + ) + if expected.get("state_probe_above_chance_expected"): + accuracy = _mean_metric( + condition_reports["T3"], "evaluation", "state_probe_accuracy" + ) + threshold = float(expected["state_probe_accuracy_min"]) + add( + "state_probe_above_chance_expected", + accuracy >= threshold, + accuracy, + {"minimum_accuracy": threshold}, + ) + if expected.get("T2_collapse_expected"): + t2_rank = _mean_metric( + condition_reports["T2"], + "evaluation", + "diagnostics", + "projector_embedding", + "entropy_effective_rank", + ) + t3_rank = _mean_metric( + condition_reports["T3"], + "evaluation", + "diagnostics", + "projector_embedding", + "entropy_effective_rank", + ) + t4_rank = _mean_metric( + condition_reports["T4"], + "evaluation", + "diagnostics", + "projector_embedding", + "entropy_effective_rank", + ) + collapse_threshold = float(expected["T2_effective_rank_max"]) + gain = float(expected["anti_collapse_effective_rank_gain_min"]) + add( + "T2_collapse_expected", + t2_rank <= collapse_threshold, + t2_rank, + {"maximum_effective_rank": collapse_threshold}, + ) + add( + "T3_or_T4_should_reduce_collapse", + max(t3_rank, t4_rank) >= t2_rank + gain, + {"T2": t2_rank, "T3": t3_rank, "T4": t4_rank}, + {"minimum_effective_rank_gain": gain}, + ) + return checks + + +def diagnostic_self_tests(config: dict[str, Any]) -> dict[str, Any]: + latent_dim = int(config["model"]["latent_dim"]) + constant = np.ones((12, latent_dim), dtype=np.float32) + identities = np.asarray( + ["synthetic:self-test:a"] * 6 + ["synthetic:self-test:b"] * 6, + dtype="U32", + ) + constant_report = representation_diagnostics( + constant, + identities, + config, + ) + nonfinite = constant.copy() + nonfinite[0, 0] = np.nan + try: + representation_diagnostics(nonfinite, identities, config) + except ContractError: + nonfinite_rejected = True + else: + nonfinite_rejected = False + return { + "constant_embedding": { + "passed": bool( + constant_report["constant_output"] + and constant_report["entropy_effective_rank"] == 0.0 + and constant_report["feature_utilization_count"] == 0 + ), + "diagnostics": constant_report, + }, + "nonfinite_embedding_rejection": { + "passed": nonfinite_rejected, + }, + } + + +def _source_manifest( + base_sessions: dict[str, list[SyntheticSession]], + mode_sessions: Sequence[SyntheticSession], + config: dict[str, Any], + *, + config_sha256: str, + generator_sha256: str, +) -> dict[str, Any]: + all_sessions = [ + session + for sessions in base_sessions.values() + for session in sessions + ] + list(mode_sessions) + manifest = { + "schema_version": SOURCE_SCHEMA, + "experiment_id": EXPERIMENT_ID, + "source_type": "deterministic_synthetic_fixture", + "device_id": "synthetic-generator", + "participant_id": None, + "physical_capture_eligible": False, + "fallback_capture_used": False, + "config_sha256": config_sha256, + "generator_contract_sha256": generator_sha256, + "seed": int(config["seed"]), + "sessions": [ + { + "session_id": session.session_id, + "generator_id": session.generator_id, + "mode": session.mode, + "window_count": len(session.windows), + "window_shape": list(session.windows.shape[1:]), + "content_sha256": _session_hash(session), + "nuisance": session.nuisance, + "missing_channel_provenance": session.missing_channel_provenance, + "physical_capture_eligible": False, + } + for session in all_sessions + ], + } + manifest["manifest_sha256"] = sha256_json(manifest) + return manifest + + +def _condition_fold_report( + generator_id: str, + condition: str, + fold: Fold, + pairs: PairSet, + config: dict[str, Any], + output_dir: Path, + *, + seed: int, + mode_policy: str = "C0", + probe_mode_only: bool = False, +) -> dict[str, Any]: + train_raw = pairs.subset(set(fold.train_sessions)) + validation_raw = pairs.subset(set(fold.validation_sessions)) + test_raw = pairs.subset(set(fold.test_sessions)) + if set(train_raw.session_ids) & set(test_raw.session_ids): + raise ContractError("grouped split leaked a session") + mean, std = fit_normalization(train_raw) + train = normalize_pairs(train_raw, mean, std) + validation = normalize_pairs(validation_raw, mean, std) + test = normalize_pairs(test_raw, mean, std) + model, training_report = train_condition( + condition, + train, + config, + seed=seed, + mode_policy=mode_policy, + ) + evaluation = evaluate_condition( + model, + train, + test, + config, + seed=seed, + mode_policy=mode_policy, + probe_mode_only=probe_mode_only, + ) + checkpoint_path = ( + output_dir + / "checkpoints" + / generator_id + / condition + / f"{fold.fold_id}.pt" + ) + checkpoint = save_checkpoint( + model, + checkpoint_path, + { + "experiment_id": EXPERIMENT_ID, + "generator_id": generator_id, + "condition": condition, + "fold_id": fold.fold_id, + "seed": seed, + "physical_eeg_used": False, + "promotion_status": "not_eligible", + }, + ) + return { + "fold_id": fold.fold_id, + "train_sessions": list(fold.train_sessions), + "validation_sessions": list(fold.validation_sessions), + "test_sessions": list(fold.test_sessions), + "train_pair_count": len(train.context), + "validation_pair_count": len(validation.context), + "test_pair_count": len(test.context), + "normalization": { + "fit_scope": "training_sessions_only", + "mean": mean.reshape(-1).tolist(), + "std": std.reshape(-1).tolist(), + }, + "training_window_digest": sha256_json(train_raw.pair_ids.tolist()), + "training": training_report, + "evaluation": evaluation, + **checkpoint, + } + + +def run_base_rehearsal( + base_sessions: dict[str, list[SyntheticSession]], + generator_contract: dict[str, Any], + config: dict[str, Any], + output_dir: Path, +) -> dict[str, Any]: + registered = {entry["id"]: entry for entry in generator_contract["generators"]} + generators_report: dict[str, Any] = {} + parameter_counts: set[int] = set() + for generator_position, generator_id in enumerate(config["data"]["generator_ids"]): + sessions = base_sessions[generator_id] + pairs = make_pairs(sessions) + folds = grouped_folds(pairs.session_ids.tolist(), config) + condition_reports: dict[str, list[dict[str, Any]]] = {} + for condition in CONDITIONS: + reports = [] + for fold_position, fold in enumerate(folds): + seed = ( + int(config["seed"]) + + generator_position * 100_000 + + fold_position * 1_000 + ) + report = _condition_fold_report( + generator_id, + condition, + fold, + pairs, + config, + output_dir, + seed=seed, + ) + parameter_counts.add(report["training"]["parameter_count"]) + reports.append(report) + condition_reports[condition] = reports + matched_execution = [] + for fold_position, fold in enumerate(folds): + trained = [ + condition_reports[condition][fold_position] + for condition in ("T1", "T2", "T3", "T4", "T5") + ] + batch_hashes = { + report["training"]["batch_schedule_sha256"] for report in trained + } + mask_hashes = { + report["training"]["mask_schedule_sha256"] for report in trained + } + pair_hashes = { + report["training_window_digest"] for report in trained + } + passed = ( + len(batch_hashes) == 1 + and len(mask_hashes) == 1 + and len(pair_hashes) == 1 + ) + if not passed: + raise ContractError( + f"{generator_id}/{fold.fold_id}: T1-T5 execution schedules differ" + ) + matched_execution.append( + { + "fold_id": fold.fold_id, + "passed": True, + "batch_schedule_sha256": next(iter(batch_hashes)), + "mask_schedule_sha256": next(iter(mask_hashes)), + "training_window_digest": next(iter(pair_hashes)), + } + ) + raw = raw_generator_diagnostics(sessions, config) + checks = evaluate_expected_invariants( + registered[generator_id], + raw, + condition_reports, + ) + generators_report[generator_id] = { + "session_count": len(sessions), + "pair_count": len(pairs.context), + "raw_diagnostics": raw, + "conditions": condition_reports, + "matched_execution": matched_execution, + "expected_invariants": checks, + "all_expected_invariants_passed": all(check["passed"] for check in checks), + } + if len(parameter_counts) != 1: + raise ContractError("T0-T5 model parameter counts are not matched") + return { + "parameter_count": next(iter(parameter_counts)), + "generators": generators_report, + "all_expected_invariants_passed": all( + report["all_expected_invariants_passed"] + for report in generators_report.values() + ), + } + + +def run_mode_rehearsal( + sessions: Sequence[SyntheticSession], + config: dict[str, Any], + output_dir: Path, +) -> dict[str, Any]: + pairs = make_pairs(sessions) + grouped = mode_grouped_fold(sessions) + reports: dict[str, Any] = {} + condition = str(config["mode_extension"]["training_condition"]) + for control in ("C0", "C1", "C2", "C3", "C5"): + reports[control] = _condition_fold_report( + f"MODE-{control}", + condition, + grouped, + pairs, + config, + output_dir / "mode", + seed=int(config["seed"]) + 500_000, + mode_policy=control, + probe_mode_only=control == "C5", + ) + leave_out_reports = [] + for fold_position, fold in enumerate(leave_one_mode_out_folds(sessions)): + leave_out_reports.append( + _condition_fold_report( + "MODE-C4", + condition, + fold, + pairs, + config, + output_dir / "mode", + seed=int(config["seed"]) + 510_000 + fold_position, + mode_policy="C4", + ) + ) + reports["C4"] = leave_out_reports + return { + "schema_version": MODE_REPORT_SCHEMA, + "experiment_id": MODE_EXPERIMENT_ID, + "source_type": "deterministic_synthetic_fixture", + "mode_labels": "externally_assigned_generator_conditions", + "cognitive_mode_inference": "prohibited", + "physical_eeg_used": False, + "physical_eeg_claims": "prohibited", + "scientific_transfer_claim_allowed": False, + "fallback_capture_used": False, + "decision": "pipeline_evidence_only", + "promotion_status": "not_eligible", + "runtime_change": "none", + "mode_transition_diagnostics": { + "status": "not_applicable_single_mode_sessions", + "transition_count": 0, + }, + "controls": reports, + "comparisons": { + "C1_vs_C0_correct_target_loss": { + "C1": reports["C1"]["evaluation"]["control_losses"]["correct_target"], + "C0": reports["C0"]["evaluation"]["control_losses"]["correct_target"], + }, + "C1_vs_C2_correct_target_loss": { + "C1": reports["C1"]["evaluation"]["control_losses"]["correct_target"], + "C2": reports["C2"]["evaluation"]["control_losses"]["correct_target"], + }, + "C5_vs_C1_mode_probe_accuracy": { + "C5": reports["C5"]["evaluation"]["mode_probe_accuracy"], + "C1": reports["C1"]["evaluation"]["mode_probe_accuracy"], + }, + }, + } + + +def _write_json(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2, sort_keys=True, allow_nan=False) + "\n") + + +def run_rehearsal( + config_path: Path, + generator_path: Path, + output_dir: Path, + *, + include_mode: bool = True, +) -> dict[str, Path]: + config, generator_contract = load_contracts(config_path, generator_path) + _seed_everything(int(config["seed"])) + output_dir.mkdir(parents=True, exist_ok=True) + base_sessions = generate_base_sessions(config) + mode_sessions = generate_mode_sessions(config) if include_mode else [] + source_manifest = _source_manifest( + base_sessions, + mode_sessions, + config, + config_sha256=sha256_file(config_path), + generator_sha256=sha256_file(generator_path), + ) + source_path = output_dir / "synthetic-source-manifest.json" + _write_json(source_path, source_manifest) + + # Prove the schema cannot be consumed as a canonical physical source. + if source_manifest["schema_version"] == SOURCE_MANIFEST_SCHEMA: + raise ContractError("synthetic source schema collided with physical source schema") + try: + load_source_manifest(source_path) + except ContractError: + pass + else: + raise ContractError("physical source loader accepted a synthetic manifest") + + base = run_base_rehearsal(base_sessions, generator_contract, config, output_dir) + repo_root = Path(__file__).resolve().parents[3] + self_tests = diagnostic_self_tests(config) + if not all(test["passed"] for test in self_tests.values()): + raise ContractError("synthetic diagnostic self-test failed") + report: dict[str, Any] = { + "schema_version": REPORT_SCHEMA, + "experiment_id": EXPERIMENT_ID, + "source_type": "deterministic_synthetic_fixture", + "physical_eeg_used": False, + "scientific_transfer_claim_allowed": False, + "decision": "pipeline_evidence_only", + "promotion_status": "not_eligible", + "runtime_change": "none", + "fallback_capture_used": False, + "source_manifest_sha256": sha256_file(source_path), + "source_manifest_identity": source_manifest["manifest_sha256"], + "config_sha256": sha256_file(config_path), + "generator_contract_sha256": sha256_file(generator_path), + "runner_sha256": sha256_file(Path(__file__)), + "runtime_provenance": runtime_provenance(repo_root), + "package_versions": package_versions(), + "matched_fields": config["training"]["matched_fields"], + "base_rehearsal": base, + "diagnostic_self_tests": self_tests, + } + report["report_identity_sha256"] = sha256_json(report) + report_path = output_dir / "jepa-synthetic-report.json" + _write_json(report_path, report) + + paths = { + "source_manifest": source_path, + "base_report": report_path, + } + if include_mode: + mode_report = run_mode_rehearsal(mode_sessions, config, output_dir) + mode_report["source_manifest_sha256"] = sha256_file(source_path) + mode_report["config_sha256"] = sha256_file(config_path) + mode_report["generator_contract_sha256"] = sha256_file(generator_path) + mode_report["report_identity_sha256"] = sha256_json(mode_report) + mode_path = output_dir / "jepa-synthetic-mode-report.json" + _write_json(mode_path, mode_report) + paths["mode_report"] = mode_path + return paths + + +def _parser() -> argparse.ArgumentParser: + root = Path(__file__).resolve().parents[3] + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--config", + type=Path, + default=root / "NeuralComposeEEG" / "configs" / "jepa-synthetic-v0.json", + ) + parser.add_argument( + "--generators", + type=Path, + default=root / "docs" / "scoping" / "jepa-synthetic-generators-v0.json", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=root / "NeuralComposeEEG" / "artifacts" / "jepa-synthetic-v0", + ) + parser.add_argument("--skip-mode", action="store_true") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parser().parse_args(argv) + paths = run_rehearsal( + args.config, + args.generators, + args.output_dir, + include_mode=not args.skip_mode, + ) + print( + json.dumps( + {name: str(path) for name, path in paths.items()}, + indent=2, + sort_keys=True, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/NeuralComposeEEG/tests/test_function_space_scope_contract.py b/NeuralComposeEEG/tests/test_function_space_scope_contract.py new file mode 100644 index 0000000..c8a9a53 --- /dev/null +++ b/NeuralComposeEEG/tests/test_function_space_scope_contract.py @@ -0,0 +1,152 @@ +"""Governance tests for the synthetic-only function-space foundations package.""" + +from __future__ import annotations + +import json +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +CONTRACT_PATH = REPO_ROOT / "configs" / "function-space-foundations-v0.json" +MEMO_PATH = ( + REPO_ROOT + / "docs" + / "research" + / "function-space-foundations-decision-memo-v0.md" +) +COMPATIBILITY_NOTE = ( + REPO_ROOT / "docs" / "science" / "sobolev-zpd-hypotheses.md" +) +FUNCTION_SPACE_NOTE = ( + REPO_ROOT / "docs" / "science" / "function-space-operator-hypotheses.md" +) +ZPD_NOTE = REPO_ROOT / "docs" / "science" / "zpd-intervention-hypotheses.md" +ENCODER_CONFIG_PATH = ( + REPO_ROOT / "NeuralComposeEEG" / "configs" / "experiment-v0.json" +) +SIBLING_CONTRACT_PATH = ( + REPO_ROOT.parent + / "NeuralComposeScience" + / "configs" + / "function-space-foundations-v0.json" +) + + +class FunctionSpaceScopeContractTests(unittest.TestCase): + def setUp(self) -> None: + self.contract = json.loads(CONTRACT_PATH.read_text()) + + def test_fixed_d0_disposition_is_nonphysical_and_nonpromotable(self) -> None: + expected = { + "schema_version": "function-space-foundations-config-v0", + "report_schema_version": "function-space-foundations-report-v0", + "experiment_id": "EXP-FUNC-SYN-000", + "status": "foundational_study_only", + "data_gate": "D0", + "decision": "insufficient_evidence", + "promotion_status": "not_eligible", + "runtime_change": "none", + "physical_eeg_used": False, + "dialogue_logs_used": False, + "fallback_capture_used": False, + "scientific_claim_allowed": False, + } + for key, value in expected.items(): + self.assertEqual(self.contract[key], value) + + def test_input_policy_has_no_external_or_private_data_path(self) -> None: + policy = self.contract["input_policy"] + self.assertEqual( + policy["source_kind"], + "embedded_deterministic_synthetic_parameters", + ) + for key, value in policy.items(): + if key != "source_kind": + self.assertIs(value, False, key) + + source_files = self.contract["source_files"] + self.assertTrue(source_files) + for relative in source_files: + self.assertFalse(Path(relative).is_absolute()) + self.assertNotIn("..", Path(relative).parts) + + def test_fs0_through_fs5_are_complete_and_pinned(self) -> None: + studies = self.contract["studies"] + self.assertEqual(set(studies), {f"FS{index}" for index in range(6)}) + self.assertEqual( + studies["FS0"]["sample_points"], + [0.0, 0.25, 0.5, 0.75, 1.0], + ) + self.assertEqual( + studies["FS1"]["finite_difference_scheme"], + "forward", + ) + self.assertEqual(studies["FS2"]["basis"]["kind"], "fourier") + self.assertIn(0.0, studies["FS2"]["regularization_grid"]) + self.assertEqual( + set(studies["FS2"]["sessions"]), + {"training", "validation", "held_out"}, + ) + self.assertEqual(studies["FS3"]["sequence"], "x^n") + self.assertEqual(studies["FS4"]["induced_matrix_norm"], "spectral_l2") + self.assertEqual( + studies["FS5"]["measure_language"], + "lebesgue_stieltjes_mixed_measure", + ) + + def test_representation_regularization_remains_in_existing_jepa_scope( + self, + ) -> None: + boundary = self.contract["representation_regularization_boundary"] + self.assertFalse(boundary["implemented_here"]) + self.assertFalse(boundary["new_trainer_authorized"]) + self.assertEqual( + boundary["existing_contracts"], + [ + "EXP-NC-EEG-JEPA-001:J2-J5", + "jepa-collapse-diagnostics-v0", + ], + ) + + def test_physical_encoder_contract_is_unchanged_by_function_space_scope( + self, + ) -> None: + encoder = json.loads(ENCODER_CONFIG_PATH.read_text()) + self.assertEqual(encoder["experiment_id"], "EXP-NC-EEG-ENC-001") + self.assertEqual(encoder["status"], "pipeline_pilot_only") + self.assertEqual(encoder["promotion_status"], "not_eligible") + self.assertNotIn("function_space", encoder) + self.assertNotIn("sobolev", encoder) + self.assertNotIn("automatic_differentiation", encoder) + + def test_docs_preserve_theory_and_policy_boundaries(self) -> None: + memo = MEMO_PATH.read_text() + compatibility = COMPATIBILITY_NOTE.read_text() + function_space = FUNCTION_SPACE_NOTE.read_text() + zpd = ZPD_NOTE.read_text() + + self.assertIn("does not stabilize or recover", memo) + self.assertIn("theorem illustration", memo) + self.assertIn("$R_0(f;(a,b))$", memo) + self.assertIn("implementation consequence is inferred", memo) + self.assertIn("No proposition numbers are asserted", memo) + self.assertIn("background evidence only", memo) + self.assertIn("function-space-operator-hypotheses.md", compatibility) + self.assertIn("zpd-intervention-hypotheses.md", compatibility) + self.assertIn("No such physical-data study is authorized", function_space) + self.assertIn("It is not part of `EXP-FUNC-SYN-000`", zpd) + + def test_adjacent_science_contract_is_byte_identical_when_available( + self, + ) -> None: + if not SIBLING_CONTRACT_PATH.exists(): + self.skipTest("independent NeuralComposeScience workspace is absent") + self.assertEqual( + CONTRACT_PATH.read_bytes(), + SIBLING_CONTRACT_PATH.read_bytes(), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/NeuralComposeEEG/tests/test_function_space_track_provenance.py b/NeuralComposeEEG/tests/test_function_space_track_provenance.py new file mode 100644 index 0000000..463954d --- /dev/null +++ b/NeuralComposeEEG/tests/test_function_space_track_provenance.py @@ -0,0 +1,91 @@ +"""Contract tests for function-space provenance in SVD and JEPA scopes.""" + +from __future__ import annotations + +import json +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +SVD_REGISTER_PATH = ( + REPO_ROOT / "docs" / "scoping" / "svd-decision-register-v0.json" +) +JEPA_CONTRACT_PATH = ( + REPO_ROOT / "docs" / "scoping" / "jepa-collapse-diagnostics-v0.json" +) +SVD_MEMO_PATH = ( + REPO_ROOT / "docs" / "research" / "svd-four-channel-eeg-decision-memo_v1.md" +) +JEPA_MEMO_PATH = ( + REPO_ROOT / "docs" / "research" / "jepa-four-channel-eeg-decision-memo-v0.md" +) + +EXPECTED_REFERENCE = { + "experiment_id": "EXP-FUNC-SYN-000", + "status": "foundational_study_only", + "report_sha256": ( + "5625b616a846c58978ec4b965c2e717509fbc60990b129da36987813c0414b0e" + ), + "contract_sha256": ( + "6a08b3a3ff717f98bd40d37bcb35cc4745097e0a72013ec852fd081747c1d1c3" + ), + "science_workspace_commit": "8eafaea", + "synthetic_jepa_isolation_commit": "ba7822b", + "claim_scope": "mathematical and implementation reference only", + "physical_thresholds_derived": False, + "runtime_dependency_authorized": False, +} + + +class FunctionSpaceTrackProvenanceTests(unittest.TestCase): + def setUp(self) -> None: + self.svd = json.loads(SVD_REGISTER_PATH.read_text()) + self.jepa = json.loads(JEPA_CONTRACT_PATH.read_text()) + + def test_tracks_pin_the_same_foundational_reference(self) -> None: + self.assertEqual(self.svd["foundational_reference"], EXPECTED_REFERENCE) + self.assertEqual(self.jepa["foundational_reference"], EXPECTED_REFERENCE) + + def test_reference_does_not_change_track_dispositions(self) -> None: + self.assertEqual(self.svd["document_status"], "foundational_study_only") + self.assertEqual(self.svd["current_data_gate"], "D0") + self.assertEqual(self.svd["promotion_status"], "not_eligible") + self.assertTrue( + all( + not entry["runtime_dependency_authorized"] + for entry in self.svd["entries"] + ) + ) + + self.assertEqual(self.jepa["status"], "scope_complete") + self.assertEqual(self.jepa["track"], "Pass_1.5_non_runtime") + self.assertEqual(self.jepa["data_gate"], "D0") + self.assertEqual(self.jepa["promotion_status"], "not_eligible") + self.assertFalse(self.jepa["runtime_dependency_authorized"]) + self.assertFalse(self.jepa["live_inference_authorized"]) + + def test_memos_pin_the_allowed_track_specific_interpretations(self) -> None: + svd_memo = SVD_MEMO_PATH.read_text() + for phrase in ( + "operator norms and measured perturbation amplification", + "pseudoinverse instability", + "retained-rank sensitivity", + "controlled truncated-SVD versus ridge/Tikhonov comparisons", + "does not establish physical EEG stability", + ): + self.assertIn(phrase, svd_memo) + + jepa_memo = JEPA_MEMO_PATH.read_text() + for phrase in ( + "empirical-pseudometric vocabulary", + "singular spectra, effective", + "No Sobolev term is added to J0-J6", + "Egorov's theorem is never a threshold", + "leaving all JEPA losses, gates, thresholds, and runtime", + ): + self.assertIn(phrase, jepa_memo) + + +if __name__ == "__main__": + unittest.main() diff --git a/NeuralComposeEEG/tests/test_jepa_scope_contract.py b/NeuralComposeEEG/tests/test_jepa_scope_contract.py new file mode 100644 index 0000000..cd45d9c --- /dev/null +++ b/NeuralComposeEEG/tests/test_jepa_scope_contract.py @@ -0,0 +1,194 @@ +"""Contract tests for the D0 JEPA research scope.""" + +from __future__ import annotations + +import json +import re +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +DIAGNOSTICS_PATH = ( + REPO_ROOT / "docs" / "scoping" / "jepa-collapse-diagnostics-v0.json" +) +ENCODER_CONFIG_PATH = ( + REPO_ROOT / "NeuralComposeEEG" / "configs" / "experiment-v0.json" +) +EXPERIMENT_PATH = ( + REPO_ROOT + / "NeuralComposeEEG" + / "experiments" + / "EXP-NC-EEG-JEPA-001.md" +) +MEMO_PATH = ( + REPO_ROOT + / "docs" + / "research" + / "jepa-four-channel-eeg-decision-memo-v0.md" +) + + +class JEPAScopeContractTests(unittest.TestCase): + def setUp(self) -> None: + self.contract = json.loads(DIAGNOSTICS_PATH.read_text()) + + def test_scope_is_d0_non_runtime_and_non_promotable(self) -> None: + self.assertEqual( + self.contract["schema_version"], + "nc-eeg-jepa-collapse-diagnostics-v0", + ) + self.assertEqual(self.contract["experiment_id"], "EXP-NC-EEG-JEPA-001") + self.assertEqual(self.contract["status"], "scope_complete") + self.assertEqual(self.contract["track"], "Pass_1.5_non_runtime") + self.assertEqual(self.contract["data_gate"], "D0") + self.assertEqual( + self.contract["decision"], + "foundational_study_only", + ) + self.assertEqual(self.contract["promotion_status"], "not_eligible") + self.assertFalse(self.contract["live_control"]) + self.assertFalse(self.contract["runtime_dependency_authorized"]) + self.assertFalse(self.contract["live_inference_authorized"]) + self.assertFalse(self.contract["laya_download_or_training_authorized"]) + + def test_execution_gates_and_j6_block_are_pinned(self) -> None: + gates = self.contract["execution_gates"] + self.assertEqual( + set(gates), + {"D0", "D1", "D2", "D3", "post_encoder"}, + ) + self.assertIn("no_jepa_training", gates["D1"]) + self.assertIn("no_confirmation_or_promotion", gates["D2"]) + self.assertIn("fold_local_pretraining", gates["D3"]) + self.assertIn("action_conditioned", gates["post_encoder"]) + + j6 = self.contract["j6_gate"] + self.assertEqual(j6["status"], "blocked") + self.assertEqual( + set(j6["requires"]), + { + "reproducible_pinned_implementation", + "pinned_checkpoint", + "compatible_channel_mapping", + "matched_compute_controls", + "license_review", + }, + ) + + def test_all_required_collapse_diagnostics_are_registered(self) -> None: + required = { + "per_dimension_variance", + "covariance_off_diagonal_magnitude", + "singular_value_spectrum", + "entropy_effective_rank", + "energy_rank", + "condition_number", + "mean_pairwise_distance", + "nearest_neighbor_session_identity", + "constant_output_check", + "feature_utilization", + "predictor_loss", + "regularization_loss", + "linear_probe_performance", + } + registered = { + diagnostic["id"] + for diagnostic in self.contract["diagnostics"] + if diagnostic["required"] + } + self.assertEqual(registered, required) + self.assertEqual( + self.contract["measurement_frequency"]["trainable_conditions"], + "every_epoch_on_training_and_inner_validation_groups", + ) + self.assertEqual( + self.contract["measurement_frequency"]["outer_held_out_group"], + "once_after_complete_condition_freeze", + ) + + def test_negative_controls_and_train_only_threshold_policy_are_pinned( + self, + ) -> None: + expected_controls = { + "temporal_target_permutation", + "different_session_target", + "channel_coordinate_shuffle", + "channel_order_shuffle", + "mask_location_shuffle", + "no_regularization", + "constant_embedding", + "random_encoder", + "matched_reconstruction", + } + self.assertEqual( + set(self.contract["required_negative_controls"]), + expected_controls, + ) + self.assertFalse( + self.contract["threshold_policy"]["held_out_threshold_fitting_allowed"] + ) + self.assertFalse( + self.contract["threshold_policy"]["outer_group_selects_epoch_or_condition"] + ) + self.assertIn( + "predictor-output-only", + self.contract["regularization_application_rule"], + ) + self.assertFalse( + self.contract["interpretation_rules"]["geometry_alone_supports_model"] + ) + self.assertFalse( + self.contract["interpretation_rules"][ + "reconstruction_error_proves_noncollapse" + ] + ) + + def test_current_encoder_experiment_remains_the_m0_to_m4_pilot(self) -> None: + encoder = json.loads(ENCODER_CONFIG_PATH.read_text()) + self.assertEqual(encoder["experiment_id"], "EXP-NC-EEG-ENC-001") + self.assertEqual( + {"m0", "m1", "m2_m3", "m4"}, + {"m0", "m1", "m2_m3", "m4"} & set(encoder), + ) + self.assertFalse(any(key.startswith("j") for key in encoder)) + self.assertEqual(encoder["status"], "pipeline_pilot_only") + self.assertEqual(encoder["promotion_status"], "not_eligible") + + def test_scope_documents_keep_jepa_separate_from_the_runtime(self) -> None: + experiment = EXPERIMENT_PATH.read_text() + memo = MEMO_PATH.read_text() + self.assertIn("This experiment is separate from", experiment) + self.assertIn("Runtime dependency:** prohibited", experiment) + self.assertIn("implement_live_JEPA: false", memo) + self.assertIn("modify_EXP_NC_EEG_ENC_001: false", memo) + self.assertIn("download_or_train_Laya_now: false", memo) + self.assertIn("status: scope_complete", memo) + self.assertIn("track: Pass_1.5_non_runtime", memo) + self.assertIn('"retrieved_date": "2026-07-23"', memo) + self.assertEqual(memo.count('"claim_scope": "background evidence only"'), 2) + + def test_experiment_pins_the_complete_condition_and_outcome_sets(self) -> None: + experiment = EXPERIMENT_PATH.read_text() + condition_ids = set( + re.findall(r"^\| (J[0-6]) \|", experiment, flags=re.MULTILINE) + ) + self.assertEqual(condition_ids, {f"J{index}" for index in range(7)}) + + for outcome in ( + "balanced accuracy", + "macro F1", + "Brier score", + "expected calibration error", + "artifact sensitivity and specificity", + "cross-session degradation", + "data-efficiency curves", + "channel dropout", + "EMG-like noise", + "movement artifacts", + ): + self.assertIn(outcome, experiment) + + +if __name__ == "__main__": + unittest.main() diff --git a/NeuralComposeEEG/tests/test_jepa_synthetic.py b/NeuralComposeEEG/tests/test_jepa_synthetic.py new file mode 100644 index 0000000..bbce537 --- /dev/null +++ b/NeuralComposeEEG/tests/test_jepa_synthetic.py @@ -0,0 +1,401 @@ +"""Tests for the quarantined synthetic JEPA execution rehearsal.""" + +from __future__ import annotations + +import copy +import json +import tempfile +import unittest +from pathlib import Path + +import numpy as np +import torch + +from neuralcompose_eeg.contracts import ContractError, load_source_manifest +from neuralcompose_eeg.jepa_synthetic import ( + CONDITIONS, + EXPERIMENT_ID, + MODE_CONTROLS, + MODE_EXPERIMENT_ID, + MODES, + SOURCE_SCHEMA, + _mask_for_indices, + build_model, + fit_normalization, + generate_base_sessions, + generate_mode_sessions, + grouped_folds, + load_contracts, + make_pairs, + mode_grouped_fold, + normalize_pairs, + raw_generator_diagnostics, + run_rehearsal, + sigreg_loss, + train_condition, + vicreg_terms, +) +from neuralcompose_eeg.provenance import sha256_file + + +REPO_ROOT = Path(__file__).resolve().parents[2] +CONFIG_PATH = REPO_ROOT / "NeuralComposeEEG" / "configs" / "jepa-synthetic-v0.json" +GENERATORS_PATH = REPO_ROOT / "docs" / "scoping" / "jepa-synthetic-generators-v0.json" + + +class JEPASyntheticContractTests(unittest.TestCase): + def test_contract_is_separate_nonphysical_and_nonpromotable(self) -> None: + config, generators = load_contracts(CONFIG_PATH, GENERATORS_PATH) + self.assertEqual(config["experiment_id"], EXPERIMENT_ID) + self.assertEqual(config["mode_experiment_id"], MODE_EXPERIMENT_ID) + self.assertEqual(config["source"]["schema_version"], SOURCE_SCHEMA) + self.assertFalse(config["source"]["physical_capture_eligible"]) + self.assertFalse(config["source"]["fallback_capture_allowed"]) + self.assertFalse(generators["fallback_acquisition_stream"]["accepted"]) + self.assertEqual(tuple(config["training"]["conditions"]), CONDITIONS) + self.assertEqual(tuple(config["mode_extension"]["controls"]), MODE_CONTROLS) + self.assertEqual(tuple(config["mode_extension"]["modes"]), MODES) + self.assertEqual(config["artifact"]["decision"], "pipeline_evidence_only") + self.assertEqual(config["artifact"]["promotion_status"], "not_eligible") + self.assertEqual(config["artifact"]["runtime_change"], "none") + self.assertFalse(config["artifact"]["physical_eeg_used"]) + self.assertFalse(config["artifact"]["scientific_transfer_claim_allowed"]) + expected = { + entry["id"]: entry["expected_invariants"] + for entry in generators["generators"] + } + self.assertEqual(expected["S0"]["correct_to_permuted_ratio_max"], 0.98) + self.assertEqual(expected["S1"]["correct_to_permuted_ratio_min"], 0.95) + self.assertEqual(expected["S5"]["state_probe_accuracy_min"], 0.55) + self.assertEqual(expected["S6"]["T2_effective_rank_max"], 2.0) + self.assertEqual( + expected["S6"]["anti_collapse_effective_rank_gain_min"], + 0.25, + ) + + def test_fallback_admission_cannot_be_enabled(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + config = json.loads(CONFIG_PATH.read_text()) + config["source"]["fallback_capture_allowed"] = True + config_path = root / "config.json" + config_path.write_text(json.dumps(config)) + with self.assertRaisesRegex(ContractError, "fallback acquisition"): + load_contracts(config_path, GENERATORS_PATH) + + def test_synthetic_schema_is_rejected_by_physical_source_loader(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "synthetic-source.json" + path.write_text( + json.dumps( + { + "schema_version": SOURCE_SCHEMA, + "experiment_id": EXPERIMENT_ID, + "source_type": "deterministic_synthetic_fixture", + "sessions": [], + } + ) + ) + with self.assertRaisesRegex(ContractError, "nc-eeg-source-manifest-v0"): + load_source_manifest(path) + + +class JEPASyntheticGeneratorTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.config, _ = load_contracts(CONFIG_PATH, GENERATORS_PATH) + cls.sessions = generate_base_sessions(cls.config) + + def test_generators_are_byte_deterministic(self) -> None: + second = generate_base_sessions(self.config) + for generator_id in self.sessions: + for first_session, second_session in zip( + self.sessions[generator_id], + second[generator_id], + strict=True, + ): + np.testing.assert_array_equal(first_session.windows, second_session.windows) + np.testing.assert_array_equal( + first_session.state_labels, + second_session.state_labels, + ) + + def test_known_raw_structures_are_present(self) -> None: + diagnostics = { + generator_id: raw_generator_diagnostics(sessions, self.config) + for generator_id, sessions in self.sessions.items() + } + self.assertGreaterEqual(diagnostics["S0"]["entropy_effective_rank"], 1.5) + self.assertLessEqual(diagnostics["S0"]["entropy_effective_rank"], 2.6) + self.assertGreaterEqual(diagnostics["S1"]["entropy_effective_rank"], 3.2) + self.assertGreaterEqual(diagnostics["S2"]["first_singular_energy"], 0.55) + self.assertGreaterEqual( + diagnostics["S3"]["nearest_neighbor_session_identity"], + 0.75, + ) + self.assertLessEqual( + diagnostics["S4"]["maximum_session_channel_matrix_rank"], + 3, + ) + self.assertEqual( + len(diagnostics["S4"]["missing_channel_provenance"]), + self.config["data"]["sessions_per_generator"], + ) + + def test_nonfinite_fixture_is_rejected(self) -> None: + session = self.sessions["S0"][0] + broken = session.windows.copy() + broken[0, 0, 0] = np.nan + with self.assertRaisesRegex(ContractError, "nonfinite"): + type(session)( + session_id=session.session_id, + generator_id=session.generator_id, + windows=broken, + state_labels=session.state_labels, + mode=session.mode, + nuisance=session.nuisance, + ).validate( + channels=self.config["data"]["channel_count"], + window_samples=self.config["data"]["window_samples"], + ) + + def test_mode_regimes_cross_the_same_nuisance_grid(self) -> None: + sessions = generate_mode_sessions(self.config) + self.assertEqual({session.mode for session in sessions}, set(MODES)) + self.assertEqual(len(sessions), 4 * len(MODES)) + for mode in MODES: + mode_sessions = [session for session in sessions if session.mode == mode] + self.assertEqual( + {session.nuisance["mixing_family"] for session in mode_sessions}, + {0, 1, 2, 3}, + ) + self.assertEqual( + {session.nuisance["latent_rank"] for session in mode_sessions}, + {2, 3}, + ) + + +class JEPASyntheticLeakageTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.config, _ = load_contracts(CONFIG_PATH, GENERATORS_PATH) + cls.sessions = generate_base_sessions(cls.config)["S0"] + cls.pairs = make_pairs(cls.sessions) + + def test_grouped_folds_have_no_shared_sessions(self) -> None: + folds = grouped_folds(self.pairs.session_ids.tolist(), self.config) + self.assertEqual(len(folds), self.config["split"]["outer_fold_count"]) + for fold in folds: + train = set(fold.train_sessions) + validation = set(fold.validation_sessions) + test = set(fold.test_sessions) + self.assertFalse(train & validation) + self.assertFalse(train & test) + self.assertFalse(validation & test) + for session_id in set(self.pairs.session_ids.tolist()): + pair_indices = np.flatnonzero(self.pairs.session_ids == session_id) + self.assertGreater(len(pair_indices), 1) + self.assertIn( + session_id, + train | validation | test, + ) + + def test_normalization_uses_training_values_only(self) -> None: + fold = grouped_folds(self.pairs.session_ids.tolist(), self.config)[0] + train = self.pairs.subset(set(fold.train_sessions)) + test = self.pairs.subset(set(fold.test_sessions)) + mean, std = fit_normalization(train) + changed = copy.deepcopy(test) + changed.context[:] = 1e6 + changed.target[:] = -1e6 + unchanged_mean, unchanged_std = fit_normalization(train) + np.testing.assert_array_equal(mean, unchanged_mean) + np.testing.assert_array_equal(std, unchanged_std) + normalized = normalize_pairs(changed, mean, std) + self.assertGreater(float(np.abs(normalized.context).mean()), 1000.0) + + def test_mask_is_deterministic_and_does_not_read_target(self) -> None: + indices = np.arange(12) + first = _mask_for_indices( + indices, + window_samples=64, + fraction=0.25, + seed=42, + ) + second = _mask_for_indices( + indices, + window_samples=64, + fraction=0.25, + seed=42, + ) + shifted = _mask_for_indices( + indices, + window_samples=64, + fraction=0.25, + seed=42, + offset=1, + ) + np.testing.assert_array_equal(first, second) + self.assertFalse(np.array_equal(first, shifted)) + self.assertTrue(np.all(first.sum(axis=2) == 16)) + + def test_mode_split_uses_complete_sessions(self) -> None: + sessions = generate_mode_sessions(self.config) + fold = mode_grouped_fold(sessions) + self.assertFalse(set(fold.train_sessions) & set(fold.test_sessions)) + self.assertEqual(len(fold.test_sessions), len(MODES)) + test_modes = { + session.mode + for session in sessions + if session.session_id in set(fold.test_sessions) + } + self.assertEqual(test_modes, set(MODES)) + + +class JEPASyntheticObjectiveTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.config, _ = load_contracts(CONFIG_PATH, GENERATORS_PATH) + sessions = generate_base_sessions(cls.config)["S5"] + pairs = make_pairs(sessions) + fold = grouped_folds(pairs.session_ids.tolist(), cls.config)[0] + train_raw = pairs.subset(set(fold.train_sessions)) + mean, std = fit_normalization(train_raw) + cls.train = normalize_pairs(train_raw, mean, std) + + def test_all_conditions_have_identical_parameter_count(self) -> None: + counts = [] + for condition in CONDITIONS: + model, report = train_condition( + condition, + self.train, + self.config, + seed=42, + ) + self.assertIsNotNone(model) + counts.append(report["parameter_count"]) + self.assertEqual(len(set(counts)), 1) + + def test_target_encoder_is_stop_gradient(self) -> None: + model = build_model(self.config) + batch = torch.from_numpy(self.train.context[:8]) + target = model.target(batch) + self.assertFalse(target.requires_grad) + self.assertTrue( + all(not parameter.requires_grad for parameter in model.target_encoder.parameters()) + ) + self.assertTrue( + all(not parameter.requires_grad for parameter in model.target_projector.parameters()) + ) + + def test_sigreg_and_vicreg_attach_to_projector_not_predictor(self) -> None: + model = build_model(self.config) + batch = torch.from_numpy(self.train.context[:16]) + _, projector = model.encode(batch) + sigreg_loss(projector).backward(retain_graph=True) + self.assertTrue( + any( + parameter.grad is not None and torch.any(parameter.grad != 0) + for parameter in model.projector.parameters() + ) + ) + self.assertTrue(all(parameter.grad is None for parameter in model.predictor.parameters())) + + model.zero_grad(set_to_none=True) + _, projector = model.encode(batch) + variance, covariance = vicreg_terms(projector) + (variance + covariance).backward() + self.assertTrue( + any( + parameter.grad is not None and torch.any(parameter.grad != 0) + for parameter in model.projector.parameters() + ) + ) + self.assertTrue(all(parameter.grad is None for parameter in model.predictor.parameters())) + + def test_bounded_reconstruction_never_exceeds_registered_fraction(self) -> None: + _, report = train_condition("T5", self.train, self.config, seed=42) + losses = report["last_losses"] + self.assertLessEqual( + losses["bounded_reconstruction"], + 0.25 * max(losses["prediction"], 1e-6) + 1e-7, + ) + + +class JEPASyntheticArtifactTests(unittest.TestCase): + def _micro_contracts(self, root: Path) -> tuple[Path, Path]: + config = json.loads(CONFIG_PATH.read_text()) + config["data"]["generator_ids"] = ["S0"] + config["data"]["windows_per_session"] = 8 + config["data"]["sessions_per_generator"] = 4 + config["split"]["outer_test_sessions"] = 1 + config["split"]["inner_validation_sessions"] = 1 + config["split"]["outer_fold_count"] = 1 + config["training"]["steps"] = 2 + config["training"]["batch_size"] = 8 + config["mode_extension"]["enabled"] = False + generators = json.loads(GENERATORS_PATH.read_text()) + generators["generators"] = [ + generator for generator in generators["generators"] if generator["id"] == "S0" + ] + config_path = root / "config.json" + generators_path = root / "generators.json" + config_path.write_text(json.dumps(config, sort_keys=True)) + generators_path.write_text(json.dumps(generators, sort_keys=True)) + return config_path, generators_path + + def test_end_to_end_artifacts_are_deterministic_and_nonphysical(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + config_path, generators_path = self._micro_contracts(root) + first = run_rehearsal( + config_path, + generators_path, + root / "first", + include_mode=False, + ) + second = run_rehearsal( + config_path, + generators_path, + root / "second", + include_mode=False, + ) + first_report = json.loads(first["base_report"].read_text()) + second_report = json.loads(second["base_report"].read_text()) + self.assertEqual( + first_report["report_identity_sha256"], + second_report["report_identity_sha256"], + ) + self.assertEqual(first_report["experiment_id"], EXPERIMENT_ID) + self.assertFalse(first_report["physical_eeg_used"]) + self.assertFalse(first_report["scientific_transfer_claim_allowed"]) + self.assertFalse(first_report["fallback_capture_used"]) + self.assertEqual(first_report["decision"], "pipeline_evidence_only") + self.assertEqual(first_report["promotion_status"], "not_eligible") + self.assertEqual(first_report["runtime_change"], "none") + self.assertTrue( + all( + result["passed"] + for result in first_report["diagnostic_self_tests"].values() + ) + ) + generator = first_report["base_rehearsal"]["generators"]["S0"] + self.assertTrue( + all(result["passed"] for result in generator["matched_execution"]) + ) + source = json.loads(first["source_manifest"].read_text()) + self.assertEqual(source["schema_version"], SOURCE_SCHEMA) + self.assertTrue( + all( + session["session_id"].startswith("synthetic:") + and not session["physical_capture_eligible"] + for session in source["sessions"] + ) + ) + self.assertEqual( + sha256_file(first["source_manifest"]), + first_report["source_manifest_sha256"], + ) + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/local_open_weight_review.py b/Scripts/local_open_weight_review.py new file mode 100644 index 0000000..34823c0 --- /dev/null +++ b/Scripts/local_open_weight_review.py @@ -0,0 +1,1260 @@ +#!/usr/bin/env python3 +"""Fail-closed local open-weight review cascade. + +This module is deliberately narrow. It may be used by a *local* operator to +review a quarantined engineering corpus, but it cannot decide science, alter an +experiment, create embeddings, train a model, or control the application. On +import it reads no source data and contacts no backend. The command-line entry +point requires an explicit source path and two operator attestations. + +Only metadata-only receipts are serializable. Raw records are held in memory +for one stateless model request and are never passed to aggregation. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import re +import subprocess +import time +import urllib.error +import urllib.request +from collections import Counter, defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, Mapping, Protocol +from urllib.parse import urlparse + + +CHUNK_SCHEMA = "nc-local-review-chunk-v0" +FINDING_SCHEMA = "nc-local-review-finding-v0" +SUMMARY_SCHEMA = "nc-local-review-summary-v0" +RUN_MANIFEST_SCHEMA = "nc-local-review-run-manifest-v0" +CRITIC_SCHEMA = "nc-local-review-critic-v0" +CONFIG_SCHEMA = "nc-local-review-cascade-config-v0" +CASCADE_VERSION = "nc-local-open-weight-review-cascade-v0" +DEFAULT_CONFIGURATION_PATH = Path(__file__).resolve().parents[1] / "configs" / "local-open-weight-review-v0.json" + +ALLOWED_FINDING_TYPES = frozenset( + { + "malformed_record", + "duplicate_index", + "non_monotonic_index", + "candidate_role_inconsistency", + "candidate_duplication", + "selection_inertia", + "repetitive_synthesis", + "state_discontinuity", + "generator_identity_change", + "possible_context_loss", + "candidate_selection_mismatch", + "unclassified", + } +) +ALLOWED_SEVERITIES = frozenset({"low", "medium", "high"}) +REQUIRED_DISPOSITION = { + "corpus_role": "engineering_replay_only", + "development_only_permanent": True, + "eligible_for_encoder_training": False, + "eligible_for_encoder_evaluation": False, + "eligible_for_policy_training": False, + "eligible_for_policy_evaluation": False, + "eligible_for_science": False, + "cloud_exposure_allowed": False, +} +_FORBIDDEN_FIELD_TERMS = ( + "eeg", + "cognitive", + "emotion", + "intention", + "agreement", + "diagnos", + "brain", + "training", + "scientific", + "policy_target", +) +_FORBIDDEN_TEXT_PATTERNS = tuple( + re.compile(pattern, flags=re.IGNORECASE) + for pattern in ( + r"\beeg\s+(?:label|feature|target)", + r"\b(?:cognitive|emotional)\s+(?:label|state)", + r"\b(?:intention|agreement|diagnosis)\b", + r"\bbrain[ -]dialogue\b", + r"\bpolicy[ -]training\b", + r"\bscientific\s+(?:conclusion|claim)\b", + r"\bmodel[ -]training\s+recommendation\b", + ) +) +_LEAK_TOKEN_PATTERN = re.compile(r"[\w]+", flags=re.UNICODE) +_FINDING_ID_PATTERN = re.compile(r"[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}\Z") +_CITATION_ID_PATTERN = re.compile(r"line:([1-9][0-9]*)\Z") +_METADATA_FORBIDDEN_KEYS = frozenset( + { + "raw_record", + "raw_records", + "record", + "records", + "raw_response", + "response", + "response_text", + "response_bytes", + "prompt", + "prompt_text", + "prompt_content", + "completion", + } +) + +FROZEN_SYSTEM_PROMPT = """You are a local engineering reviewer of a permanently quarantined corpus. + +Your only task is to report bounded replay or dialogue-runtime defects from the +provided chunk. Use source-line citations exactly as supplied. Return one JSON +array with the required finding schema. Do not reproduce source text. + +Do not infer thoughts, intentions, emotions, agreement, diagnoses, cognitive +state, brain state, or scientific conclusions. Do not propose training, EEG +labels, EEG features, policy targets, or application changes. If the evidence +is ambiguous, use finding_type \"unclassified\". Every finding needs at least +one citation and must cite at least one non-overlap record in this chunk.""" + + +class ReviewContractError(ValueError): + """A deterministic contract failure that is safe to pass between reviewers.""" + + def __init__(self, code: str, message: str | None = None) -> None: + self.code = code + super().__init__(message or code) + + +def canonical_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def sha256_json(value: Any) -> str: + return sha256_bytes(canonical_json(value).encode("utf-8")) + + +def _reject_nonfinite_constant(value: str) -> None: + raise ReviewContractError("nonfinite_number", "JSON must not contain non-finite numeric constants") + + +def strict_json_loads(value: str) -> Any: + try: + return json.loads(value, parse_constant=_reject_nonfinite_constant) + except ReviewContractError: + raise + except json.JSONDecodeError as exc: + raise ReviewContractError("invalid_json", "response must be one complete JSON value") from exc + + +def _require_nonempty_string(value: Any, *, code: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ReviewContractError(code, "required text is absent") + return value + + +def require_quarantine_disposition(value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + raise ReviewContractError("missing_disposition", "source disposition is required") + for key, expected in REQUIRED_DISPOSITION.items(): + if value.get(key) != expected: + raise ReviewContractError("invalid_disposition", f"source disposition violates {key}") + return dict(value) + + +@dataclass(frozen=True) +class ReviewRecord: + source_line: int + legacy_turn_index: int + record: dict[str, Any] + + +@dataclass(frozen=True) +class SourceEnvelope: + source_sha256: str + quarantine_report_sha256: str + disposition: dict[str, Any] + records: tuple[ReviewRecord, ...] + source_path: Path | None = None + + def verify_unchanged(self) -> None: + """Fail the run if an explicit source file changed after envelope creation.""" + if self.source_path is not None and sha256_bytes(self.source_path.read_bytes()) != self.source_sha256: + raise ReviewContractError("source_changed", "source checksum changed during review") + + +@dataclass(frozen=True) +class ChunkRecord: + citation_id: str + source_line: int + legacy_turn_index: int + is_overlap_record: bool + record: dict[str, Any] + + +@dataclass(frozen=True) +class ChunkEnvelope: + chunk_id: str + source_sha256: str + records: tuple[ChunkRecord, ...] + + @property + def allowed_citation_ids(self) -> tuple[str, ...]: + return tuple(record.citation_id for record in self.records) + + @property + def new_record_citation_ids(self) -> tuple[str, ...]: + return tuple(record.citation_id for record in self.records if not record.is_overlap_record) + + def prompt_payload(self, validation_error_codes: Iterable[str] = ()) -> dict[str, Any]: + """Construct one in-memory request. This value must never be persisted.""" + payload: dict[str, Any] = { + "schema_version": CHUNK_SCHEMA, + "chunk_id": self.chunk_id, + "source_sha256": self.source_sha256, + "records": [ + { + "citation_id": record.citation_id, + "source_line": record.source_line, + "legacy_turn_index": record.legacy_turn_index, + "is_overlap_record": record.is_overlap_record, + "record": record.record, + } + for record in self.records + ], + "allowed_citation_ids": list(self.allowed_citation_ids), + "new_record_citation_ids": list(self.new_record_citation_ids), + } + errors = sorted(set(validation_error_codes)) + if errors: + payload["previous_validation_error_codes"] = errors + return payload + + def metadata_receipt(self) -> dict[str, Any]: + return { + "schema_version": CHUNK_SCHEMA, + "chunk_id": self.chunk_id, + "source_sha256": self.source_sha256, + "allowed_citation_ids": list(self.allowed_citation_ids), + "new_record_citation_ids": list(self.new_record_citation_ids), + } + + +@dataclass(frozen=True) +class ReviewConfiguration: + raw: dict[str, Any] + valid_records_per_chunk: int + overlap_records: int + proposer_model: str + reviewer_models: tuple[str, ...] + maximum_attempts: int + temperature: float + seed: int + context_limit: int | None + max_output_tokens: int | None + critic_enabled: bool + critic_model: str + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> "ReviewConfiguration": + allowed = { + "schema_version", + "configuration_version", + "validator", + "proposer", + "reviewer", + "critic", + "human_gate", + "chunking", + "generation", + "retry", + } + if set(value) != allowed or value.get("schema_version") != CONFIG_SCHEMA: + raise ReviewContractError("invalid_configuration", "review configuration schema is invalid") + validator = value.get("validator") + proposer = value.get("proposer") + reviewer = value.get("reviewer") + critic = value.get("critic") + human_gate = value.get("human_gate") + chunking = value.get("chunking") + generation = value.get("generation") + retry = value.get("retry") + for section in (validator, proposer, reviewer, critic, human_gate, chunking, generation, retry): + if not isinstance(section, dict): + raise ReviewContractError("invalid_configuration", "review configuration sections must be objects") + if validator != {"id": "R0", "type": "deterministic"}: + raise ReviewContractError("invalid_configuration", "R0 must be the deterministic validator") + proposer_model = _require_nonempty_string(proposer.get("model"), code="invalid_configuration") + if proposer.get("id") != "R1" or proposer.get("purpose") != "bounded_chunk_findings" or set(proposer) != {"id", "model", "purpose"}: + raise ReviewContractError("invalid_configuration", "R1 configuration is invalid") + if reviewer.get("id") != "R2" or reviewer.get("purpose") != "citation_repair_and_aggregation" or set(reviewer) != {"id", "preferred_model", "fallback_model", "purpose"}: + raise ReviewContractError("invalid_configuration", "R2 configuration is invalid") + reviewer_values = (reviewer.get("preferred_model"), reviewer.get("fallback_model")) + reviewer_models = tuple(value for value in reviewer_values if isinstance(value, str) and value) + if not reviewer_models: + raise ReviewContractError("invalid_configuration", "R2 needs at least one configured model") + if set(critic) != {"id", "model", "purpose", "enabled_by_default"} or critic.get("id") != "R3" or critic.get("purpose") != "explicit_independent_adjudication" or critic.get("enabled_by_default") is not False or not isinstance(critic.get("model"), str) or not critic["model"]: + raise ReviewContractError("invalid_configuration", "R3 must be disabled by default") + if human_gate.get("id") != "R4" or not isinstance(human_gate.get("required_for"), list): + raise ReviewContractError("invalid_configuration", "R4 configuration is invalid") + required_human = {"policy_changes", "experiment_changes", "scientific_claims", "promotion_decisions"} + if set(human_gate["required_for"]) != required_human: + raise ReviewContractError("invalid_configuration", "R4 gate coverage is incomplete") + if set(chunking) != {"valid_records_per_chunk", "overlap_records", "conversation_memory_between_chunks"}: + raise ReviewContractError("invalid_configuration", "chunking configuration is invalid") + chunk_size = chunking["valid_records_per_chunk"] + overlap = chunking["overlap_records"] + if isinstance(chunk_size, bool) or not isinstance(chunk_size, int) or chunk_size <= 0: + raise ReviewContractError("invalid_configuration", "chunk size must be a positive integer") + if isinstance(overlap, bool) or not isinstance(overlap, int) or not 0 <= overlap < chunk_size: + raise ReviewContractError("invalid_configuration", "chunk overlap is invalid") + if chunking["conversation_memory_between_chunks"] is not False: + raise ReviewContractError("invalid_configuration", "cross-chunk conversation memory is prohibited") + if set(generation) != {"temperature", "seed", "context_limit", "max_output_tokens"}: + raise ReviewContractError("invalid_configuration", "generation configuration is invalid") + temperature = generation["temperature"] + seed = generation["seed"] + if isinstance(temperature, bool) or not isinstance(temperature, (int, float)) or not math.isfinite(float(temperature)) or float(temperature) != 0.0: + raise ReviewContractError("invalid_configuration", "temperature must be pinned to 0.0") + if isinstance(seed, bool) or not isinstance(seed, int): + raise ReviewContractError("invalid_configuration", "seed must be an integer") + for key in ("context_limit", "max_output_tokens"): + item = generation[key] + if item is not None and (isinstance(item, bool) or not isinstance(item, int) or item <= 0): + raise ReviewContractError("invalid_configuration", f"{key} must be null or a positive integer") + if set(retry) != {"maximum_attempts"} or retry["maximum_attempts"] not in {1, 2, 3}: + raise ReviewContractError("invalid_configuration", "retry count must be between one and three") + return cls( + raw=json.loads(canonical_json(dict(value))), + valid_records_per_chunk=chunk_size, + overlap_records=overlap, + proposer_model=proposer_model, + reviewer_models=reviewer_models, + maximum_attempts=retry["maximum_attempts"], + temperature=float(temperature), + seed=seed, + context_limit=generation["context_limit"], + max_output_tokens=generation["max_output_tokens"], + critic_enabled=False, + critic_model=critic["model"], + ) + + @property + def sha256(self) -> str: + return sha256_json(self.raw) + + +def default_configuration() -> dict[str, Any]: + """Load the tracked default configuration without embedding model identities in code.""" + try: + value = strict_json_loads(DEFAULT_CONFIGURATION_PATH.read_text(encoding="utf-8")) + except OSError as exc: + raise ReviewContractError("missing_configuration", "default cascade configuration is unavailable") from exc + if not isinstance(value, dict): + raise ReviewContractError("invalid_configuration", "default configuration must be a JSON object") + return value + + +def build_chunk_envelopes(source: SourceEnvelope, configuration: ReviewConfiguration) -> list[ChunkEnvelope]: + source.verify_unchanged() + records = list(source.records) + chunks: list[ChunkEnvelope] = [] + start = 0 + ordinal = 1 + while start < len(records): + selected = records[start : start + configuration.valid_records_per_chunk] + overlap_count = configuration.overlap_records if ordinal > 1 else 0 + chunk_records = tuple( + ChunkRecord( + citation_id=f"line:{record.source_line}", + source_line=record.source_line, + legacy_turn_index=record.legacy_turn_index, + is_overlap_record=position < overlap_count, + record=record.record, + ) + for position, record in enumerate(selected) + ) + chunks.append( + ChunkEnvelope( + chunk_id=f"{source.source_sha256[:16]}:chunk:{ordinal:04d}", + source_sha256=source.source_sha256, + records=chunk_records, + ) + ) + if start + configuration.valid_records_per_chunk >= len(records): + break + start += configuration.valid_records_per_chunk - configuration.overlap_records + ordinal += 1 + return chunks + + +def _iter_strings(value: Any) -> Iterable[str]: + if isinstance(value, str): + yield value + elif isinstance(value, dict): + for item in value.values(): + yield from _iter_strings(item) + elif isinstance(value, list): + for item in value: + yield from _iter_strings(item) + + +def _private_ngrams(chunk: ChunkEnvelope, width: int = 5) -> set[str]: + ngrams: set[str] = set() + for record in chunk.records: + for text in _iter_strings(record.record): + tokens = [token.casefold() for token in _LEAK_TOKEN_PATTERN.findall(text)] + for start in range(0, max(0, len(tokens) - width + 1)): + candidate = " ".join(tokens[start : start + width]) + if len(candidate) >= 24: + ngrams.add(candidate) + return ngrams + + +def contains_substantial_private_text(value: str, chunk: ChunkEnvelope) -> bool: + """Bounded n-gram detector; it is a guardrail, never a proof of privacy.""" + output_tokens = [token.casefold() for token in _LEAK_TOKEN_PATTERN.findall(value)] + source_ngrams = _private_ngrams(chunk) + for start in range(0, max(0, len(output_tokens) - 5 + 1)): + if " ".join(output_tokens[start : start + 5]) in source_ngrams: + return True + return False + + +def _contains_forbidden_text(value: str) -> bool: + return any(pattern.search(value) is not None for pattern in _FORBIDDEN_TEXT_PATTERNS) + + +def _reject_unknown_or_forbidden_fields(value: dict[str, Any], expected: set[str]) -> None: + extra = set(value) - expected + if not extra: + return + if any(any(term in field.casefold() for term in _FORBIDDEN_FIELD_TERMS) for field in extra): + raise ReviewContractError("forbidden_field", "response contains a prohibited field") + raise ReviewContractError("unknown_field", "response contains an unsupported field") + + +def validate_findings_response(response_text: str, chunk: ChunkEnvelope) -> list[dict[str, Any]]: + """R0 validates a complete model response without repairing it.""" + value = strict_json_loads(response_text) + if not isinstance(value, list): + raise ReviewContractError("response_not_array", "response must be a JSON array") + allowed = set(chunk.allowed_citation_ids) + new = set(chunk.new_record_citation_ids) + source_lines = {record.citation_id: record.source_line for record in chunk.records} + seen_ids: set[str] = set() + findings: list[dict[str, Any]] = [] + expected_finding = { + "schema_version", + "finding_id", + "finding_type", + "severity", + "confidence", + "evidence", + "observation", + "engineering_implication", + "contains_verbatim_private_text", + } + for finding in value: + if not isinstance(finding, dict): + raise ReviewContractError("invalid_finding", "finding must be an object") + _reject_unknown_or_forbidden_fields(finding, expected_finding) + if set(finding) != expected_finding: + raise ReviewContractError("missing_finding_field", "finding is missing required fields") + if finding["schema_version"] != FINDING_SCHEMA: + raise ReviewContractError("wrong_finding_schema", "finding schema version is invalid") + finding_id = _require_nonempty_string(finding["finding_id"], code="invalid_finding_id") + if not _FINDING_ID_PATTERN.fullmatch(finding_id) or finding_id in seen_ids: + raise ReviewContractError("invalid_finding_id", "finding id is malformed or repeated") + seen_ids.add(finding_id) + if finding["finding_type"] not in ALLOWED_FINDING_TYPES: + raise ReviewContractError("unknown_finding_type", "finding type is not allowed") + if finding["severity"] not in ALLOWED_SEVERITIES: + raise ReviewContractError("invalid_severity", "severity is not allowed") + confidence = finding["confidence"] + if isinstance(confidence, bool) or not isinstance(confidence, (int, float)) or not math.isfinite(float(confidence)): + raise ReviewContractError("nonfinite_number", "confidence must be finite") + if not 0.0 <= float(confidence) <= 1.0: + raise ReviewContractError("confidence_out_of_range", "confidence must be within [0, 1]") + evidence = finding["evidence"] + if not isinstance(evidence, list) or not evidence: + raise ReviewContractError("empty_evidence", "finding evidence must be nonempty") + citations: list[str] = [] + for item in evidence: + if not isinstance(item, dict): + raise ReviewContractError("invalid_evidence", "evidence must be an object") + _reject_unknown_or_forbidden_fields(item, {"citation_id", "claim"}) + if set(item) != {"citation_id", "claim"}: + raise ReviewContractError("invalid_evidence", "evidence fields are incomplete") + citation_id = _require_nonempty_string(item["citation_id"], code="invalid_citation") + match = _CITATION_ID_PATTERN.fullmatch(citation_id) + if match is None: + raise ReviewContractError("invalid_citation", "citation ids must use line:") + if citation_id not in allowed: + raise ReviewContractError("citation_not_allowed", "citation is not in this chunk") + if source_lines[citation_id] != int(match.group(1)): + raise ReviewContractError("citation_line_mismatch", "citation does not match its source line") + claim = _require_nonempty_string(item["claim"], code="invalid_evidence") + if _contains_forbidden_text(claim) or contains_substantial_private_text(claim, chunk): + raise ReviewContractError("private_or_forbidden_evidence", "evidence claim violates the content boundary") + citations.append(citation_id) + if not any(citation in new for citation in citations): + raise ReviewContractError("overlap_only_evidence", "at least one citation must be a new chunk record") + observation = _require_nonempty_string(finding["observation"], code="invalid_observation") + implication = _require_nonempty_string(finding["engineering_implication"], code="invalid_implication") + if finding["contains_verbatim_private_text"] is not False: + raise ReviewContractError("verbatim_flag", "finding must explicitly deny verbatim private text") + for text in (observation, implication): + if _contains_forbidden_text(text): + raise ReviewContractError("forbidden_semantic_content", "finding has prohibited semantic content") + if contains_substantial_private_text(text, chunk): + raise ReviewContractError("private_text_leak", "finding contains substantial source text") + findings.append( + { + "schema_version": FINDING_SCHEMA, + "finding_id": finding_id, + "finding_type": finding["finding_type"], + "severity": finding["severity"], + "confidence": float(confidence), + "evidence": [{"citation_id": item["citation_id"], "claim": item["claim"]} for item in evidence], + "observation": observation, + "engineering_implication": implication, + "contains_verbatim_private_text": False, + } + ) + return sorted(findings, key=lambda item: item["finding_id"]) + + +@dataclass(frozen=True) +class BackendResponse: + raw_text: str | None + model_identity: str + model_digest: str | None + runtime_name: str + runtime_version: str | None + elapsed_ms: float + prompt_tokens: int | None + completion_tokens: int | None + stop_reason: str | None + error_status: str | None = None + + +class LocalReviewBackend(Protocol): + backend_type: str + endpoint_class: str + explicitly_local: bool + + def invoke(self, *, model: str, system_prompt: str, user_payload: dict[str, Any], configuration: ReviewConfiguration) -> BackendResponse: + """Invoke one stateless local completion without persisting source payload.""" + + +def classify_local_endpoint(endpoint: str) -> str: + parsed = urlparse(endpoint) + if parsed.scheme == "unix" and parsed.netloc == "" and parsed.path.startswith("/"): + return "unix_socket" + if parsed.scheme not in {"http", "https"} or parsed.username is not None or parsed.password is not None: + raise ReviewContractError("remote_endpoint", "backend endpoint must be a local loopback or Unix socket") + if parsed.hostname not in {"127.0.0.1", "localhost", "::1"} or parsed.path not in {"", "/"} or parsed.query or parsed.fragment: + raise ReviewContractError("remote_endpoint", "backend endpoint must be a local loopback or Unix socket") + return "loopback" + + +class OllamaLocalBackend: + """Small Ollama-compatible local HTTP adapter. It intentionally has no cloud mode.""" + + backend_type = "ollama" + explicitly_local = True + + def __init__(self, endpoint: str) -> None: + self.endpoint_class = classify_local_endpoint(endpoint) + if self.endpoint_class != "loopback": + raise ReviewContractError("unsupported_endpoint", "Ollama HTTP requires a loopback HTTP endpoint") + self.endpoint = endpoint.rstrip("/") + + def _request(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: + request = urllib.request.Request( + f"{self.endpoint}{path}", + data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + method="POST", + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + try: + with urllib.request.urlopen(request, timeout=120) as response: + return strict_json_loads(response.read().decode("utf-8")) + except (urllib.error.URLError, TimeoutError, UnicodeDecodeError) as exc: + raise ReviewContractError("backend_error", "local backend request failed") from exc + + def invoke(self, *, model: str, system_prompt: str, user_payload: dict[str, Any], configuration: ReviewConfiguration) -> BackendResponse: + _require_local_model_name(model) + started = time.monotonic() + response = self._request( + "/api/chat", + { + "model": model, + "stream": False, + "format": "json", + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": canonical_json(user_payload)}, + ], + "options": { + "temperature": configuration.temperature, + "seed": configuration.seed, + **({"num_ctx": configuration.context_limit} if configuration.context_limit is not None else {}), + **({"num_predict": configuration.max_output_tokens} if configuration.max_output_tokens is not None else {}), + }, + }, + ) + if not isinstance(response, dict): + raise ReviewContractError("backend_error", "local backend returned a non-object") + message = response.get("message") + content = message.get("content") if isinstance(message, dict) else None + resolved_model = response.get("model") if isinstance(response.get("model"), str) else model + _require_local_model_name(resolved_model) + return BackendResponse( + raw_text=content if isinstance(content, str) else None, + model_identity=resolved_model, + model_digest=response.get("digest") if isinstance(response.get("digest"), str) else None, + runtime_name="ollama", + runtime_version=None, + elapsed_ms=(time.monotonic() - started) * 1000, + prompt_tokens=response.get("prompt_eval_count") if isinstance(response.get("prompt_eval_count"), int) else None, + completion_tokens=response.get("eval_count") if isinstance(response.get("eval_count"), int) else None, + stop_reason=response.get("done_reason") if isinstance(response.get("done_reason"), str) else None, + error_status=None if isinstance(content, str) else "missing_response_content", + ) + + +class MockLocalBackend: + """Deterministic test backend. It never opens a socket or writes prompts.""" + + backend_type = "mock" + endpoint_class = "mock" + explicitly_local = True + + def __init__(self, responses_by_model: Mapping[str, Iterable[BackendResponse | str]]) -> None: + self._responses = {model: list(responses) for model, responses in responses_by_model.items()} + self.invocations: list[dict[str, Any]] = [] + + def invoke(self, *, model: str, system_prompt: str, user_payload: dict[str, Any], configuration: ReviewConfiguration) -> BackendResponse: + self.invocations.append( + { + "model": model, + "system_prompt_sha256": sha256_bytes(system_prompt.encode("utf-8")), + "payload_schema": user_payload.get("schema_version"), + "previous_validation_error_codes": user_payload.get("previous_validation_error_codes", []), + } + ) + values = self._responses.get(model) + if not values: + return BackendResponse(None, model, None, "mock", "v0", 0.0, None, None, None, "no_mock_response") + response = values.pop(0) + if isinstance(response, BackendResponse): + return response + return BackendResponse(response, model, f"mock:{model}", "mock", "v0", 0.0, None, None, "stop", None) + + +@dataclass(frozen=True) +class OperatorAttestation: + network_isolation_operator_attested: bool + prompt_response_logging_confined: bool + + def require_complete(self) -> None: + if not self.network_isolation_operator_attested: + raise ReviewContractError("missing_network_attestation", "operator must attest network state; loopback alone is insufficient") + if not self.prompt_response_logging_confined: + raise ReviewContractError("unconfined_logging", "operator must attest prompt and response logging confinement") + + +@dataclass(frozen=True) +class AttemptReceipt: + attempt: int + stage: str + requested_model: str + resolved_model: str | None + model_digest: str | None + runtime_name: str + runtime_version: str | None + endpoint_class: str + elapsed_ms: float + prompt_tokens: int | None + completion_tokens: int | None + stop_reason: str | None + status: str + validation_error_codes: tuple[str, ...] + + def as_dict(self) -> dict[str, Any]: + return { + "attempt": self.attempt, + "stage": self.stage, + "requested_model": self.requested_model, + "resolved_model": self.resolved_model, + "model_digest": self.model_digest, + "runtime_name": self.runtime_name, + "runtime_version": self.runtime_version, + "endpoint_class": self.endpoint_class, + "elapsed_ms": self.elapsed_ms, + "prompt_tokens": self.prompt_tokens, + "completion_tokens": self.completion_tokens, + "stop_reason": self.stop_reason, + "status": self.status, + "validation_error_codes": list(self.validation_error_codes), + } + + +@dataclass(frozen=True) +class ChunkReviewResult: + chunk: dict[str, Any] + findings: tuple[dict[str, Any], ...] + review_status: str + attempts: tuple[AttemptReceipt, ...] + rejection_error_codes: tuple[str, ...] + + def persisted(self) -> dict[str, Any]: + return { + **self.chunk, + "findings": list(self.findings), + "review_status": self.review_status, + "attempts": [attempt.as_dict() for attempt in self.attempts], + "rejection_error_codes": list(self.rejection_error_codes), + } + + +@dataclass(frozen=True) +class CascadeRun: + source: SourceEnvelope + configuration: ReviewConfiguration + backend: LocalReviewBackend + chunk_results: tuple[ChunkReviewResult, ...] + attestation: OperatorAttestation + + +def _attempt_schedule(configuration: ReviewConfiguration) -> list[tuple[str, str]]: + schedule = [("R1", configuration.proposer_model)] + schedule.extend(("R2", model) for model in configuration.reviewer_models) + return schedule[: configuration.maximum_attempts] + + +def _require_local_model_name(model: str) -> None: + """Reject explicit cloud-routing identities without making a vendor assumption.""" + normalized = model.casefold() + if ":cloud" in normalized or "://" in normalized or normalized.startswith("http:") or normalized.startswith("https:"): + raise ReviewContractError("remote_model", "configured model identity is not explicitly local") + + +def run_review_cascade( + source: SourceEnvelope, + configuration: ReviewConfiguration, + backend: LocalReviewBackend, + attestation: OperatorAttestation, +) -> CascadeRun: + """Run R0/R1/R2. R3 is intentionally unavailable to automatic retries.""" + if not backend.explicitly_local or backend.endpoint_class not in {"loopback", "unix_socket", "mock"}: + raise ReviewContractError("backend_not_local", "review backend must be explicitly classified as local") + attestation.require_complete() + source.verify_unchanged() + results: list[ChunkReviewResult] = [] + for chunk in build_chunk_envelopes(source, configuration): + attempts: list[AttemptReceipt] = [] + prior_errors: list[str] = [] + accepted: list[dict[str, Any]] | None = None + for ordinal, (stage, model) in enumerate(_attempt_schedule(configuration), start=1): + _require_local_model_name(model) + source.verify_unchanged() + payload = chunk.prompt_payload(prior_errors if ordinal > 1 else ()) + response: BackendResponse | None = None + try: + response = backend.invoke(model=model, system_prompt=FROZEN_SYSTEM_PROMPT, user_payload=payload, configuration=configuration) + source.verify_unchanged() + if response.error_status is not None or response.raw_text is None: + raise ReviewContractError("backend_error", "backend did not return a complete response") + accepted = validate_findings_response(response.raw_text, chunk) + attempts.append( + AttemptReceipt( + ordinal, stage, model, response.model_identity, response.model_digest, response.runtime_name, + response.runtime_version, backend.endpoint_class, response.elapsed_ms, response.prompt_tokens, + response.completion_tokens, response.stop_reason, "accepted", (), + ) + ) + break + except ReviewContractError as error: + prior_errors.append(error.code) + attempts.append( + AttemptReceipt( + ordinal, + stage, + model, + response.model_identity if response is not None else None, + response.model_digest if response is not None else None, + response.runtime_name if response is not None else getattr(backend, "backend_type", "unknown"), + response.runtime_version if response is not None else None, + backend.endpoint_class, + response.elapsed_ms if response is not None else 0.0, + response.prompt_tokens if response is not None else None, + response.completion_tokens if response is not None else None, + response.stop_reason if response is not None else None, + "rejected", + (error.code,), + ) + ) + metadata = chunk.metadata_receipt() + if accepted is None: + results.append(ChunkReviewResult(metadata, (), "rejected", tuple(attempts), tuple(sorted(set(prior_errors))))) + else: + results.append(ChunkReviewResult(metadata, tuple(accepted), "accepted", tuple(attempts), ())) + source.verify_unchanged() + return CascadeRun(source, configuration, backend, tuple(results), attestation) + + +def run_explicit_critic_adjudication( + *, + chunk: ChunkEnvelope, + accepted_finding: dict[str, Any], + configuration: ReviewConfiguration, + backend: LocalReviewBackend, + attestation: OperatorAttestation, + requested_by_human: bool, +) -> dict[str, Any]: + """Run R3 only on explicit human request; its output cannot override R0. + + The critic receives the original frozen chunk and an already accepted + structured finding, never a previous model's raw response. It returns a + separate receipt for human review rather than mutating a cascade result. + """ + if not requested_by_human: + raise ReviewContractError("critic_requires_human_request", "R3 adjudication requires explicit human request") + if not backend.explicitly_local or backend.endpoint_class not in {"loopback", "unix_socket", "mock"}: + raise ReviewContractError("backend_not_local", "critic backend must be explicitly classified as local") + attestation.require_complete() + _require_local_model_name(configuration.critic_model) + validated_input = validate_findings_response(canonical_json([accepted_finding]), chunk) + if len(validated_input) != 1: + raise ReviewContractError("invalid_critic_input", "R3 accepts exactly one R0-valid finding") + payload = chunk.prompt_payload() + payload["operation"] = "independent_adjudication" + payload["accepted_finding"] = validated_input[0] + response = backend.invoke(model=configuration.critic_model, system_prompt=FROZEN_SYSTEM_PROMPT, user_payload=payload, configuration=configuration) + if response.error_status is not None or response.raw_text is None: + raise ReviewContractError("backend_error", "R3 did not return a complete response") + critic_findings = validate_findings_response(response.raw_text, chunk) + return { + "schema_version": CRITIC_SCHEMA, + "status": "advisory_only", + "critic_id": "R3", + "requested_by_human": True, + "input_finding_id": validated_input[0]["finding_id"], + "critic_findings": critic_findings, + "backend": { + "requested_model": configuration.critic_model, + "resolved_model": response.model_identity, + "model_digest": response.model_digest, + "runtime": response.runtime_name, + "runtime_version": response.runtime_version, + "endpoint_class": backend.endpoint_class, + }, + "r0_override_permitted": False, + "science_influence_allowed": False, + "promotion_status": "not_eligible", + "live_control": False, + } + + +def _assert_metadata_only(value: Any) -> None: + if isinstance(value, dict): + forbidden = set(value) & _METADATA_FORBIDDEN_KEYS + if forbidden: + raise ReviewContractError("raw_payload_in_aggregate", "aggregate received prohibited raw payload fields") + for item in value.values(): + _assert_metadata_only(item) + elif isinstance(value, list): + for item in value: + _assert_metadata_only(item) + + +def aggregate_review_results(results: Iterable[ChunkReviewResult], *, source_sha256: str, disposition: dict[str, Any]) -> dict[str, Any]: + """Aggregate accepted structured findings only. Raw chunk records cannot enter.""" + require_quarantine_disposition(disposition) + persisted = [result.persisted() for result in results] + _assert_metadata_only(persisted) + by_type: Counter[str] = Counter() + source_lines: set[int] = set() + confidence_bins = {"0.00_to_0.24": 0, "0.25_to_0.49": 0, "0.50_to_0.74": 0, "0.75_to_1.00": 0} + classifications: dict[int, set[str]] = defaultdict(set) + model_runs: list[dict[str, Any]] = [] + accepted = 0 + rejected = 0 + for result in persisted: + if result["review_status"] == "accepted": + accepted += 1 + else: + rejected += 1 + for attempt in result["attempts"]: + model_runs.append({key: attempt[key] for key in attempt if key != "validation_error_codes"}) + for finding in result["findings"]: + by_type[finding["finding_type"]] += 1 + confidence = finding["confidence"] + key = "0.00_to_0.24" if confidence < 0.25 else "0.25_to_0.49" if confidence < 0.5 else "0.50_to_0.74" if confidence < 0.75 else "0.75_to_1.00" + confidence_bins[key] += 1 + for evidence in finding["evidence"]: + line = int(_CITATION_ID_PATTERN.fullmatch(evidence["citation_id"]).group(1)) + source_lines.add(line) + classifications[line].add(finding["finding_type"]) + conflicts = [ + {"source_line": line, "finding_types": sorted(types)} + for line, types in sorted(classifications.items()) + if len(types) > 1 + ] + return { + "schema_version": SUMMARY_SCHEMA, + "source_sha256": source_sha256, + "model_runs": sorted(model_runs, key=canonical_json), + "chunk_count": len(persisted), + "accepted_finding_count": sum(len(result["findings"]) for result in persisted), + "rejected_response_count": rejected, + "accepted_chunk_count": accepted, + "finding_counts_by_type": dict(sorted(by_type.items())), + "affected_source_lines": sorted(source_lines), + "confidence_distribution": confidence_bins, + "conflicting_findings": conflicts, + "privacy_checks": { + "raw_records_received_by_aggregator": False, + "bounded_private_text_detector": "synthetic_ngram_overlap_v0", + "private_text_detector_is_not_a_proof_of_privacy": True, + "embeddings_created": False, + "weights_updated": False, + }, + "science_influence_allowed": False, + "disposition": disposition, + } + + +def _git_commit(repository_root: Path) -> str: + try: + result = subprocess.run( + ["git", "-C", str(repository_root), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + except (OSError, subprocess.CalledProcessError): + return "not_reported" + + +def build_run_manifest(run: CascadeRun, *, repository_root: Path) -> dict[str, Any]: + attempts = [attempt for result in run.chunk_results for attempt in result.attempts] + first = attempts[0] if attempts else None + return { + "schema_version": RUN_MANIFEST_SCHEMA, + "cascade_version": CASCADE_VERSION, + "source_sha256": run.source.source_sha256, + "quarantine_report_sha256": run.source.quarantine_report_sha256, + "orchestrator_commit": _git_commit(repository_root), + "orchestrator_sha256": sha256_bytes(Path(__file__).read_bytes()), + "configuration_sha256": run.configuration.sha256, + "system_prompt_sha256": sha256_bytes(FROZEN_SYSTEM_PROMPT.encode("utf-8")), + "backend": { + "runtime": first.runtime_name if first is not None else getattr(run.backend, "backend_type", "not_reported"), + "endpoint_class": run.backend.endpoint_class, + "requested_model": first.requested_model if first is not None else "not_reported", + "resolved_model": first.resolved_model if first is not None else "not_reported", + "model_digest": first.model_digest if first is not None else "not_reported", + "network_isolation_operator_attested": run.attestation.network_isolation_operator_attested, + }, + "generation": { + "temperature": run.configuration.temperature, + "seed": run.configuration.seed, + "context_limit": run.configuration.context_limit, + "max_output_tokens": run.configuration.max_output_tokens, + }, + "privacy": { + "embeddings_created": False, + "weights_updated": False, + "persistent_memory_enabled": False, + "cloud_exposure_allowed": False, + "prompt_response_logging_confined_operator_attested": run.attestation.prompt_response_logging_confined, + }, + "disposition": run.source.disposition, + "science_status": "pipeline_only", + "decision": "insufficient_evidence", + "promotion_status": "not_eligible", + "live_control": False, + } + + +def _approved_artifact_directory(path: Path, repository_root: Path) -> str: + resolved = path.resolve() + root = repository_root.resolve() + try: + relative = resolved.relative_to(root) + except ValueError: + return "outside_repository" + result = subprocess.run( + ["git", "-C", str(root), "check-ignore", "-q", "--", str(relative)], + capture_output=True, + check=False, + ) + if result.returncode != 0: + raise ReviewContractError("unapproved_artifact_directory", "repository-local artifacts must be Git-ignored") + return "git_ignored" + + +def write_metadata_only_artifacts(run: CascadeRun, *, artifact_directory: Path, repository_root: Path) -> dict[str, Path]: + """Persist receipts only after the destination is outside Git or ignored by it.""" + _approved_artifact_directory(artifact_directory, repository_root) + artifact_directory.mkdir(parents=True, exist_ok=True) + chunks = [result.persisted() for result in run.chunk_results] + summary = aggregate_review_results(chunks_to_results(chunks), source_sha256=run.source.source_sha256, disposition=run.source.disposition) + manifest = build_run_manifest(run, repository_root=repository_root) + for artifact in (chunks, summary, manifest): + _assert_metadata_only(artifact) + paths = { + "chunks": artifact_directory / "local-review-chunks-v0.jsonl", + "summary": artifact_directory / "local-review-summary-v0.json", + "manifest": artifact_directory / "local-review-run-manifest-v0.json", + } + paths["chunks"].write_text("".join(canonical_json(chunk) + "\n" for chunk in chunks), encoding="utf-8") + paths["summary"].write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8") + paths["manifest"].write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return paths + + +def chunks_to_results(chunks: Iterable[dict[str, Any]]) -> list[ChunkReviewResult]: + """Validate persisted receipts before allowing them into aggregation.""" + results: list[ChunkReviewResult] = [] + required = { + "schema_version", + "chunk_id", + "source_sha256", + "allowed_citation_ids", + "new_record_citation_ids", + "findings", + "review_status", + "attempts", + "rejection_error_codes", + } + for chunk in chunks: + if not isinstance(chunk, dict): + raise ReviewContractError("invalid_receipt", "persisted chunk receipt must be an object") + _assert_metadata_only(chunk) + if set(chunk) != required or chunk.get("schema_version") != CHUNK_SCHEMA: + raise ReviewContractError("invalid_receipt", "persisted chunk receipt has an invalid schema") + if chunk.get("review_status") not in {"accepted", "rejected"}: + raise ReviewContractError("invalid_receipt", "persisted chunk review status is invalid") + if not isinstance(chunk.get("findings"), list) or not isinstance(chunk.get("attempts"), list): + raise ReviewContractError("invalid_receipt", "persisted chunk receipt is malformed") + if chunk["review_status"] == "rejected" and chunk["findings"]: + raise ReviewContractError("invalid_receipt", "rejected chunks cannot contain partial findings") + allowed_citations = chunk["allowed_citation_ids"] + new_citations = chunk["new_record_citation_ids"] + if ( + not isinstance(allowed_citations, list) + or not isinstance(new_citations, list) + or not all(isinstance(item, str) and _CITATION_ID_PATTERN.fullmatch(item) for item in allowed_citations) + or not set(new_citations).issubset(set(allowed_citations)) + ): + raise ReviewContractError("invalid_receipt", "persisted citation metadata is invalid") + normalized_findings: list[dict[str, Any]] = [] + finding_keys = { + "schema_version", + "finding_id", + "finding_type", + "severity", + "confidence", + "evidence", + "observation", + "engineering_implication", + "contains_verbatim_private_text", + } + for finding in chunk["findings"]: + if not isinstance(finding, dict): + raise ReviewContractError("invalid_receipt", "persisted finding must be an object") + _reject_unknown_or_forbidden_fields(finding, finding_keys) + if set(finding) != finding_keys or finding.get("schema_version") != FINDING_SCHEMA: + raise ReviewContractError("invalid_receipt", "persisted finding schema is invalid") + finding_id = _require_nonempty_string(finding.get("finding_id"), code="invalid_receipt") + if not _FINDING_ID_PATTERN.fullmatch(finding_id) or finding.get("finding_type") not in ALLOWED_FINDING_TYPES or finding.get("severity") not in ALLOWED_SEVERITIES: + raise ReviewContractError("invalid_receipt", "persisted finding identity is invalid") + confidence = finding.get("confidence") + if isinstance(confidence, bool) or not isinstance(confidence, (int, float)) or not math.isfinite(float(confidence)) or not 0.0 <= float(confidence) <= 1.0: + raise ReviewContractError("invalid_receipt", "persisted finding confidence is invalid") + if finding.get("contains_verbatim_private_text") is not False: + raise ReviewContractError("invalid_receipt", "persisted finding cannot allow verbatim text") + evidence = finding.get("evidence") + if not isinstance(evidence, list) or not evidence: + raise ReviewContractError("invalid_receipt", "persisted finding evidence is invalid") + citations: list[str] = [] + normalized_evidence: list[dict[str, str]] = [] + for item in evidence: + if not isinstance(item, dict) or set(item) != {"citation_id", "claim"}: + raise ReviewContractError("invalid_receipt", "persisted evidence is invalid") + citation = item.get("citation_id") + claim = item.get("claim") + if not isinstance(citation, str) or citation not in allowed_citations or not isinstance(claim, str) or not claim.strip() or _contains_forbidden_text(claim): + raise ReviewContractError("invalid_receipt", "persisted evidence violates the boundary") + citations.append(citation) + normalized_evidence.append({"citation_id": citation, "claim": claim}) + if not set(citations) & set(new_citations): + raise ReviewContractError("invalid_receipt", "persisted finding has overlap-only evidence") + observation = _require_nonempty_string(finding.get("observation"), code="invalid_receipt") + implication = _require_nonempty_string(finding.get("engineering_implication"), code="invalid_receipt") + if _contains_forbidden_text(observation) or _contains_forbidden_text(implication): + raise ReviewContractError("invalid_receipt", "persisted finding has prohibited semantic content") + normalized_findings.append( + { + "schema_version": FINDING_SCHEMA, + "finding_id": finding_id, + "finding_type": finding["finding_type"], + "severity": finding["severity"], + "confidence": float(confidence), + "evidence": normalized_evidence, + "observation": observation, + "engineering_implication": implication, + "contains_verbatim_private_text": False, + } + ) + attempt_keys = { + "attempt", + "stage", + "requested_model", + "resolved_model", + "model_digest", + "runtime_name", + "runtime_version", + "endpoint_class", + "elapsed_ms", + "prompt_tokens", + "completion_tokens", + "stop_reason", + "status", + "validation_error_codes", + } + for item in chunk["attempts"]: + if not isinstance(item, dict) or set(item) != attempt_keys: + raise ReviewContractError("invalid_receipt", "persisted attempt receipt is invalid") + if item["stage"] not in {"R1", "R2"} or item["status"] not in {"accepted", "rejected"}: + raise ReviewContractError("invalid_receipt", "persisted attempt status is invalid") + if isinstance(item["attempt"], bool) or not isinstance(item["attempt"], int) or item["attempt"] < 1: + raise ReviewContractError("invalid_receipt", "persisted attempt ordinal is invalid") + if not isinstance(item["requested_model"], str) or not isinstance(item["runtime_name"], str): + raise ReviewContractError("invalid_receipt", "persisted attempt identity is invalid") + if item["endpoint_class"] not in {"loopback", "unix_socket", "mock"}: + raise ReviewContractError("invalid_receipt", "persisted endpoint class is invalid") + if not isinstance(item["elapsed_ms"], (int, float)) or not math.isfinite(float(item["elapsed_ms"])): + raise ReviewContractError("invalid_receipt", "persisted elapsed time is invalid") + if not isinstance(item["validation_error_codes"], list) or not all(isinstance(code, str) for code in item["validation_error_codes"]): + raise ReviewContractError("invalid_receipt", "persisted validation errors are invalid") + # Aggregation only needs the metadata representation, so create a small + # shell rather than reconstructing the raw in-memory chunk. + results.append( + ChunkReviewResult( + {key: chunk[key] for key in ("schema_version", "chunk_id", "source_sha256", "allowed_citation_ids", "new_record_citation_ids")}, + tuple(sorted(normalized_findings, key=lambda item: item["finding_id"])), + chunk["review_status"], + tuple( + AttemptReceipt( + item["attempt"], item["stage"], item["requested_model"], item.get("resolved_model"), item.get("model_digest"), + item["runtime_name"], item.get("runtime_version"), item["endpoint_class"], float(item["elapsed_ms"]), + item.get("prompt_tokens"), item.get("completion_tokens"), item.get("stop_reason"), item["status"], + tuple(item.get("validation_error_codes", [])), + ) + for item in chunk["attempts"] + ), + tuple(chunk["rejection_error_codes"]), + ) + ) + return results + + +def load_quarantined_source(source_path: Path, quarantine_report_path: Path) -> SourceEnvelope: + """Load an explicit local source only after validating its existing quarantine report.""" + import quarantine_dialectic_corpus as quarantine # Local sibling; no data read on import. + + report_bytes = quarantine_report_path.read_bytes() + report = strict_json_loads(report_bytes.decode("utf-8")) + if not isinstance(report, dict) or report.get("schema_version") != quarantine.PARSE_REPORT_SCHEMA: + raise ReviewContractError("invalid_quarantine_report", "quarantine report schema is invalid") + disposition = require_quarantine_disposition(report.get("disposition")) + source_bytes = source_path.read_bytes() + source_sha = sha256_bytes(source_bytes) + source_metadata = report.get("source") + if not isinstance(source_metadata, dict) or source_metadata.get("sha256") != source_sha: + raise ReviewContractError("source_checksum_mismatch", "source does not match quarantine report") + records: list[ReviewRecord] = [] + for line, raw_line in enumerate(source_bytes.splitlines(), start=1): + try: + event = quarantine.load_strict_json_object(raw_line) + quarantine.validate_turn_schema(event) + except quarantine.ParseFailure: + continue + records.append(ReviewRecord(line, event["index"], event)) + expected = report.get("records", {}).get("valid_record_count") if isinstance(report.get("records"), dict) else None + if expected != len(records): + raise ReviewContractError("source_record_count_mismatch", "source no longer matches quarantine report count") + return SourceEnvelope(source_sha, sha256_bytes(report_bytes), disposition, tuple(records), source_path) + + +def dry_run_receipt(source: SourceEnvelope, configuration: ReviewConfiguration) -> dict[str, Any]: + """Build envelopes without invoking a model or serializing raw records.""" + chunks = build_chunk_envelopes(source, configuration) + return { + "schema_version": "nc-local-review-dry-run-v0", + "source_sha256": source.source_sha256, + "chunk_count": len(chunks), + "chunk_ids": [chunk.chunk_id for chunk in chunks], + "all_chunks_have_new_records": all(bool(chunk.new_record_citation_ids) for chunk in chunks), + "configuration_sha256": configuration.sha256, + "model_invoked": False, + "disposition": source.disposition, + "science_status": "pipeline_only", + "decision": "insufficient_evidence", + "promotion_status": "not_eligible", + "live_control": False, + } + + +def _load_configuration(path: Path | None) -> ReviewConfiguration: + if path is None: + return ReviewConfiguration.from_mapping(default_configuration()) + value = strict_json_loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ReviewContractError("invalid_configuration", "configuration must be a JSON object") + return ReviewConfiguration.from_mapping(value) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", required=True, type=Path) + parser.add_argument("--quarantine-report", required=True, type=Path) + parser.add_argument("--configuration", type=Path) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--backend-url") + parser.add_argument("--artifact-directory", type=Path) + parser.add_argument("--repository-root", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--attest-network-isolation", action="store_true") + parser.add_argument("--attest-prompt-response-logging-confined", action="store_true") + args = parser.parse_args(argv) + source = load_quarantined_source(args.source, args.quarantine_report) + configuration = _load_configuration(args.configuration) + if args.dry_run: + print(json.dumps(dry_run_receipt(source, configuration), sort_keys=True)) + return 0 + if not args.backend_url or args.artifact_directory is None: + parser.error("a non-dry run requires --backend-url and --artifact-directory") + attestation = OperatorAttestation(args.attest_network_isolation, args.attest_prompt_response_logging_confined) + run = run_review_cascade(source, configuration, OllamaLocalBackend(args.backend_url), attestation) + paths = write_metadata_only_artifacts(run, artifact_directory=args.artifact_directory, repository_root=args.repository_root) + print(json.dumps({key: str(value) for key, value in paths.items()}, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/local_review_noninterference.py b/Scripts/local_review_noninterference.py new file mode 100644 index 0000000..34fbc35 --- /dev/null +++ b/Scripts/local_review_noninterference.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Deterministic metadata-only audit for review/EEG noninterference.""" + +from __future__ import annotations + +import json +from typing import Any, Iterable + + +NONINTERFERENCE_SCHEMA = "nc-local-review-eeg-noninterference-v0" + + +class NoninterferenceError(ValueError): + pass + + +def _serialized(value: Any) -> str: + return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + + +def _require_absent(value: Any, prohibited_values: Iterable[str], label: str) -> None: + serialized = _serialized(value) + if any(item and item in serialized for item in prohibited_values): + raise NoninterferenceError(label) + + +def audit_noninterference( + *, + dialogue_source_sha256: str, + dialogue_content_hashes: Iterable[str], + review_finding_ids: Iterable[str], + eeg_dataset_artifact: Any, + eeg_state_artifact: Any, + eeg_model_input_manifest: Any, + eeg_experiment_configuration: Any, + local_review_prompt_metadata: Any, + eeg_window_hashes: Iterable[str], + shared_training_buffer: Any = None, + dialogue_embeddings_created: bool = False, + dialogue_derived_weight_updates: bool = False, +) -> dict[str, Any]: + """Prove only absence from supplied metadata artifacts, never data quality.""" + if not isinstance(dialogue_source_sha256, str) or not dialogue_source_sha256: + raise NoninterferenceError("dialogue source SHA-256 is required") + content_hashes = tuple(dialogue_content_hashes) + finding_ids = tuple(review_finding_ids) + window_hashes = tuple(eeg_window_hashes) + _require_absent([eeg_dataset_artifact, eeg_state_artifact], [dialogue_source_sha256], "dialogue source SHA present in EEG artifact") + _require_absent(eeg_model_input_manifest, content_hashes, "dialogue content hash present in EEG model input") + _require_absent(eeg_experiment_configuration, finding_ids, "review finding present in EEG experiment configuration") + _require_absent(local_review_prompt_metadata, window_hashes, "EEG window hash present in review prompt metadata") + if shared_training_buffer is not None: + raise NoninterferenceError("review and EEG tracks must not share a training buffer") + if dialogue_embeddings_created: + raise NoninterferenceError("dialogue embeddings are prohibited") + if dialogue_derived_weight_updates: + raise NoninterferenceError("dialogue-derived weight updates are prohibited") + return { + "schema_version": NONINTERFERENCE_SCHEMA, + "status": "pass", + "checks": { + "dialogue_source_sha_absent_from_eeg_artifacts": True, + "dialogue_content_hashes_absent_from_eeg_model_inputs": True, + "review_findings_absent_from_eeg_configuration": True, + "eeg_window_hashes_absent_from_review_prompts": True, + "no_shared_training_buffer": True, + "no_dialogue_embeddings": True, + "no_dialogue_derived_weight_updates": True, + }, + "science_status": "pipeline_only", + "decision": "insufficient_evidence", + "promotion_status": "not_eligible", + "live_control": False, + } diff --git a/Scripts/quarantine_dialectic_corpus.py b/Scripts/quarantine_dialectic_corpus.py index fe93a4e..2271c43 100644 --- a/Scripts/quarantine_dialectic_corpus.py +++ b/Scripts/quarantine_dialectic_corpus.py @@ -40,6 +40,7 @@ "eligible_for_encoder_evaluation": False, "eligible_for_policy_training": False, "eligible_for_policy_evaluation": False, + "eligible_for_science": False, "contains_private_dialogue": True, "cloud_exposure_allowed": False, } diff --git a/Scripts/research_decision_register.py b/Scripts/research_decision_register.py new file mode 100644 index 0000000..e9ad5f5 --- /dev/null +++ b/Scripts/research_decision_register.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Validation for governance-only research decision-register entries. + +An entry records why a method may be studied. It cannot authorize a dependency, +runtime feature, model run, experiment, or promotion decision. +""" + +from __future__ import annotations + +import json +from typing import Any, Mapping + + +DECISION_REGISTER_SCHEMA = "nc-research-decision-register-v0" +PASSES = frozenset({1, 2, 3, 4}) +OWNERS = frozenset({"science", "engineering", "computation"}) +DATA_GATES = frozenset({"D0", "D1", "D2", "D3", "post_encoder"}) +IMPLEMENTATION_STATUSES = frozenset({"deferred", "study_only", "eligible_for_experiment"}) +REQUIRED_FIELDS = frozenset( + { + "schema_version", + "topic", + "pass", + "owner", + "registered_question", + "decision_it_can_change", + "required_data_gate", + "falsification_criterion", + "implementation_status", + "runtime_dependency_authorized", + } +) + + +class DecisionRegisterError(ValueError): + pass + + +def _required_text(value: Any, name: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise DecisionRegisterError(f"{name} must be a nonempty string") + return value + + +def validate_decision_register_entry(value: Mapping[str, Any]) -> dict[str, Any]: + """Strictly validate a metadata-only entry without granting implementation.""" + if set(value) != REQUIRED_FIELDS: + raise DecisionRegisterError("decision register entry has missing or unsupported fields") + if value.get("schema_version") != DECISION_REGISTER_SCHEMA: + raise DecisionRegisterError("decision register schema version is invalid") + if isinstance(value.get("pass"), bool) or value.get("pass") not in PASSES: + raise DecisionRegisterError("pass must be an integer in [1, 4]") + if value.get("owner") not in OWNERS: + raise DecisionRegisterError("owner is invalid") + if value.get("required_data_gate") not in DATA_GATES: + raise DecisionRegisterError("required_data_gate is invalid") + if value.get("implementation_status") not in IMPLEMENTATION_STATUSES: + raise DecisionRegisterError("implementation_status is invalid") + if value.get("runtime_dependency_authorized") is not False: + raise DecisionRegisterError("decision registers never authorize runtime dependencies") + for field in ("topic", "registered_question", "decision_it_can_change", "falsification_criterion"): + _required_text(value.get(field), field) + return json.loads(json.dumps(dict(value), sort_keys=True)) diff --git a/Scripts/review_quarantined_dialectics.py b/Scripts/review_quarantined_dialectics.py new file mode 100644 index 0000000..f5c351b --- /dev/null +++ b/Scripts/review_quarantined_dialectics.py @@ -0,0 +1,605 @@ +#!/usr/bin/env python3 +"""Run a bounded, local-only semantic engineering review of private dialectics. + +This tool accepts only a quarantined dialectic corpus and a loopback Ollama +endpoint. It sends bounded raw chunks to a local Qwen model without retaining +prompts, then persists only validated, metadata-only findings. It never creates +embeddings, changes weights, or produces scientific evidence. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import urllib.error +import urllib.request +from collections import Counter, defaultdict +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import quarantine_dialectic_corpus as quarantine + + +REVIEW_SCHEMA = "nc-dialectic-local-review-chunk-v0" +RUN_SCHEMA = "nc-dialectic-local-review-run-v0" +AGGREGATE_SCHEMA = "nc-dialectic-local-review-aggregate-v0" +REVIEWER_VERSION = "local-dialectic-review-v1" +DEFAULT_MODEL = "qwen2.5:0.5b" +DEFAULT_OLLAMA_URL = "http://127.0.0.1:11434" +CHUNK_SIZE = 16 +CHUNK_OVERLAP = 2 +REVIEW_SEED = 42 +REVIEW_TEMPERATURE = 0.0 +DEFAULT_CONTEXT_LIMIT = 8192 +MAX_OBSERVATION_LENGTH = 400 +MAX_IMPLICATION_LENGTH = 300 +FINDING_TYPES = frozenset( + { + "malformed_record", + "duplicate_index", + "non_monotonic_index", + "candidate_role_inconsistency", + "candidate_duplication", + "selection_inertia", + "repetitive_synthesis", + "state_discontinuity", + "generator_identity_change", + "possible_context_loss", + "unclassified", + } +) +SEVERITIES = frozenset({"low", "medium", "high"}) +REVIEW_DISPOSITION = { + "corpus_role": "engineering_replay_only", + "review_role": "local_semantic_engineering_review", + "development_only_permanent": True, + "eligible_for_science": False, + "eligible_for_training": False, + "cloud_exposure_allowed": False, +} +FINDINGS_JSON_SCHEMA = { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "required": [ + "source_lines", + "legacy_turn_indices", + "finding_type", + "severity", + "confidence", + "observation", + "engineering_implication", + "contains_verbatim_private_text", + ], + "properties": { + "source_lines": {"type": "array", "items": {"type": "integer"}}, + "legacy_turn_indices": {"type": "array", "items": {"type": "integer"}}, + "finding_type": {"type": "string", "enum": sorted(FINDING_TYPES)}, + "severity": {"type": "string", "enum": sorted(SEVERITIES)}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "observation": {"type": "string", "maxLength": MAX_OBSERVATION_LENGTH}, + "engineering_implication": {"type": "string", "maxLength": MAX_IMPLICATION_LENGTH}, + "contains_verbatim_private_text": {"type": "boolean", "const": False}, + }, + }, +} +REQUIRED_QUARANTINE_DISPOSITION = { + "corpus_role": "engineering_replay_only", + "development_only_permanent": True, + "eligible_for_encoder_training": False, + "eligible_for_encoder_evaluation": False, + "eligible_for_policy_training": False, + "eligible_for_policy_evaluation": False, + "cloud_exposure_allowed": False, +} + +SYSTEM_PROMPT = """You are reviewing a private, permanently quarantined dialogue corpus for local engineering purposes. + +The corpus is development-only. It is ineligible for encoder training, encoder evaluation, policy training, policy evaluation, scientific claims, EEG labeling, or cloud exposure. + +Identify structural and conversational-runtime defects that could affect deterministic replay or dialogue-system engineering. You may inspect semantic content only for repeated/collapsed candidates, candidate-role inconsistency, selection inertia, synthesis repetition, apparent context loss, turn-state discontinuity, candidate-diversity versus selection mismatch, or generator behavior changes. + +Do not infer thoughts, intention, emotion, agreement, diagnosis, or cognitive state. Do not propose EEG labels or features. Do not infer timestamps or speaker identities. Do not reproduce private dialogue text. Do not recommend training from this corpus. + +Return only a JSON array. Each object must contain source_lines, legacy_turn_indices, finding_type, severity, confidence, observation, engineering_implication, and contains_verbatim_private_text. The final field must be false. Use source-line order as canonical; legacy turn indices may repeat or reset. Use unclassified when evidence is ambiguous.""" + + +class ReviewContractError(ValueError): + """A local review request or model response violates the quarantine contract.""" + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def sha256_json(value: Any) -> str: + return sha256_bytes(json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")) + + +def _require_disposition(value: Any) -> None: + if not isinstance(value, dict): + raise ReviewContractError("parse report lacks quarantine disposition") + for key, expected in REQUIRED_QUARANTINE_DISPOSITION.items(): + if value.get(key) != expected: + raise ReviewContractError(f"parse report quarantine disposition mismatch: {key}") + + +def load_reviewable_records(source_path: Path, parse_report_path: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]: + """Load only source rows that passed the prior strict quarantine parser.""" + report = json.loads(parse_report_path.read_text()) + if report.get("schema_version") != quarantine.PARSE_REPORT_SCHEMA: + raise ReviewContractError("unexpected parse report schema") + _require_disposition(report.get("disposition")) + + source_bytes = source_path.read_bytes() + source = report.get("source") + if not isinstance(source, dict) or source.get("sha256") != sha256_bytes(source_bytes): + raise ReviewContractError("raw source checksum does not match quarantine parse report") + + records: list[dict[str, Any]] = [] + for source_line, raw_line in enumerate(source_bytes.splitlines(), start=1): + try: + event = quarantine.load_strict_json_object(raw_line) + quarantine.validate_turn_schema(event) + except quarantine.ParseFailure: + continue + records.append({"source_line": source_line, "legacy_turn_index": event["index"], "raw_event": event}) + + expected_count = report.get("records", {}).get("valid_record_count") + if expected_count != len(records): + raise ReviewContractError("raw source no longer reconstructs the quarantined valid-record count") + return report, records + + +def chunk_records(records: list[dict[str, Any]], *, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> list[list[dict[str, Any]]]: + if chunk_size <= 0 or overlap < 0 or overlap >= chunk_size: + raise ReviewContractError("chunk size must be positive and overlap must be smaller than it") + chunks: list[list[dict[str, Any]]] = [] + start = 0 + while start < len(records): + chunk = records[start : start + chunk_size] + if not chunk: + break + chunks.append(chunk) + if start + chunk_size >= len(records): + break + start += chunk_size - overlap + return chunks + + +def _sanitize_identity(event: dict[str, Any]) -> dict[str, str] | None: + return quarantine.fingerprint_identity(event) + + +def build_chunk_context(chunk_id: str, records: list[dict[str, Any]]) -> str: + """Build the only raw-content prompt payload used by one stateless request.""" + source_line_to_legacy_index = { + str(record["source_line"]): record["legacy_turn_index"] + for record in records + } + return json.dumps( + { + "disposition": REVIEW_DISPOSITION, + "chunk_id": chunk_id, + "citation_contract": { + "canonical_source_lines_allowed": [record["source_line"] for record in records], + "legacy_turn_index_by_source_line": source_line_to_legacy_index, + "rule": "Cite only listed source lines and their exact mapped legacy index. Do not invent contiguous ordinals.", + }, + "records": [ + { + "source_line": record["source_line"], + "legacy_turn_index": record["legacy_turn_index"], + "generator_fingerprint": _sanitize_identity(record["raw_event"]), + "raw_record": record["raw_event"], + } + for record in records + ], + }, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + + +def _require_loopback_ollama(base_url: str) -> str: + parsed = urlparse(base_url) + if ( + parsed.scheme != "http" + or parsed.hostname not in {"127.0.0.1", "localhost", "::1"} + or parsed.port != 11434 + or parsed.username is not None + or parsed.password is not None + or parsed.path not in {"", "/"} + ): + raise ReviewContractError("local review permits only http://127.0.0.1:11434 or localhost equivalent") + return f"http://{parsed.netloc}" + + +def _request_json(url: str, *, method: str = "GET", payload: dict[str, Any] | None = None) -> dict[str, Any]: + data = None if payload is None else json.dumps(payload).encode("utf-8") + request = urllib.request.Request(url, data=data, method=method) + request.add_header("Accept", "application/json") + if data is not None: + request.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(request, timeout=120) as response: + value = json.loads(response.read().decode("utf-8")) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error: + raise ReviewContractError(f"local Ollama request failed: {error}") from error + if not isinstance(value, dict): + raise ReviewContractError("local Ollama response must be a JSON object") + return value + + +def local_model_identity(base_url: str, model: str) -> dict[str, Any]: + if not model.startswith("qwen2.5:"): + raise ReviewContractError("only a local qwen2.5 model is allowed for this review") + tags = _request_json(f"{base_url}/api/tags") + models = tags.get("models") + if not isinstance(models, list): + raise ReviewContractError("local Ollama tags response lacks models") + for candidate in models: + if isinstance(candidate, dict) and candidate.get("name") == model: + if candidate.get("remote_model") or candidate.get("remote_host"): + raise ReviewContractError("remote Ollama models are prohibited for corpus review") + digest = candidate.get("digest") + if not isinstance(digest, str) or not digest: + raise ReviewContractError("local Ollama model lacks a digest") + return { + "name": model, + "digest": digest, + "details": candidate.get("details") if isinstance(candidate.get("details"), dict) else {}, + } + raise ReviewContractError(f"local Ollama model is unavailable: {model}") + + +def _json_array_from_model_content(content: str) -> list[Any]: + stripped = content.strip() + if stripped.startswith("```"): + match = re.fullmatch(r"```(?:json)?\s*(.*?)\s*```", stripped, flags=re.DOTALL | re.IGNORECASE) + if match is not None: + stripped = match.group(1) + try: + value = json.loads(stripped) + except json.JSONDecodeError as first_error: + decoder = json.JSONDecoder() + value = None + for position, character in enumerate(stripped): + if character != "[": + continue + try: + candidate, _ = decoder.raw_decode(stripped[position:]) + except json.JSONDecodeError: + continue + if isinstance(candidate, list): + value = candidate + break + if value is None: + raise ReviewContractError("local review response was not a JSON array") from first_error + if not isinstance(value, list): + raise ReviewContractError("local review response must be a JSON array") + return value + + +def _private_text_fragments(records: list[dict[str, Any]]) -> list[str]: + fragments: list[str] = [] + + def add_fragment(value: str) -> None: + normalized = " ".join(value.split()) + if len(normalized) >= 16: + fragments.append(normalized) + words = normalized.split() + for start in range(max(0, len(words) - 3)): + phrase = " ".join(words[start : start + 4]) + if len(phrase) >= 16: + fragments.append(phrase) + + for record in records: + content = quarantine.content_projection(record["raw_event"]) + for field, value in content.items(): + if isinstance(value, str) and len(value.strip()) >= 16: + add_fragment(value) + if field == "candidates" and isinstance(value, list): + for candidate in value: + if isinstance(candidate, dict): + text = candidate.get("text") + if isinstance(text, str) and len(text.strip()) >= 16: + add_fragment(text) + return fragments + + +def _contains_private_text(value: str, fragments: list[str]) -> bool: + normalized = " ".join(value.casefold().split()) + return any(" ".join(fragment.casefold().split()) in normalized for fragment in fragments) + + +def validate_findings(response_content: str, records: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Accept only bounded, chunk-local, non-verbatim engineering findings.""" + value = _json_array_from_model_content(response_content) + allowed_lines = {record["source_line"] for record in records} + line_to_index = {record["source_line"]: record["legacy_turn_index"] for record in records} + index_to_lines: dict[int, list[int]] = defaultdict(list) + for source_line, legacy_index in line_to_index.items(): + index_to_lines[legacy_index].append(source_line) + private_fragments = _private_text_fragments(records) + findings: list[dict[str, Any]] = [] + for position, finding in enumerate(value): + if not isinstance(finding, dict): + raise ReviewContractError(f"finding {position} must be an object") + source_lines = finding.get("source_lines") + legacy_indexes = finding.get("legacy_turn_indices") + if ( + not isinstance(legacy_indexes, list) + or not legacy_indexes + or any(isinstance(index, bool) or not isinstance(index, int) for index in legacy_indexes) + ): + raise ReviewContractError(f"finding {position} has invalid legacy turn indexes") + if not isinstance(source_lines, list) or not source_lines or any(isinstance(line, bool) or not isinstance(line, int) for line in source_lines): + raise ReviewContractError(f"finding {position} has invalid source lines") + citation_normalized = False + if any(line not in allowed_lines for line in source_lines): + remapped_lines = [index_to_lines.get(index, []) for index in legacy_indexes] + if any(len(lines) != 1 for lines in remapped_lines): + raise ReviewContractError(f"finding {position} cites source lines outside its chunk") + source_lines = [lines[0] for lines in remapped_lines] + citation_normalized = True + if len(set(source_lines)) != len(source_lines): + raise ReviewContractError(f"finding {position} repeats a source line") + expected_indexes = {line_to_index[line] for line in source_lines} + if set(legacy_indexes) != expected_indexes: + raise ReviewContractError(f"finding {position} cites inconsistent source lines and legacy indexes") + finding_type = finding.get("finding_type") + severity = finding.get("severity") + confidence = finding.get("confidence") + observation = finding.get("observation") + implication = finding.get("engineering_implication") + if finding_type not in FINDING_TYPES or severity not in SEVERITIES: + raise ReviewContractError(f"finding {position} uses an unsupported category") + if isinstance(confidence, bool) or not isinstance(confidence, (int, float)) or not 0.0 <= float(confidence) <= 1.0: + raise ReviewContractError(f"finding {position} has invalid confidence") + if not isinstance(observation, str) or not observation or len(observation) > MAX_OBSERVATION_LENGTH: + raise ReviewContractError(f"finding {position} has invalid observation") + if not isinstance(implication, str) or not implication or len(implication) > MAX_IMPLICATION_LENGTH: + raise ReviewContractError(f"finding {position} has invalid engineering implication") + if finding.get("contains_verbatim_private_text") is not False: + raise ReviewContractError(f"finding {position} must deny verbatim private text") + if _contains_private_text(observation, private_fragments) or _contains_private_text(implication, private_fragments): + raise ReviewContractError(f"finding {position} contains private dialogue text") + findings.append( + { + "source_lines": sorted(source_lines), + "legacy_turn_indices": sorted(set(legacy_indexes)), + "finding_type": finding_type, + "severity": severity, + "confidence": float(confidence), + "observation": observation, + "engineering_implication": implication, + "contains_verbatim_private_text": False, + "citation_normalized_from_legacy_index": citation_normalized, + } + ) + return findings + + +def review_chunk(base_url: str, model: str, context_limit: int, chunk_id: str, records: list[dict[str, Any]]) -> list[dict[str, Any]]: + response = _request_json( + f"{base_url}/api/chat", + method="POST", + payload={ + "model": model, + "stream": False, + "format": FINDINGS_JSON_SCHEMA, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": build_chunk_context(chunk_id, records)}, + ], + "options": {"temperature": REVIEW_TEMPERATURE, "seed": REVIEW_SEED, "num_ctx": context_limit}, + }, + ) + message = response.get("message") + if not isinstance(message, dict) or not isinstance(message.get("content"), str): + raise ReviewContractError("local Ollama chat response lacks message content") + return validate_findings(message["content"], records) + + +def aggregate_review_chunks(chunks: list[dict[str, Any]]) -> dict[str, Any]: + """Aggregate structured Pass-A findings without reopening the raw corpus.""" + category_lines: dict[str, set[int]] = defaultdict(set) + category_chunks: dict[str, set[str]] = defaultdict(set) + line_types: dict[int, set[str]] = defaultdict(set) + confidences: list[float] = [] + finding_count = 0 + for chunk in chunks: + chunk_id = str(chunk["chunk_id"]) + for finding in chunk["findings"]: + finding_count += 1 + finding_type = str(finding["finding_type"]) + source_lines = [int(line) for line in finding["source_lines"]] + category_lines[finding_type].update(source_lines) + category_chunks[finding_type].add(chunk_id) + for source_line in source_lines: + line_types[source_line].add(finding_type) + confidences.append(float(finding["confidence"])) + confidence_distribution = { + "0.00_to_0.24": sum(confidence < 0.25 for confidence in confidences), + "0.25_to_0.49": sum(0.25 <= confidence < 0.5 for confidence in confidences), + "0.50_to_0.74": sum(0.5 <= confidence < 0.75 for confidence in confidences), + "0.75_to_1.00": sum(confidence >= 0.75 for confidence in confidences), + } + categories = [ + { + "finding_type": finding_type, + "finding_count": sum( + 1 for chunk in chunks for finding in chunk["findings"] if finding["finding_type"] == finding_type + ), + "affected_source_lines": sorted(category_lines[finding_type]), + "chunk_ids": sorted(category_chunks[finding_type]), + "spans_chunk_boundaries": len(category_chunks[finding_type]) > 1, + } + for finding_type in sorted(category_lines) + ] + conflicts = [ + {"source_line": source_line, "finding_types": sorted(finding_types)} + for source_line, finding_types in sorted(line_types.items()) + if len(finding_types) > 1 + ] + return { + "schema_version": AGGREGATE_SCHEMA, + "reviewer_version": REVIEWER_VERSION, + "input_chunk_count": len(chunks), + "finding_count": finding_count, + "categories": categories, + "conflicting_findings": conflicts, + "confidence_distribution": confidence_distribution, + "disposition": REVIEW_DISPOSITION, + } + + +def _load_review_chunks(path: Path) -> list[dict[str, Any]]: + chunks: list[dict[str, Any]] = [] + for line_number, line in enumerate(path.read_text().splitlines(), start=1): + try: + chunk = json.loads(line) + except json.JSONDecodeError as error: + raise ReviewContractError(f"review findings line {line_number} is invalid JSON") from error + if not isinstance(chunk, dict) or chunk.get("schema_version") != REVIEW_SCHEMA: + raise ReviewContractError(f"review findings line {line_number} has the wrong schema") + if chunk.get("disposition") != REVIEW_DISPOSITION: + raise ReviewContractError(f"review findings line {line_number} has the wrong disposition") + if not isinstance(chunk.get("chunk_id"), str) or not isinstance(chunk.get("findings"), list): + raise ReviewContractError(f"review findings line {line_number} is malformed") + chunks.append(chunk) + return chunks + + +def run_review( + source_path: Path, + parse_report_path: Path, + findings_path: Path, + run_manifest_path: Path, + *, + base_url: str, + model: str, + context_limit: int, + prompt_logging_status: str, +) -> dict[str, Any]: + if prompt_logging_status != "verified_disabled": + raise ReviewContractError("private review requires prompt_logging_status=verified_disabled") + report, records = load_reviewable_records(source_path, parse_report_path) + chunks = chunk_records(records) + model_identity = local_model_identity(base_url, model) + review_chunks: list[dict[str, Any]] = [] + review_status = "completed" + rejected_chunk_count = 0 + for ordinal, records_chunk in enumerate(chunks, start=1): + chunk_id = f"chunk-{ordinal:03d}" + chunk_result = { + "schema_version": REVIEW_SCHEMA, + "chunk_id": chunk_id, + "source_lines": [record["source_line"] for record in records_chunk], + "legacy_turn_indices": [record["legacy_turn_index"] for record in records_chunk], + "disposition": REVIEW_DISPOSITION, + } + try: + chunk_result["findings"] = review_chunk(base_url, model, context_limit, chunk_id, records_chunk) + chunk_result["review_status"] = "accepted" + except ReviewContractError as error: + # Never persist a raw model response, even when it violates the + # requested schema or privacy boundary. The safe rejection receipt + # is enough to decide whether this model can perform the review. + chunk_result["findings"] = [] + chunk_result["review_status"] = "rejected_response" + chunk_result["rejection_reason"] = str(error) + review_chunks.append(chunk_result) + review_status = "contract_rejected" + rejected_chunk_count = 1 + break + review_chunks.append(chunk_result) + run_manifest = { + "schema_version": RUN_SCHEMA, + "reviewer_version": REVIEWER_VERSION, + "review_model": model_identity["name"], + "model_digest": model_identity["digest"], + "model_details": model_identity["details"], + "runtime": "ollama_local_loopback", + "network_enabled": False, + "temperature": REVIEW_TEMPERATURE, + "seed": REVIEW_SEED, + "context_limit": context_limit, + "chunk_size_valid_records": CHUNK_SIZE, + "chunk_overlap_records": CHUNK_OVERLAP, + "conversation_memory_between_chunks": False, + "embeddings_created": False, + "weights_updated": False, + "source_sha256": report["source"]["sha256"], + "science_influence_allowed": False, + "prompt_logging_status": "verified_disabled", + "disposition": REVIEW_DISPOSITION, + "review_status": review_status, + "accepted_chunk_count": len(review_chunks) - rejected_chunk_count, + "rejected_chunk_count": rejected_chunk_count, + "review_chunk_count": len(review_chunks), + "review_findings_sha256": sha256_json(review_chunks), + } + findings_path.parent.mkdir(parents=True, exist_ok=True) + run_manifest_path.parent.mkdir(parents=True, exist_ok=True) + findings_path.write_text("".join(json.dumps(chunk, sort_keys=True) + "\n" for chunk in review_chunks)) + run_manifest_path.write_text(json.dumps(run_manifest, indent=2, sort_keys=True) + "\n") + return run_manifest + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + subcommands = parser.add_subparsers(dest="command", required=True) + + review = subcommands.add_parser("review", help="perform stateless local Qwen chunk review") + review.add_argument("--input", required=True, type=Path, help="private local dialectic JSONL") + review.add_argument("--parse-report", required=True, type=Path) + review.add_argument("--findings-output", required=True, type=Path) + review.add_argument("--run-manifest-output", required=True, type=Path) + review.add_argument("--model", default=DEFAULT_MODEL) + review.add_argument("--ollama-url", default=DEFAULT_OLLAMA_URL) + review.add_argument("--context-limit", default=DEFAULT_CONTEXT_LIMIT, type=int) + review.add_argument( + "--prompt-logging-status", + choices=("verified_disabled",), + required=True, + help="operator attestation required before private text is sent to local Ollama", + ) + + aggregate = subcommands.add_parser("aggregate", help="aggregate structured Pass-A findings without raw dialogue") + aggregate.add_argument("--findings-input", required=True, type=Path) + aggregate.add_argument("--output", required=True, type=Path) + args = parser.parse_args(argv) + + if args.command == "aggregate": + result = aggregate_review_chunks(_load_review_chunks(args.findings_input)) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n") + print(f"wrote {args.output} ({result['finding_count']} metadata-only findings)") + return 0 + + if args.context_limit <= 0: + raise SystemExit("--context-limit must be positive") + base_url = _require_loopback_ollama(args.ollama_url) + manifest = run_review( + args.input, + args.parse_report, + args.findings_output, + args.run_manifest_output, + base_url=base_url, + model=args.model, + context_limit=args.context_limit, + prompt_logging_status=args.prompt_logging_status, + ) + print(f"wrote {args.findings_output} and {args.run_manifest_output} ({manifest['review_chunk_count']} local-only chunks)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Tests/eval/test_local_dialectic_review.py b/Tests/eval/test_local_dialectic_review.py new file mode 100644 index 0000000..e4ebb73 --- /dev/null +++ b/Tests/eval/test_local_dialectic_review.py @@ -0,0 +1,161 @@ +"""Regression tests for bounded local semantic review of quarantined dialogue.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPTS = Path(__file__).resolve().parents[2] / "Scripts" +sys.path.insert(0, str(SCRIPTS)) + + +def _load(name: str): + path = SCRIPTS / name + spec = importlib.util.spec_from_file_location(path.stem, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +QUARANTINE = _load("quarantine_dialectic_corpus.py") +REVIEW = _load("review_quarantined_dialectics.py") + + +def _turn(index: int, text: str) -> dict: + return { + "index": index, + "heard": text, + "candidates": [ + {"text": f"candidate one {text}", "roleID": "coherence-seeking"}, + {"text": f"candidate two {text}", "roleID": "displacement-seeking"}, + ], + "tension": 0.4, + "margin": 0.1, + "selectionTemperature": 0.2, + "glossScalar": 0.5, + "outcome": "synthesized:synthesis", + "spokenText": f"spoken {text}", + } + + +class LocalDialecticReviewTests(unittest.TestCase): + def _quarantined_source(self, root: Path) -> tuple[Path, Path]: + source = root / "dialectic-turns-2026-07-22.jsonl" + source.write_text("\n".join(json.dumps(_turn(index, f"private phrase number {index}")) for index in range(18)) + "\n") + report = root / "parse-report.json" + QUARANTINE.inspect_corpus(source, report, root / "events.jsonl") + return source, report + + def test_chunking_is_stateless_with_fixed_overlap(self) -> None: + with tempfile.TemporaryDirectory() as directory: + source, report = self._quarantined_source(Path(directory)) + _, records = REVIEW.load_reviewable_records(source, report) + chunks = REVIEW.chunk_records(records) + self.assertEqual([len(chunk) for chunk in chunks], [16, 4]) + self.assertEqual([record["source_line"] for record in chunks[0][-2:]], [15, 16]) + self.assertEqual([record["source_line"] for record in chunks[1][:2]], [15, 16]) + context = REVIEW.build_chunk_context("chunk-001", chunks[0]) + self.assertIn("private phrase number 0", context) + self.assertIn("canonical_source_lines_allowed", context) + self.assertIn('"1":0', context) + self.assertNotIn("research", REVIEW.SYSTEM_PROMPT.casefold()) + + def test_review_response_is_chunk_bound_and_non_verbatim(self) -> None: + with tempfile.TemporaryDirectory() as directory: + source, report = self._quarantined_source(Path(directory)) + _, records = REVIEW.load_reviewable_records(source, report) + safe_response = json.dumps([{ + "source_lines": [1, 2], + "legacy_turn_indices": [0, 1], + "finding_type": "selection_inertia", + "severity": "medium", + "confidence": 0.75, + "observation": "Adjacent outputs show limited structural variation.", + "engineering_implication": "Replay should preserve source-line identity for comparison.", + "contains_verbatim_private_text": False, + }]) + findings = REVIEW.validate_findings(safe_response, records[:2]) + self.assertEqual(findings[0]["finding_type"], "selection_inertia") + self.assertFalse(findings[0]["contains_verbatim_private_text"]) + self.assertFalse(findings[0]["citation_normalized_from_legacy_index"]) + + prefixed_findings = REVIEW.validate_findings("Review result:\n" + safe_response, records[:2]) + self.assertEqual(prefixed_findings, findings) + + leaking_response = safe_response.replace( + "Adjacent outputs show limited structural variation.", + "private phrase number 0", + ) + with self.assertRaisesRegex(REVIEW.ReviewContractError, "private dialogue"): + REVIEW.validate_findings(leaking_response, records[:2]) + + legacy_citation_response = safe_response.replace('"source_lines": [1, 2]', '"source_lines": [0, 1]') + normalized = REVIEW.validate_findings(legacy_citation_response, records[:2]) + self.assertEqual(normalized[0]["source_lines"], [1, 2]) + self.assertTrue(normalized[0]["citation_normalized_from_legacy_index"]) + + def test_aggregation_never_reopens_raw_dialogue(self) -> None: + chunks = [ + { + "schema_version": REVIEW.REVIEW_SCHEMA, + "chunk_id": "chunk-001", + "findings": [{"finding_type": "duplicate_index", "source_lines": [3], "confidence": 0.8}], + "disposition": REVIEW.REVIEW_DISPOSITION, + }, + { + "schema_version": REVIEW.REVIEW_SCHEMA, + "chunk_id": "chunk-002", + "findings": [ + {"finding_type": "duplicate_index", "source_lines": [3, 17], "confidence": 0.9}, + {"finding_type": "state_discontinuity", "source_lines": [3], "confidence": 0.6}, + ], + "disposition": REVIEW.REVIEW_DISPOSITION, + }, + ] + result = REVIEW.aggregate_review_chunks(chunks) + duplicate = next(category for category in result["categories"] if category["finding_type"] == "duplicate_index") + self.assertTrue(duplicate["spans_chunk_boundaries"]) + self.assertEqual(duplicate["affected_source_lines"], [3, 17]) + self.assertEqual(result["conflicting_findings"], [{"source_line": 3, "finding_types": ["duplicate_index", "state_discontinuity"]}]) + self.assertEqual(result["disposition"], REVIEW.REVIEW_DISPOSITION) + + def test_aggregation_accepts_a_safe_rejection_receipt(self) -> None: + result = REVIEW.aggregate_review_chunks([{ + "schema_version": REVIEW.REVIEW_SCHEMA, + "chunk_id": "chunk-001", + "review_status": "rejected_response", + "rejection_reason": "finding 0 cites source lines outside its chunk", + "findings": [], + "disposition": REVIEW.REVIEW_DISPOSITION, + }]) + self.assertEqual(result["finding_count"], 0) + self.assertEqual(result["categories"], []) + + def test_remote_endpoint_and_cloud_model_are_rejected(self) -> None: + with self.assertRaisesRegex(REVIEW.ReviewContractError, "only http://127.0.0.1"): + REVIEW._require_loopback_ollama("https://example.com") + with self.assertRaisesRegex(REVIEW.ReviewContractError, "qwen2.5"): + REVIEW.local_model_identity("http://127.0.0.1:11434", "deepseek-v4-flash:cloud") + + def test_private_review_requires_prompt_logging_attestation(self) -> None: + with self.assertRaisesRegex(REVIEW.ReviewContractError, "prompt_logging_status"): + REVIEW.run_review( + Path("unused-source.jsonl"), + Path("unused-report.json"), + Path("unused-findings.jsonl"), + Path("unused-manifest.json"), + base_url="http://127.0.0.1:11434", + model="qwen2.5:0.5b", + context_limit=8192, + prompt_logging_status="unverified", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tests/eval/test_local_open_weight_review.py b/Tests/eval/test_local_open_weight_review.py new file mode 100644 index 0000000..c53fece --- /dev/null +++ b/Tests/eval/test_local_open_weight_review.py @@ -0,0 +1,392 @@ +"""Synthetic contract tests for the fail-closed local open-weight review cascade.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path + +import numpy as np + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPTS = ROOT / "Scripts" +EEG_SOURCE = ROOT / "NeuralComposeEEG" / "src" +sys.path[:0] = [str(SCRIPTS), str(EEG_SOURCE), str(ROOT)] + + +def _load(filename: str): + path = SCRIPTS / filename + spec = importlib.util.spec_from_file_location(path.stem, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +CASCADE = _load("local_open_weight_review.py") +NONINTERFERENCE = _load("local_review_noninterference.py") +REGISTER = _load("research_decision_register.py") + + +def _disposition() -> dict: + return { + "corpus_role": "engineering_replay_only", + "development_only_permanent": True, + "eligible_for_encoder_training": False, + "eligible_for_encoder_evaluation": False, + "eligible_for_policy_training": False, + "eligible_for_policy_evaluation": False, + "eligible_for_science": False, + "cloud_exposure_allowed": False, + } + + +def _record(index: int) -> dict: + return { + "index": index, + "payload": { + "heard": f"fixture confidential dialogue material alpha {index} stable", + "candidate": f"fixture candidate material beta {index} stable", + }, + } + + +def _source(count: int = 18, *, source_path: Path | None = None) -> object: + records = tuple( + CASCADE.ReviewRecord(41 + position, [7, 7, 4][position % 3], _record(position)) + for position in range(count) + ) + source_bytes = ("fixture-source-" + str(count)).encode("utf-8") + return CASCADE.SourceEnvelope( + CASCADE.sha256_bytes(source_bytes), + CASCADE.sha256_bytes(b"fixture-quarantine-report"), + _disposition(), + records, + source_path, + ) + + +def _configuration(*, maximum_attempts: int = 3) -> object: + value = json.loads(json.dumps(CASCADE.default_configuration())) + value["retry"]["maximum_attempts"] = maximum_attempts + return CASCADE.ReviewConfiguration.from_mapping(value) + + +def _finding(chunk: object, *, citation_id: str | None = None, finding_id: str = "finding-1") -> dict: + citation = citation_id or chunk.new_record_citation_ids[0] + return { + "schema_version": CASCADE.FINDING_SCHEMA, + "finding_id": finding_id, + "finding_type": "selection_inertia", + "severity": "medium", + "confidence": 0.75, + "evidence": [{"citation_id": citation, "claim": "A bounded structural pattern is visible."}], + "observation": "Adjacent records exhibit limited structural variation.", + "engineering_implication": "Replay should preserve source-line identity.", + "contains_verbatim_private_text": False, + } + + +def _attestation() -> object: + return CASCADE.OperatorAttestation(True, True) + + +class LocalOpenWeightReviewTests(unittest.TestCase): + def test_valid_chunk_construction_overlap_duplicate_and_nonmonotonic_indices(self) -> None: + chunks = CASCADE.build_chunk_envelopes(_source(), _configuration()) + self.assertEqual([len(chunk.records) for chunk in chunks], [16, 4]) + self.assertEqual(chunks[0].chunk_id.split(":")[-1], "0001") + self.assertEqual([record.citation_id for record in chunks[0].records[-2:]], ["line:55", "line:56"]) + self.assertEqual([record.citation_id for record in chunks[1].records[:2]], ["line:55", "line:56"]) + self.assertTrue(all(record.is_overlap_record for record in chunks[1].records[:2])) + self.assertTrue(all(not record.is_overlap_record for record in chunks[1].records[2:])) + self.assertEqual([record.legacy_turn_index for record in chunks[0].records[:3]], [7, 7, 4]) + self.assertEqual(CASCADE.FROZEN_SYSTEM_PROMPT, CASCADE.FROZEN_SYSTEM_PROMPT) + + def test_finding_validator_accepts_complete_causal_response(self) -> None: + chunk = CASCADE.build_chunk_envelopes(_source(2), _configuration())[0] + findings = CASCADE.validate_findings_response(json.dumps([_finding(chunk)]), chunk) + self.assertEqual(findings[0]["finding_type"], "selection_inertia") + self.assertEqual(findings[0]["evidence"][0]["citation_id"], "line:41") + + def test_validator_rejects_invented_and_mismatched_citations(self) -> None: + chunk = CASCADE.build_chunk_envelopes(_source(2), _configuration())[0] + invented = _finding(chunk, citation_id="line:999") + with self.assertRaisesRegex(CASCADE.ReviewContractError, "citation"): + CASCADE.validate_findings_response(json.dumps([invented]), chunk) + malformed_chunk = CASCADE.ChunkEnvelope( + chunk.chunk_id, + chunk.source_sha256, + (CASCADE.ChunkRecord("line:41", 42, 7, False, _record(0)),), + ) + mismatch = _finding(malformed_chunk) + with self.assertRaisesRegex(CASCADE.ReviewContractError, "citation"): + CASCADE.validate_findings_response(json.dumps([mismatch]), malformed_chunk) + + def test_validator_rejects_overlap_only_evidence(self) -> None: + chunk = CASCADE.build_chunk_envelopes(_source(), _configuration())[1] + finding = _finding(chunk, citation_id=chunk.records[0].citation_id) + with self.assertRaisesRegex(CASCADE.ReviewContractError, "new chunk"): + CASCADE.validate_findings_response(json.dumps([finding]), chunk) + + def test_validator_rejects_invalid_json_nonfinite_and_unknown_type(self) -> None: + chunk = CASCADE.build_chunk_envelopes(_source(2), _configuration())[0] + with self.assertRaisesRegex(CASCADE.ReviewContractError, "complete JSON"): + CASCADE.validate_findings_response("not-json", chunk) + nonfinite = json.dumps([_finding(chunk)]).replace("0.75", "NaN") + with self.assertRaisesRegex(CASCADE.ReviewContractError, "non-finite"): + CASCADE.validate_findings_response(nonfinite, chunk) + infinity = json.dumps([_finding(chunk)]).replace("0.75", "Infinity") + with self.assertRaisesRegex(CASCADE.ReviewContractError, "non-finite"): + CASCADE.validate_findings_response(infinity, chunk) + unknown = _finding(chunk) + unknown["finding_type"] = "unsupported" + with self.assertRaisesRegex(CASCADE.ReviewContractError, "not allowed"): + CASCADE.validate_findings_response(json.dumps([unknown]), chunk) + + def test_validator_rejects_out_of_range_empty_forbidden_and_private_leakage(self) -> None: + chunk = CASCADE.build_chunk_envelopes(_source(2), _configuration())[0] + out_of_range = _finding(chunk) + out_of_range["confidence"] = 1.1 + with self.assertRaisesRegex(CASCADE.ReviewContractError, "within"): + CASCADE.validate_findings_response(json.dumps([out_of_range]), chunk) + empty_evidence = _finding(chunk) + empty_evidence["evidence"] = [] + with self.assertRaisesRegex(CASCADE.ReviewContractError, "nonempty"): + CASCADE.validate_findings_response(json.dumps([empty_evidence]), chunk) + forbidden = _finding(chunk) + forbidden["eeg_label"] = "not permitted" + with self.assertRaisesRegex(CASCADE.ReviewContractError, "prohibited"): + CASCADE.validate_findings_response(json.dumps([forbidden]), chunk) + leaking = _finding(chunk) + leaking["observation"] = "fixture confidential dialogue material alpha 0 stable" + with self.assertRaisesRegex(CASCADE.ReviewContractError, "source text"): + CASCADE.validate_findings_response(json.dumps([leaking]), chunk) + + def test_loopback_acceptance_and_remote_rejection(self) -> None: + for endpoint in ("http://127.0.0.1:11434", "http://localhost:11434", "http://[::1]:11434", "unix:///tmp/local-review.sock"): + self.assertIn(CASCADE.classify_local_endpoint(endpoint), {"loopback", "unix_socket"}) + for endpoint in ("https://example.com", "http://192.168.1.10:11434", "http://localhost.evil:11434"): + with self.assertRaises(CASCADE.ReviewContractError): + CASCADE.classify_local_endpoint(endpoint) + + def test_missing_disposition_and_source_checksum_change_fail_closed(self) -> None: + incomplete = _disposition() + incomplete.pop("eligible_for_science") + with self.assertRaisesRegex(CASCADE.ReviewContractError, "disposition"): + CASCADE.require_quarantine_disposition(incomplete) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "source.jsonl" + original = b"synthetic-source" + path.write_bytes(original) + source = _source(1, source_path=path) + source = CASCADE.SourceEnvelope(CASCADE.sha256_bytes(original), source.quarantine_report_sha256, source.disposition, source.records, path) + path.write_bytes(b"changed") + with self.assertRaisesRegex(CASCADE.ReviewContractError, "changed"): + CASCADE.build_chunk_envelopes(source, _configuration()) + + def test_quarantine_report_checksum_mismatch_is_rejected(self) -> None: + import quarantine_dialectic_corpus as quarantine + + turn = { + "index": 1, + "heard": "synthetic fixture only", + "candidates": [ + {"text": "synthetic coherence", "roleID": "coherence-seeking"}, + {"text": "synthetic displacement", "roleID": "displacement-seeking"}, + ], + "tension": 0.4, + "margin": 0.1, + "selectionTemperature": 0.2, + "glossScalar": 0.5, + "outcome": "synthesized:fixture", + } + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "fixture.jsonl" + report = root / "report.json" + source.write_text(json.dumps(turn) + "\n") + quarantine.inspect_corpus(source, report, root / "events.jsonl") + source.write_text(json.dumps({**turn, "index": 2}) + "\n") + with self.assertRaisesRegex(CASCADE.ReviewContractError, "does not match"): + CASCADE.load_quarantined_source(source, report) + + def test_source_mutation_during_attempt_aborts_the_entire_run(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "source.jsonl" + original = b"fixture-source-2" + path.write_bytes(original) + initial = _source(2, source_path=path) + source = CASCADE.SourceEnvelope(CASCADE.sha256_bytes(original), initial.quarantine_report_sha256, initial.disposition, initial.records, path) + chunk = CASCADE.build_chunk_envelopes(source, _configuration())[0] + + class MutatingBackend(CASCADE.MockLocalBackend): + def invoke(self, **kwargs): + path.write_bytes(b"source-mutated-during-attempt") + return super().invoke(**kwargs) + + backend = MutatingBackend({"qwen3:0.6b": [json.dumps([_finding(chunk)])]}) + with self.assertRaisesRegex(CASCADE.ReviewContractError, "changed"): + CASCADE.run_review_cascade(source, _configuration(), backend, _attestation()) + + def test_retry_r1_failure_then_valid_r2_without_raw_response_handoff(self) -> None: + source = _source(2) + configuration = _configuration() + chunk = CASCADE.build_chunk_envelopes(source, configuration)[0] + invalid = json.dumps([_finding(chunk, citation_id="line:999")]) + valid = json.dumps([_finding(chunk)]) + backend = CASCADE.MockLocalBackend({"qwen3:0.6b": [invalid], "qwen3:4b": [valid]}) + run = CASCADE.run_review_cascade(source, configuration, backend, _attestation()) + result = run.chunk_results[0] + self.assertEqual(result.review_status, "accepted") + self.assertEqual([attempt.stage for attempt in result.attempts], ["R1", "R2"]) + self.assertEqual(backend.invocations[1]["previous_validation_error_codes"], ["citation_not_allowed"]) + self.assertEqual(backend.invocations[0]["system_prompt_sha256"], backend.invocations[1]["system_prompt_sha256"]) + + def test_bounded_retry_count_and_all_attempts_rejected(self) -> None: + source = _source(2) + configuration = _configuration(maximum_attempts=2) + backend = CASCADE.MockLocalBackend({"qwen3:0.6b": ["not-json"], "qwen3:4b": ["not-json"]}) + run = CASCADE.run_review_cascade(source, configuration, backend, _attestation()) + result = run.chunk_results[0] + self.assertEqual(result.review_status, "rejected") + self.assertEqual(len(result.attempts), 2) + self.assertEqual(len(backend.invocations), 2) + self.assertEqual(result.rejection_error_codes, ("invalid_json",)) + + def test_local_attestations_and_explicit_r3_adjudication_boundary(self) -> None: + source = _source(2) + configuration = _configuration() + chunk = CASCADE.build_chunk_envelopes(source, configuration)[0] + valid = _finding(chunk) + backend = CASCADE.MockLocalBackend({"gemma-family-local": [json.dumps([_finding(chunk, finding_id="critic-1")])]}) + with self.assertRaisesRegex(CASCADE.ReviewContractError, "attest"): + CASCADE.run_review_cascade( + source, + configuration, + CASCADE.MockLocalBackend({"qwen3:0.6b": [json.dumps([valid])]}), + CASCADE.OperatorAttestation(False, True), + ) + with self.assertRaisesRegex(CASCADE.ReviewContractError, "human request"): + CASCADE.run_explicit_critic_adjudication( + chunk=chunk, + accepted_finding=valid, + configuration=configuration, + backend=backend, + attestation=_attestation(), + requested_by_human=False, + ) + receipt = CASCADE.run_explicit_critic_adjudication( + chunk=chunk, + accepted_finding=valid, + configuration=configuration, + backend=backend, + attestation=_attestation(), + requested_by_human=True, + ) + self.assertEqual(receipt["status"], "advisory_only") + self.assertFalse(receipt["r0_override_permitted"]) + + def test_aggregator_is_metadata_only_and_deterministic(self) -> None: + source = _source(2) + configuration = _configuration() + chunk = CASCADE.build_chunk_envelopes(source, configuration)[0] + backend = CASCADE.MockLocalBackend({"qwen3:0.6b": [json.dumps([_finding(chunk)])]}) + run = CASCADE.run_review_cascade(source, configuration, backend, _attestation()) + first = CASCADE.aggregate_review_results(run.chunk_results, source_sha256=source.source_sha256, disposition=source.disposition) + second = CASCADE.aggregate_review_results(run.chunk_results, source_sha256=source.source_sha256, disposition=source.disposition) + self.assertEqual(first, second) + self.assertEqual(first["affected_source_lines"], [41]) + bad = run.chunk_results[0].persisted() + bad["raw_record"] = _record(0) + with self.assertRaisesRegex(CASCADE.ReviewContractError, "raw payload"): + CASCADE.chunks_to_results([bad]) + + def test_metadata_artifacts_never_persist_synthetic_source_content(self) -> None: + source = _source(2) + configuration = _configuration() + chunk = CASCADE.build_chunk_envelopes(source, configuration)[0] + backend = CASCADE.MockLocalBackend({"qwen3:0.6b": [json.dumps([_finding(chunk)])]}) + run = CASCADE.run_review_cascade(source, configuration, backend, _attestation()) + with tempfile.TemporaryDirectory() as directory: + paths = CASCADE.write_metadata_only_artifacts(run, artifact_directory=Path(directory), repository_root=ROOT) + serialized = "".join(path.read_text() for path in paths.values()) + self.assertNotIn("fixture confidential dialogue material", serialized) + self.assertIn("pipeline_only", serialized) + + def test_cross_track_noninterference(self) -> None: + report = NONINTERFERENCE.audit_noninterference( + dialogue_source_sha256="a" * 64, + dialogue_content_hashes=["b" * 64], + review_finding_ids=["finding-1"], + eeg_dataset_artifact={"dataset": "synthetic"}, + eeg_state_artifact={"state": "shadow_only"}, + eeg_model_input_manifest={"input": "canonical"}, + eeg_experiment_configuration={"experiment": "EXP-NC-EEG-ENC-001"}, + local_review_prompt_metadata={"schema": "chunk"}, + eeg_window_hashes=["c" * 64], + ) + self.assertEqual(report["status"], "pass") + with self.assertRaisesRegex(NONINTERFERENCE.NoninterferenceError, "source SHA"): + NONINTERFERENCE.audit_noninterference( + dialogue_source_sha256="a" * 64, + dialogue_content_hashes=[], + review_finding_ids=[], + eeg_dataset_artifact={"source": "a" * 64}, + eeg_state_artifact={}, + eeg_model_input_manifest={}, + eeg_experiment_configuration={}, + local_review_prompt_metadata={}, + eeg_window_hashes=[], + ) + + def test_synthetic_structured_state_replay_remains_shadow_only(self) -> None: + from neuralcompose_eeg.dataset import build_canonical_dataset + from neuralcompose_eeg.structured_state import load_shadow_state_records, write_shadow_state_artifacts + from NeuralComposeEEG.tests.test_pipeline import _manifest, _preprocessing_path + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + dataset, _ = build_canonical_dataset(_manifest(root), _preprocessing_path()) + probabilities = np.zeros((len(dataset.labels), len(dataset.label_order)), dtype=np.float64) + probabilities[:, 0] = 1.0 + states = root / "states.jsonl" + manifest = root / "manifest.json" + written = write_shadow_state_artifacts( + dataset, + probabilities, + encoder_provenance={"model_id": "fixture", "model_revision": "v0", "source_kind": "synthetic_contract_fixture"}, + states_output=states, + manifest_output=manifest, + ) + replay = load_shadow_state_records(states, manifest) + self.assertEqual(written["status"], "insufficient_evidence") + self.assertTrue(all(item["shadow_only"] for item in replay)) + self.assertFalse(any(item["live_control"] for item in replay)) + + def test_decision_register_is_governance_only(self) -> None: + entry = { + "schema_version": REGISTER.DECISION_REGISTER_SCHEMA, + "topic": "forward model foundations", + "pass": 2, + "owner": "science", + "registered_question": "Does a specified forward model change a stated decision?", + "decision_it_can_change": "Whether to register a separate forward-model experiment.", + "required_data_gate": "D1", + "falsification_criterion": "The model fails its preregistered error criterion.", + "implementation_status": "deferred", + "runtime_dependency_authorized": False, + } + self.assertEqual(REGISTER.validate_decision_register_entry(entry), entry) + entry["runtime_dependency_authorized"] = True + with self.assertRaisesRegex(REGISTER.DecisionRegisterError, "never authorize"): + REGISTER.validate_decision_register_entry(entry) + + +if __name__ == "__main__": + unittest.main() diff --git a/configs/function-space-foundations-v0.json b/configs/function-space-foundations-v0.json new file mode 100644 index 0000000..cea204b --- /dev/null +++ b/configs/function-space-foundations-v0.json @@ -0,0 +1,161 @@ +{ + "schema_version": "function-space-foundations-config-v0", + "report_schema_version": "function-space-foundations-report-v0", + "experiment_id": "EXP-FUNC-SYN-000", + "status": "foundational_study_only", + "data_gate": "D0", + "decision": "insufficient_evidence", + "promotion_status": "not_eligible", + "runtime_change": "none", + "physical_eeg_used": false, + "dialogue_logs_used": false, + "fallback_capture_used": false, + "scientific_claim_allowed": false, + "input_policy": { + "source_kind": "embedded_deterministic_synthetic_parameters", + "external_data_arguments_allowed": false, + "physical_eeg_allowed": false, + "historical_eeg_allowed": false, + "dialogue_allowed": false, + "telemetry_allowed": false, + "fallback_data_allowed": false, + "model_outputs_allowed": false + }, + "serialization": { + "float_significant_digits": 12, + "same_platform_byte_identity_required": true, + "cross_platform_numerical_tolerance_required": true, + "wall_clock_fields_allowed": false, + "absolute_paths_allowed": false + }, + "source_files": [ + "src/FunctionSpaceFoundations.jl", + "scripts/run_function_space_foundations.jl", + "notebooks/01_empirical_measure_curve_fit.jl", + "notebooks/02_ad_vs_finite_difference.jl", + "notebooks/03_sobolev_curve_fit.jl", + "notebooks/04_egorov_convergence_fixture.jl", + "notebooks/05_operator_stability.jl", + "notebooks/06_stieltjes_event_integration.jl" + ], + "tolerances": { + "absolute": 1e-10, + "gradient": 1e-10, + "quadrature": 1e-7, + "operator_scaling_relative": 0.05, + "positive_distance_minimum": 1e-6 + }, + "studies": { + "FS0": { + "id": "FS0", + "name": "empirical_measure_and_pseudometric", + "sample_points": [0.0, 0.25, 0.5, 0.75, 1.0], + "atomic_weights": [0.2, 0.2, 0.2, 0.2, 0.2], + "dense_grid_size": 1001, + "normalization": "maximum_absolute_value_on_dense_grid", + "negative_fixture_sample_index": 3, + "negative_fixture_perturbation": 0.1 + }, + "FS1": { + "id": "FS1", + "name": "automatic_vs_numerical_differentiation", + "parameters": [0.8, 1.7, -0.2], + "evaluation_time": 0.37, + "signal": "sin(2*pi*t)", + "finite_difference_scheme": "forward", + "step_sizes": [0.03125, 0.015625, 0.0078125, 0.00390625], + "noise_model": { + "kind": "deterministic_alternating_sample_perturbation", + "amplitude": 0.001, + "seed": 1201 + } + }, + "FS2": { + "id": "FS2", + "name": "sobolev_tikhonov_curve_fit", + "domain": [0.0, 1.0], + "sample_count": 65, + "basis": { + "kind": "fourier", + "maximum_harmonic": 12, + "coefficient_order": "intercept_then_sin_cos_by_harmonic" + }, + "truth": { + "intercept": 0.35, + "sine_coefficients": [0.7, 0.0, 0.15, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + "cosine_coefficients": [0.0, -0.25, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] + }, + "noise_model": { + "kind": "gaussian", + "standard_deviation": 0.15 + }, + "sessions": { + "training": [ + {"id": "fs2-train-01", "seed": 1101}, + {"id": "fs2-train-02", "seed": 1102}, + {"id": "fs2-train-03", "seed": 1103}, + {"id": "fs2-train-04", "seed": 1104} + ], + "validation": [ + {"id": "fs2-validation-01", "seed": 2101}, + {"id": "fs2-validation-02", "seed": 2102} + ], + "held_out": [ + {"id": "fs2-held-out-01", "seed": 3101}, + {"id": "fs2-held-out-02", "seed": 3102} + ] + }, + "regularization_grid": [0.0, 1e-6, 0.0001, 0.01, 1.0, 100.0], + "validation_derivative_weight": 0.25, + "normalization": "training_target_mean_and_standard_deviation_only", + "quadrature_rule": "analytic_fourier_roughness_matrix", + "overfit_baseline_lambda": 0.0 + }, + "FS3": { + "id": "FS3", + "name": "egorov_theorem_fixture", + "domain": [0.0, 1.0], + "sequence": "x^n", + "n_values": [1, 2, 4, 8, 16, 32, 64], + "epsilon_values": [0.1, 0.05], + "interpretation": "theorem_illustration" + }, + "FS4": { + "id": "FS4", + "name": "operator_stability", + "domain": [0.0, 1.0], + "grid_sizes": [17, 33, 65, 129], + "vector_norm": "l2", + "induced_matrix_norm": "spectral_l2", + "derivative_operator": "forward_difference", + "integration_operator": "cumulative_trapezoid", + "control_operator": "identity", + "perturbation_amplitude": 1e-6, + "perturbation_frequency": 6.5, + "negative_fixtures": ["dimension_mismatch", "nonfinite_input"] + }, + "FS5": { + "id": "FS5", + "name": "continuous_and_atomic_integration", + "domain": [0.0, 1.0], + "test_function": "exp(t)", + "density": "1+t", + "quadrature_rule": "composite_trapezoid", + "quadrature_points": 4097, + "event_times": [0.2, 0.55, 0.9], + "event_weights": [0.5, -0.25, 0.75], + "perturbed_event_index": 2, + "event_weight_delta": 0.1, + "event_semantics": "synthetic_cue_only", + "measure_language": "lebesgue_stieltjes_mixed_measure" + } + }, + "representation_regularization_boundary": { + "implemented_here": false, + "new_trainer_authorized": false, + "existing_contracts": [ + "EXP-NC-EEG-JEPA-001:J2-J5", + "jepa-collapse-diagnostics-v0" + ] + } +} diff --git a/configs/local-open-weight-review-v0.json b/configs/local-open-weight-review-v0.json new file mode 100644 index 0000000..9c653be --- /dev/null +++ b/configs/local-open-weight-review-v0.json @@ -0,0 +1,48 @@ +{ + "schema_version": "nc-local-review-cascade-config-v0", + "configuration_version": "local-open-weight-review-v0", + "validator": { + "id": "R0", + "type": "deterministic" + }, + "proposer": { + "id": "R1", + "model": "qwen3:0.6b", + "purpose": "bounded_chunk_findings" + }, + "reviewer": { + "id": "R2", + "preferred_model": "qwen3:4b", + "fallback_model": "qwen3:1.7b", + "purpose": "citation_repair_and_aggregation" + }, + "critic": { + "id": "R3", + "model": "gemma-family-local", + "purpose": "explicit_independent_adjudication", + "enabled_by_default": false + }, + "human_gate": { + "id": "R4", + "required_for": [ + "policy_changes", + "experiment_changes", + "scientific_claims", + "promotion_decisions" + ] + }, + "chunking": { + "valid_records_per_chunk": 16, + "overlap_records": 2, + "conversation_memory_between_chunks": false + }, + "generation": { + "temperature": 0.0, + "seed": 42, + "context_limit": 8192, + "max_output_tokens": 2048 + }, + "retry": { + "maximum_attempts": 3 + } +} diff --git a/docs/NeuralCompose-android-client-prompt.md b/docs/NeuralCompose-android-client-prompt.md new file mode 100644 index 0000000..2910fc4 --- /dev/null +++ b/docs/NeuralCompose-android-client-prompt.md @@ -0,0 +1,430 @@ +# NeuralCompose Android Client: Build Prompt for Hermes Agent on Pixel 8a + +> Execute this prompt in Termux on the Pixel 8a. The agent (you) has terminal, +> file editing, search, and code execution tools. The model is minimax-m3:cloud +> via Ollama cloud API. Work in order. Each stage is independently testable. + +--- + +## Context + +NeuralCompose is a privacy-first macOS BCI application running on an M4 Mac at +home. It processes EEG from a Muse headband through BrainFlow, Core ML (ANE), +and MLX, producing real-time intent classifications, channel health, and 3D +visualizations. The Mac is on a Tailscale VPN at `100.94.124.23` (Pixel 8a) +and `100.105.8.22` (M4 Mac). + +Your job: build an Android client app on this Pixel 8a that connects to the +NeuralCompose M4 server over Tailscale and displays live pipeline state. The +app is a thin client: all heavy processing stays on the Mac. The Pixel does +UI rendering, voice dictation, and local storage only. + +## Environment + +- Device: Google Pixel 8a, 8GB RAM, Tensor G3, 128GB storage +- OS: Android, Termux (Linux userland, no root) +- Python: 3.13 (Termux native) +- Node.js: needs installing +- Tailscale: installed, tailnet IP `100.94.124.23` +- Hermes Agent: installed in venv at `~/.hermes/hermes-agent/venv` +- Model: minimax-m3:cloud via Ollama cloud API + +## Hard constraints + +1. **No Android SDK.** Termux cannot run `aapt2`, `d8`, or the Android build + tools. APK builds go through Expo EAS Build (cloud service, free tier: + 30 builds/month). No local APK compilation. +2. **No root.** No `sudo`, no Docker, no kernel modules. Termux userland only. +3. **8GB RAM.** Metro bundler + Expo Go + Hermes Python process must not run + simultaneously during builds. Cycle: Hermes edits, Metro restarts, Expo Go + reloads. Close Hermes while testing in Expo Go if memory pressure appears. +4. **Expo Go is the renderer.** Install Expo Go from the Play Store. The dev + server (Metro) runs in Termux on `localhost:8080`. Expo Go connects to it. + Hot reload works. No emulator needed. +5. **No local inference.** The cloud LLM is the reasoning engine. The Pixel + is the terminal + dev server + renderer. No local model weights. +6. **The M4 server does not exist yet.** Build against mock data fixtures + matching the API contract below. The real server is a separate PR on the + M4 (Claude Code's job). When it's ready, swap the mock URL for the + Tailscale URL. + +--- + +## Part 1: Environment setup + +### 1.1 Install Node.js and Expo + +```bash +pkg install nodejs git +node --version # confirm v20+ +npm install -g expo-cli +expo-cli --version +``` + +If `expo-cli` global install fails (RAM or permission), use `npx expo` +instead: it downloads on first use and doesn't require a global install. + +### 1.2 Bootstrap the project + +```bash +cd ~ +npx create-expo-app neuralcompose-client +cd neuralcompose-client +npx expo start +``` + +Open Expo Go on the Pixel. Scan the QR code or open the URL. Confirm the +default "Welcome to Expo" screen renders. If it does, the dev loop works. + +### 1.3 Install dependencies + +```bash +npx expo install react-native-chart-kit react-native-svg +npx expo install @react-native-async-storage/async-storage +npx expo install expo-av +npx expo install expo-network +``` + +If any install fails due to RAM, install them one at a time with +`--max-workers=1`. + +--- + +## Part 2: API contract + +The M4 server (not yet built) will expose these endpoints. Build the client +against mock fixtures matching these schemas. When the server is ready, +replace the mock URL with `http://100.105.8.22:8081` (M4's Tailscale IP, +port to be confirmed). + +### 2.1 GET /api/diagnostics + +Returns `StreamDiagnostics` as JSON: + +```json +{ + "transport": "OSC (Mind Monitor)", + "sampleRate": 256.0, + "packetsReceived": 15432, + "packetsDropped": 3, + "packetLossEstimate": null, + "packetJitterMillis": 2.4, + "lastInterArrivalMillis": 3.9, + "lastHeartbeat": "2026-07-23T18:30:00Z", + "boundPort": 5000, + "localInterfaceName": "utun3" +} +``` + +### 2.2 GET /api/health + +Returns an array of `ChannelHealthState`: + +```json +[ + {"channel": "TP9", "status": "healthy", "rms": 162.5, "samples": 77966, "timestamp": 305.2, "lastSampleWallClock": 1783738346.0}, + {"channel": "AF7", "status": "healthy", "rms": 176.6, "samples": 77966, "timestamp": 305.2, "lastSampleWallClock": 1783738346.0}, + {"channel": "AF8", "status": "saturated", "rms": 971.0, "samples": 77966, "timestamp": 305.2, "lastSampleWallClock": 1783738346.0}, + {"channel": "TP10", "status": "healthy", "rms": 146.4, "samples": 77966, "timestamp": 305.2, "lastSampleWallClock": 1783738346.0} +] +``` + +### 2.3 GET /api/classifier + +Returns the latest `IntentPrediction`: + +```json +{ + "intent": "rest", + "confidence": 0.89, + "distribution": {"rest": 0.89, "jawClench": 0.04, "singleBlink": 0.03, "doubleBlink": 0.02, "select": 0.02}, + "windowSequence": 15432, + "endTimestamp": 305.2 +} +``` + +### 2.4 GET /api/pipeline-mode + +Returns the current `PipelineMode`: + +```json +{ + "source": "oscRemote", + "sourceProfile": "OSC Remote (network)", + "classifier": "coreML", + "predictor": "mlx", + "transportDetail": "UDP 5000 · utun3", + "isFullyLive": false, + "substitutionSummary": "EEG: OSC Remote (network) (UDP 5000 · utun3)" +} +``` + +### 2.5 WebSocket /api/eeg/stream + +Streams `EEGSample` objects as JSON, one per ~4ms (256 Hz): + +```json +{"timestamp": 305.21, "channels": [12.5, -34.0, 0.0, 999.75]} +``` + +Channel order: TP9, AF7, AF8, TP10. + +--- + +## Part 3: Mock data layer + +Create `src/mock/` with fixture files matching each API endpoint above. +Create a `MockApiClient` class that returns these fixtures with simulated +delay and jitter (e.g., incrementing `packetsReceived` every second, adding +random noise to EEG samples). + +The `MockApiClient` must conform to the same interface as the real +`ApiClient` (defined next). The app should switch between them via a +single config flag: `USE_MOCK = true` in `src/config.ts`. + +When the M4 server is ready, set `USE_MOCK = false` and point +`SERVER_URL` at the Tailscale IP. Nothing else changes. + +--- + +## Part 4: App architecture + +``` +src/ + config.ts : SERVER_URL, USE_MOCK, poll intervals + api/ + ApiClient.ts : interface for all API calls + MockApiClient.ts : mock implementation with fixtures + LiveApiClient.ts : real implementation, fetch from M4 over Tailscale + screens/ + OverviewScreen.tsx: pipeline mode + privacy indicator + diagnostics + EEGScreen.tsx : real-time 4-channel EEG trace + HealthScreen.tsx : channel health badges (TP9/AF7/AF8/TP10) + ClassifierScreen.tsx: intent prediction + confidence bar + distribution + DreamJournalScreen.tsx: voice/text dream report entry + local storage + components/ + PrivacyBadge.tsx : shows active source + transport detail + ChannelBadge.tsx : single channel health indicator + EEGTrace.tsx : single-channel waveform renderer + ConfidenceBar.tsx : horizontal bar for classifier confidence + StaleIndicator.tsx : "no data Ns" warning + hooks/ + useDiagnostics.ts : polls /api/diagnostics every 1s + useHealth.ts : polls /api/health every 1s + useClassifier.ts : polls /api/classifier every 0.5s + useEEGStream.ts : WebSocket subscription to /api/eeg/stream + usePipelineMode.ts : polls /api/pipeline-mode every 2s + storage/ + DreamJournal.ts : AsyncStorage CRUD for dream reports + App.tsx : tab navigator, 5 screens +``` + +### Navigation + +Bottom tab bar with 5 tabs: Overview, EEG, Health, Classifier, Journal. + +### Polling strategy + +Each hook polls independently. Use `setInterval` with cleanup on unmount. +For the EEG WebSocket, use `reconnecting-websocket` (npm package) with +exponential backoff. On reconnect failure after 3 attempts, show a +"Stream disconnected" banner and fall back to the last cached data. + +--- + +## Part 5: Screens + +### 5.1 OverviewScreen + +Top: privacy badge showing active source, transport detail, and whether +the pipeline is fully live. Below: a diagnostics panel with packets +received, packets dropped, jitter, last heartbeat (relative time: "2s ago"), +bound port, and interface name. If `lastHeartbeat` is more than 5 seconds +old, show the stale indicator in orange. + +### 5.2 EEGScreen + +Four stacked waveforms (TP9, AF7, AF8, TP10), scrolling left-to-right at +256 Hz. Use `react-native-chart-kit` or a custom SVG renderer. Show the +last 5 seconds of data (1280 samples per channel). Color-code: TP9/TP10 +in blue, AF7/AF8 in green. If the WebSocket is disconnected, show a +placeholder with "Connecting..." and the last data frozen. + +Y-axis: auto-scaling per channel, with the range label on the right +(µV). X-axis: time in seconds, relative to stream start. + +### 5.3 HealthScreen + +Four `ChannelBadge` components in a vertical list, one per channel. Each +badge shows: channel name, status (healthy/saturated/dead/unknown, color- +coded green/red/gray/blue), RMS value in µV, sample count, and a staleness +indicator if `lastSampleWallClock` is more than 2 seconds old. Mirror the +macOS `ChannelHealthBadge` behavior. + +### 5.4 ClassifierScreen + +Top: the predicted intent as large text ("Rest", "Jaw Clench", "Single +Blink", "Double Blink", "Select"). Below: a horizontal confidence bar +(0-100%). Below that: the full distribution as 5 stacked horizontal bars, +one per intent class, width proportional to probability. If the classifier +hasn't produced a prediction yet (stale > 5s), dim the screen and show +"Classifier idle." + +### 5.5 DreamJournalScreen + +Top: a text input field for dream report entry. Below the input: a +microphone button using `expo-av` for voice recording. On stop, the audio +is saved locally (AsyncStorage or filesystem). Below: a scrollable list of +past dream reports, each with timestamp, text (if transcribed), and audio +playback button. Entries are stored locally only: no sync to the M4 in +this version. + +If voice transcription is needed (future), it would go through the M4's +MLX LLM. For now, voice recordings are stored as audio files with manual +text entry. + +--- + +## Part 6: Build and distribute + +### 6.1 Test on device + +```bash +npx expo start +``` + +Open Expo Go. Verify all 5 screens render with mock data. Verify the EEG +trace scrolls. Verify the dream journal saves and retrieves entries. + +### 6.2 Build APK via EAS + +```bash +npm install -g eas-cli +eas login # create an Expo account if needed +eas build:configure +eas build --platform android --profile preview +``` + +The `preview` profile produces a standalone APK (not a Play Store bundle). +Download the APK from the EAS dashboard and install it on the Pixel +(`adb install` if `adb` is available, or just open the APK file). + +### 6.3 Configuration for production + +In `app.json`: +- `name`: "NeuralCompose Client" +- `slug`: "neuralcompose-client" +- `version`: "0.1.0" +- `icon`: a placeholder for now (can be replaced later) +- `android.package`: "com.aurascoper.neuralcompose" + +In `eas.json`: +- `preview`: builds APK, internal distribution +- `production`: builds AAB for Play Store (future) + +--- + +## Part 7: M4 server integration (when ready) + +When the M4's thin HTTP server is built (separate Claude Code PR), update +`src/config.ts`: + +```typescript +export const USE_MOCK = false; +export const SERVER_URL = "http://100.105.8.22:8081"; // M4 Tailscale IP +export const EEG_WS_URL = "ws://100.105.8.22:8081/api/eeg/stream"; +``` + +Test over Tailscale: +1. Confirm `tailscale status` shows the M4 as online +2. Open the app, verify OverviewScreen shows live diagnostics +3. Verify EEG trace shows real Muse data +4. Verify channel health updates within 2s of first packet +5. Verify classifier produces non-uniform predictions after 30s + +--- + +## Part 8: Constraints and gotchas + +1. **RAM pressure.** If Metro + Expo Go OOMs, kill Metro + (`Ctrl+C` in Termux), close Expo Go, restart Metro, reopen Expo Go. + The Pixel 8a has 8GB but Android's background processes eat 2-3GB. + +2. **Termux process limits.** Android kills background processes + aggressively. If Termux is killed while Metro is running, use + `termux-wake-lock` to prevent it: + ```bash + termux-wake-lock + ``` + Release it when done: `termux-wake-unlock`. + +3. **WebSocket in React Native.** The default `WebSocket` global works in + React Native, but `reconnecting-websocket` needs a polyfill for + `WebSocket` if the version expects a browser implementation. Test + the WebSocket connection early with a mock server running in Termux + (`python3 -m http.server` won't do WebSocket; use `websockets` Python + package for a quick mock WS server). + +4. **Chart rendering performance.** `react-native-chart-kit` uses SVG + rendering which can be slow for 1280 data points at 30fps. If it + stutters, downgrade to 2 seconds of data (512 samples) or use a + custom Canvas renderer via `react-native-canvas`. + +5. **Audio recording permissions.** `expo-av` needs + `RECORD_AUDIO` permission. In Expo Go this is handled automatically. + In a standalone build, add it to `app.json` under + `android.permissions`. + +6. **Tailscale in Termux.** Tailscale runs as an Android app, not inside + Termux. Termux processes can reach Tailscale IPs because the Android + VPN routes all traffic through Tailscale. No special Termux config + needed. Verify with `curl http://100.105.8.22:8081/api/diagnostics` + when the M4 server is up. + +7. **No claude-mind-mcp on Android.** The Swift-based MCP server won't + compile in Termux. Memory bank and recall are unavailable on the + Pixel. Use AsyncStorage for local persistence instead. + +8. **No prose-craft gate on Android.** The `pcr` Rust binary may + compile via Termux's Rust toolchain (`pkg install rust`), but this + is untested. If you need the prose-craft gate, test `pcr` compilation + separately before relying on it. + +--- + +## Part 9: Execution order + +1. Install Node + Expo (Part 1) +2. Bootstrap project, confirm Expo Go renders (Part 1.2) +3. Write mock data fixtures (Part 3) +4. Write ApiClient interface + MockApiClient (Part 3) +5. Build OverviewScreen with privacy badge + diagnostics (Part 5.1) +6. Build HealthScreen with channel badges (Part 5.3) +7. Build ClassifierScreen with confidence bars (Part 5.4) +8. Build EEGScreen with scrolling waveforms (Part 5.2) +9. Build DreamJournalScreen with text + audio (Part 5.5) +10. Wire up tab navigation (Part 4) +11. Test all screens with mock data (Part 6.1) +12. Build APK via EAS (Part 6.2) +13. Document the Tailscale integration steps (Part 7) + +Each step is independently testable. Do not proceed to the next until +the current one renders correctly in Expo Go. + +--- + +## Part 10: What this app is NOT + +- It is not a standalone NeuralCompose. It does not do EEG processing, + classification, or LLM inference on the Pixel. All processing is on + the M4. +- It is not a Mind Monitor replacement. Mind Monitor handles Muse BLE + acquisition and OSC streaming. This app is a viewer for the M4's + pipeline output. +- It is not a production medical device. It is a research prototype + client for monitoring a BCI pipeline remotely. +- It does not sync dream reports to the M4 in this version. Local + storage only. Sync is a future feature. +- It does not include the 3D SceneKit workspace. That requires WebGL + or Three.js integration, which is a separate scope. The EEG trace + is 2D only in this version. \ No newline at end of file diff --git a/docs/architecture/dialectic-corpus-quarantine.md b/docs/architecture/dialectic-corpus-quarantine.md index 1a18a41..3c75a57 100644 --- a/docs/architecture/dialectic-corpus-quarantine.md +++ b/docs/architecture/dialectic-corpus-quarantine.md @@ -17,11 +17,16 @@ Every derived artifact carries this exact disposition: "eligible_for_encoder_evaluation": false, "eligible_for_policy_training": false, "eligible_for_policy_evaluation": false, + "eligible_for_science": false, "contains_private_dialogue": true, "cloud_exposure_allowed": false } ``` +The cascade requires every field above. A report produced before +`eligible_for_science: false` existed must be regenerated from the unchanged +local source rather than patched by hand. + Local engineering may inspect the raw file for parser recovery, chronological reconstruction, UI replay, turn reconciliation, dialogue-state debugging, or future event-manifest development. It must not use dialogue content to create @@ -71,3 +76,62 @@ structured-state schema, temporal alignment rules, whole-session splits, privacy rules, and unseen confirmation sessions. See [the EEG methods scope](../scoping/eeg-mathematics-physics-methods-scope.md) and [ADR-005](decision-log/ADR-005-local-interaction-logging.md). + +## Local Semantic Engineering Review + +New review work should use the +[Local Open-Weight Review Cascade](local-open-weight-review-cascade.md), which +keeps source-line identity canonical, never repairs citations, limits retries, +and admits only metadata-only artifacts. It still produces development-only +engineering observations, never scientific evidence. + +An already quarantined source may be inspected by a local Qwen 0.5B-class +model for bounded engineering review. This is not a scientific analysis and +does not change the disposition above. The reviewer accepts only a loopback +Ollama endpoint and a local `qwen2.5:*` model; remote or cloud-backed model +identities are rejected. + +Before a run, the operator must verify that the local runtime will not retain +prompt text outside this quarantine directory, then attest to that state on +the command line: + +```sh +python3 Scripts/review_quarantined_dialectics.py review \ + --input "$HOME/Documents/NeuralCompose/InteractionLogs/dialectic-turns-2026-07-22.jsonl" \ + --parse-report "$HOME/Documents/NeuralCompose/InteractionLogs/local-manifests/dialectic-parse-report-2026-07-22.json" \ + --findings-output "$HOME/Documents/NeuralCompose/InteractionLogs/local-manifests/dialectic-local-review-2026-07-22.jsonl" \ + --run-manifest-output "$HOME/Documents/NeuralCompose/InteractionLogs/local-manifests/dialectic-local-review-run-2026-07-22.json" \ + --prompt-logging-status verified_disabled +``` + +The review is stateless: fixed 16-valid-record chunks, two-record overlap, +temperature `0.0`, seed `42`, no embeddings, and no weight updates. The tool +itself persists neither raw prompts nor raw responses; the external runtime is +used only after the operator verifies its retention behavior. It accepts only +bounded JSON findings that cite source lines in their own chunk, use an allowed +engineering category, and do not contain verbatim private content. + +For legacy logs, a reviewer may confuse the non-unique `index` field with a +physical source line. The tool normalizes that citation only when each cited +legacy index maps to exactly one source line inside the active chunk; ambiguous +or inconsistent citations are rejected rather than guessed. + +An invalid model response creates a metadata-only rejection receipt and stops +the run before later chunks are exposed. It stores the safe contract failure, +never the raw response. A rejected run has no validated engineering findings +and is evidence that the selected local model is unsuitable for this review +configuration, not evidence about the dialogue itself. + +Aggregate the metadata-only review stream without reopening the raw corpus: + +```sh +python3 Scripts/review_quarantined_dialectics.py aggregate \ + --findings-input "$HOME/Documents/NeuralCompose/InteractionLogs/local-manifests/dialectic-local-review-2026-07-22.jsonl" \ + --output "$HOME/Documents/NeuralCompose/InteractionLogs/local-manifests/dialectic-local-review-aggregate-2026-07-22.json" +``` + +The aggregate reports issue categories, counts, affected source lines, +conflicting findings, confidence buckets, and cross-chunk recurrence. Any +future policy or prompt affected by this review remains development-only and +must be evaluated on fresh, protocol-defined sessions that have not been +inspected. diff --git a/docs/architecture/local-open-weight-review-cascade-gate-report.md b/docs/architecture/local-open-weight-review-cascade-gate-report.md new file mode 100644 index 0000000..788369a --- /dev/null +++ b/docs/architecture/local-open-weight-review-cascade-gate-report.md @@ -0,0 +1,129 @@ +# Local Open-Weight Review Cascade Gate Report + +**Date:** 2026-07-23 + +## Repository State + +- branch at start: `feat/local-dialectic-review` +- draft PR base branch: `feat/dialectic-corpus-quarantine` +- base merge point with `main`: `611b07e0b6a1030cc01f27b3cf80dfd24286931f` +- starting commit: `6ae9b96` +- prerequisite history present: quarantine `b8c1336`, capture integrity + `9726626`, encoder contract `a90a56f`, methods scope `0441c1c` and scope + audit `3e1b55b` +- both the quarantine contract and structured EEG state bridge are present on + the branch + +The worktree contained unrelated application, telemetry, scientific-document, +packaging, and soak changes before this work began. They are not staged or +modified by this gate. + +## Cascade Implementation + +`Scripts/local_open_weight_review.py` adds R0 validation, configured R1/R2 +attempts, optional manual-only R3 advisory adjudication, and R4 human-gate +documentation. R1 receives the frozen chunk; R2 receives that same chunk plus +R0 error codes only. R3 can be requested explicitly for an R0-valid finding, +but cannot override R0 or mutate a review result. + +The narrow adapter surface supports loopback Ollama-compatible HTTP and a +deterministic mock. A stable local MLX service interface was not present, so no +MLX adapter or dependency was added. The cascade rejects a remote endpoint, +explicit cloud-routing model identity, missing local classification, unconfined +logging attestation, malformed disposition, source/report checksum mismatch, +and source mutation during review. + +`configs/local-open-weight-review-v0.json` owns the model ladder, chunking, +generation settings, and retry bound. The implementation contains no model-name +selection logic. + +## Privacy + +No private corpus, local recording, or artifact beneath a local documents +directory was read for this work. All new tests use generated records and +synthetic EEG fixtures only. No model was downloaded or run. + +Raw source records and model responses remain in memory for one request. The +only serializable outputs are metadata-only chunk receipts, summary, and run +manifest. Output directories must be outside the repository or Git-ignored. +The bounded private-text detector is documented as a guardrail rather than a +proof of privacy. + +## EEG Noninterference + +`Scripts/local_review_noninterference.py` verifies the required metadata-only +separation between review and EEG tracks. It rejects dialogue source or content +hashes in EEG artifacts or model inputs, review finding identifiers in experiment +configuration, EEG window hashes in review prompt metadata, shared training +buffers, dialogue embeddings, and dialogue-derived weight updates. + +The existing structured-state bridge remains probability-only and was verified +with synthetic deterministic replay. It remains shadow-only and cannot become +physical-data evidence, speech, intervention selection, model update, or live +control through this change. + +## Research Governance + +`Scripts/research_decision_register.py` and +[`research-decision-register.schema.json`](../scoping/research-decision-register.schema.json) +validate governance metadata only. Every entry requires +`runtime_dependency_authorized: false`; it cannot authorize a dependency, +experiment, runtime feature, or promotion. + +The existing four-pass order is unchanged. No mathematics, physics, +optimization, inverse modeling, PCA/ICA, policy/control, ARC, Core ML, encoder +architecture, preprocessing, label, budget, or promotion rule changed. + +## Validation + +```text +PYTHONPATH=NeuralComposeEEG/src python3 -m unittest -v \ + Tests/eval/test_local_open_weight_review.py \ + Tests/eval/test_dialectic_corpus_quarantine.py \ + Tests/eval/test_local_dialectic_review.py \ + NeuralComposeEEG.tests.test_pipeline \ + NeuralComposeEEG.tests.test_structured_state +# 66 passed + +swift test --filter CalibrationRecorderTests +# 7 passed + +python3 -m py_compile Scripts/local_open_weight_review.py \ + Scripts/local_review_noninterference.py \ + Scripts/research_decision_register.py \ + Scripts/quarantine_dialectic_corpus.py +# passed + +python3 -m json.tool configs/local-open-weight-review-v0.json +python3 -m json.tool docs/scoping/research-decision-register.schema.json +# passed + +# deterministic validation of internal links in the two changed Markdown files +# passed + +git diff --check +# passed +``` + +The local macOS toolchain was available, so the relevant Swift test ran. The +EEG suite exercised its existing synthetic contract coverage only; no physical +recording, model download, external worker, or local open-weight model ran. + +## Commit Plan + +One implementation commit is appropriate because the R0 validator, R1/R2/R3 +orchestration, metadata-only writer, configuration, noninterference auditor, +decision-register validator, and synthetic contract test form one atomic +fail-closed boundary. A second documentation/governance commit contains the +decision-register JSON schema, quarantine migration note, architecture guide, +and this report. + +## Final Disposition + +```yaml +status: ready_for_local_open_weight_smoke_test +science_status: pipeline_only +decision: insufficient_evidence +promotion_status: not_eligible +live_control: false +``` diff --git a/docs/architecture/local-open-weight-review-cascade.md b/docs/architecture/local-open-weight-review-cascade.md new file mode 100644 index 0000000..6c5e397 --- /dev/null +++ b/docs/architecture/local-open-weight-review-cascade.md @@ -0,0 +1,122 @@ +# Local Open-Weight Review Cascade + +`Scripts/local_open_weight_review.py` is a local-only engineering-review +subsystem. It turns an already quarantined corpus into metadata-only review +receipts. It is not an experiment runner, a scientific adjudicator, a training +pipeline, or an application-control path. + +It preserves the quarantine disposition in +[Dialectic Corpus Quarantine](dialectic-corpus-quarantine.md): review material +is permanently development-only and ineligible for encoder, policy, or science +evaluation. + +## Cascade + +| Stage | Role | Boundary | +| --- | --- | --- | +| R0 | deterministic validator | accepts only complete, citation-valid, non-leaking structured findings | +| R1 | configured local proposer | makes bounded findings for one stateless chunk | +| R2 | configured local reviewer | retries the original chunk with R0 error codes only | +| R3 | configured local Gemma-family critic | manual, advisory adjudication only; never automatic and never overrides R0 | +| R4 | human and evidence gate | required for policy, experiment, scientific, and promotion decisions | + +The tracked [default configuration](../../configs/local-open-weight-review-v0.json) +names local Qwen models as values, not as code-path assumptions: `qwen3:0.6b` +for R1, then `qwen3:4b` and `qwen3:1.7b` for bounded R2 attempts. An operator +may provide a different versioned configuration. R3 is disabled by default and +can only be invoked through the explicit adjudication API with a human request. + +Only the Ollama-compatible loopback HTTP adapter and a deterministic mock +adapter exist today. An MLX adapter is intentionally deferred until the +repository has a stable, local MLX service interface; no new MLX dependency is +introduced by this subsystem. + +## Fail-Closed Contract + +Before any request, the source must have the complete quarantine disposition, +match the SHA-256 in its quarantine report, and remain unchanged for the full +review. The backend must be explicitly local and use a loopback endpoint or a +Unix socket classification. LAN, VPN, container-host, cloud, and arbitrary +remote URLs are rejected. Loopback is only backend-boundary evidence: the +operator separately attests network isolation and confinement of prompt and +response logging to an ignored local directory. + +Chunks have canonical source-line IDs, use a versioned size and overlap, and +mark overlap records. Each invocation receives the same frozen system prompt. +No model has cross-chunk conversation memory. R2 receives the original chunk +and machine-readable R0 error codes, never an earlier raw response. + +R0 accepts only a complete JSON array of known finding types with finite +confidence, nonempty evidence, exact chunk-local citations, and at least one +new-record citation. It does not repair invented citations or accept a partial +response. A bounded five-token source-overlap detector blocks substantial text +reproduction in synthetic tests. This detector is a useful guardrail, not proof +that an artifact contains no private text. + +The metadata-only summary counts accepted findings, source-line references, +confidence buckets, conflicts, and rejection receipts. It receives no raw +records and cannot infer a new semantic claim, create an EEG hypothesis, or +recommend a model or policy change. + +## Local Use + +The dry run builds and validates in-memory chunk envelopes without starting a +model or writing prompts: + +```sh +python3 Scripts/local_open_weight_review.py \ + --source \ + --quarantine-report \ + --configuration configs/local-open-weight-review-v0.json \ + --dry-run +``` + +An operator may run a bounded local smoke test only after verifying the local +runtime's retention settings and choosing an ignored local artifact directory: + +```sh +python3 Scripts/local_open_weight_review.py \ + --source \ + --quarantine-report \ + --configuration configs/local-open-weight-review-v0.json \ + --backend-url http://127.0.0.1:11434 \ + --artifact-directory \ + --attest-network-isolation \ + --attest-prompt-response-logging-confined +``` + +This command is documentation only. It is never executed by repository tests +or CI. The implementation does not download models, create embeddings, update +weights, or retain raw prompts or responses. The manifest records unavailable +backend facts as `null` or `not_reported` rather than inventing provenance. + +## EEG Noninterference + +`Scripts/local_review_noninterference.py` checks metadata-only artifacts for +the required separation: dialogue source and content hashes are absent from EEG +artifacts and model inputs; review findings are absent from the experiment +configuration; EEG window hashes are absent from review prompt metadata; and no +shared training buffer, dialogue embedding, or dialogue-derived weight update +exists. + +The review subsystem and the structured EEG state bridge may share only JSON, +hash, validation, manifest, and disposition patterns. They share no data, +embeddings, labels, model inputs, training buffers, configuration, or scientific +results. The bridge stays `shadow_only: true`, `live_control: false`, and +`promotion_status: not_eligible`. + +## Research Governance + +The JSON schema at +[Research Decision Register](../scoping/research-decision-register.schema.json) +records whether a future method is deferred, study-only, or eligible for a +separately registered experiment. It always requires +`runtime_dependency_authorized: false`; a register entry cannot add a package, +model, runtime route, app feature, or promotion decision. + +The four-pass order in the +[EEG mathematics, physics, and methods scope](../scoping/eeg-mathematics-physics-methods-scope.md) +is unchanged: Pass 1 encoder evidence, Pass 2 registered forward/inverse +foundations, Pass 3 decision-changing methods, then Pass 4 structured-state +shadow policy. The immediate EEG action remains the first frozen physical Muse +capture, not a review-model, policy, or mathematics expansion. diff --git a/docs/operations/three-node-workflow.md b/docs/operations/three-node-workflow.md new file mode 100644 index 0000000..2db4fcf --- /dev/null +++ b/docs/operations/three-node-workflow.md @@ -0,0 +1,201 @@ +# Three-Node Workflow + +NeuralCompose development spans three machines. This document describes +how they connect and what each one owns. + +## Node roles + +| Node | Tailscale IP | Role | +|---|---|---| +| Mac (hunters-macbook-air) | `100.105.8.22` | Primary development, memory, builds | +| Pi 4 8GB (1705bonzos) | `100.81.234.51` | Git continuity, backups, optional review | +| Pixel 8a | `100.94.124.23` | Device validation, local inference | + +## SSH + +All machines connect over Tailscale. No ports exposed to the public +internet. + +**Mac SSH** uses macOS Remote Login (System Settings > Sharing). Connect +via Tailscale IP: + +```bash +ssh aurascoper@100.105.8.22 +``` + +MagicDNS (`hunters-macbook-air.tail48a73f.ts.net`) may not resolve on all +clients. Use the Tailscale IP directly. + +**Pi SSH** uses the standard OpenSSH server. Connect via Tailscale IP: + +```bash +ssh bonzos@100.81.234.51 +``` + +**Pixel SSH** is not always available. Termux SSH server must be started +manually. When running, connect on the configured port (default: 8022): + +```bash +ssh -p 8022 @100.94.124.23 +``` + +## tmux sessions + +### Mac + +``` +nc-fable primary coding agent +nc-apple Apple build/test and Swift PR work +nc-mind optional memory-server inspection +``` + +### Pi + +``` +nc-fallback fallback coding agent +nc-postgres database administration/log view +nc-monitor health and backup monitoring +``` + +### Pixel + +``` +nc-qwen llama-server +nc-embed embedding server +nc-stt whisper/STT wrapper +nc-metro Expo/Metro +``` + +## Git continuity + +The Pi hosts a bare Git remote at `~/git/neuralcompose-client.git` for +the Android client repository. This is the commit-level recovery point +that survives Pixel reboots. + +### Setup (one-time) + +On the Pi: + +```bash +mkdir -p ~/git +git init --bare ~/git/neuralcompose-client.git +``` + +On the Pixel (or Mac, if the repo is cloned there): + +```bash +git remote add pi ssh://bonzos@100.81.234.51/home/bonzos/git/neuralcompose-client.git +git push -u pi +``` + +### Workflow + +**Code-only phase** (Mac agent active, Pixel as terminal): + +```bash +# On the Pixel: +./scripts/termux/stop-neuralcompose-services.sh --runtime + +# On the Mac: +ssh aurascoper@100.105.8.22 'tmux new -As nc-fable' +``` + +**Device-validation phase** (Pixel running services): + +```bash +# Push from Mac: +git push pi + +# On the Pixel: +git fetch pi +git switch +git pull --ff-only pi +./scripts/termux/start-neuralcompose-services.sh --runtime +./scripts/termux/healthcheck-neuralcompose-services.sh + +# Run physical test, sanitize evidence, commit, then: +git push pi + +# Clean up: +./scripts/termux/stop-neuralcompose-services.sh --runtime +``` + +**Recovery after Pixel reboot:** + +```bash +# On the Pixel: +git fetch pi +git switch +git pull --ff-only pi +``` + +### Branch ownership + +One active writer per branch. Use separate branches for independent +reviewers. Do not push to a branch another agent is actively editing. + +### Files excluded from the Pi remote + +The following must not be pushed to the Pi remote: + +- `*.gguf` and other model weight files +- Whisper/model binaries +- Raw audio recordings +- Private transcripts and Journal databases +- Runtime logs under `.runtime/` +- PID files +- `.env` files and credentials +- Unsanitized evidence +- Metro/Expo caches + +Provenance manifests and sanitized acceptance artifacts are committable. + +## Pixel service management + +Scripts live in `scripts/termux/` on the Android client repository. + +```bash +# Start runtime services (Qwen + embeddings + STT): +./scripts/termux/start-neuralcompose-services.sh --runtime + +# Start Metro only (UI development): +./scripts/termux/start-neuralcompose-services.sh --dev + +# Stop runtime services: +./scripts/termux/stop-neuralcompose-services.sh --runtime + +# Stop everything including Metro: +./scripts/termux/stop-neuralcompose-services.sh --all + +# Check status: +./scripts/termux/status-neuralcompose-services.sh + +# Health check: +./scripts/termux/healthcheck-neuralcompose-services.sh + +# Memory snapshot: +./scripts/termux/snapshot-memory.sh +``` + +Configuration goes in `.runtime/neuralcompose/neuralcompose-services.env`. +Copy from `scripts/termux/neuralcompose-services.env.example` and edit. + +## Pi agent limitation + +The Pi 4 8GB can run Claude Code (ARM64 Linux, 4GB+ RAM required). It is +not a guaranteed second simultaneous agent until tested for: + +- Agent authentication and operation +- Cloud provider concurrent session limits +- Memory stability during repository indexing and tests + +Until then, treat the Pi as Git continuity, backups, monitoring, and +optional review host only. + +## Deferred + +- claude-mind-mcp over SSH bridge +- Postgres/pgvector on the Pi +- Independent Linux memory service +- Sequential Thinking MCP +- Automated failover from Mac to Pi agent diff --git a/docs/research/README.md b/docs/research/README.md index 2b3889a..b3145ac 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -36,3 +36,6 @@ Design/Verification/Runtime artifact taxonomy — it contains the | [embedding-model-survey.md](embedding-model-survey.md) | 3.0 | Survey (this commit) | | [methodology-review_v1.md](methodology-review_v1.md) | 3.4/3.5 | Review (superseded by v2) | | [methodology-review_v2.md](methodology-review_v2.md) | 3.4/3.5 | Review (this commit) | +| [svd-four-channel-eeg-decision-memo_v1.md](svd-four-channel-eeg-decision-memo_v1.md) | EEG D0 | Foundational study only | +| [jepa-four-channel-eeg-decision-memo-v0.md](jepa-four-channel-eeg-decision-memo-v0.md) | EEG D0 | Scope complete; foundational Pass 1.5 study only | +| [function-space-foundations-decision-memo-v0.md](function-space-foundations-decision-memo-v0.md) | Mathematical D0 | Synthetic foundational study only | diff --git a/docs/research/function-space-foundations-decision-memo-v0.md b/docs/research/function-space-foundations-decision-memo-v0.md new file mode 100644 index 0000000..2efa9e8 --- /dev/null +++ b/docs/research/function-space-foundations-decision-memo-v0.md @@ -0,0 +1,279 @@ +# Function-Space Foundations Decision Memo v0 + +**Status:** D0 foundational study only +**Experiment:** `EXP-FUNC-SYN-000` +**Decision:** `insufficient_evidence` +**Promotion status:** `not_eligible` +**Runtime change:** none + +## Decision + +NeuralCompose will treat curve fitting, integration, differentiation, and +representation learning as operators between function or representation +spaces, while preserving the different stability and evidence questions each +operator raises. + +The shared conceptual objective is: + +$$ +J(\theta) += +\int_T |y(t)-f_\theta(t)|^2\,d\mu(t) ++ +\lambda\int_T |D f_\theta(t)|^2\,dt ++ +\gamma\Phi(E_\theta). +$$ + +This equation organizes questions; it does not authorize one combined +algorithm: + +- $\mu$ determines how observations are weighted. +- $D$ may be a classical or weak time derivative. +- automatic differentiation computes parameter derivatives of the + implemented finite model. +- $\Phi(E_\theta)$ belongs to the existing JEPA representation and collapse + contracts, not to a new trainer in this package. + +`EXP-FUNC-SYN-000` demonstrates these distinctions with deterministic +synthetic fixtures. It consumes no EEG, dialogue, telemetry, fallback data, or +model output and cannot establish a physical-data method. + +## Empirical Measures And Identifiability + +For sampled observations $(t_i,y_i)$, define: + +$$ +\mu_n=\frac1n\sum_{i=1}^n\delta_{t_i}. +$$ + +Then empirical least squares is an integral: + +$$ +\int |y-f_\theta|^2\,d\mu_n += +\frac1n\sum_i |y_i-f_\theta(t_i)|^2. +$$ + +The induced empirical pseudometric, + +$$ +d_{\mu_n}(f,g) += +\left(\int |f-g|^2\,d\mu_n\right)^{1/2}, +$$ + +cannot distinguish functions that agree at every sampled point but differ +between samples. The quotient by equality $\mu_n$-almost everywhere is the +space visible to the empirical loss. A smooth interpolant therefore is not +recovered continuous physiology. + +Dirac masses represent atomic observations or events. They do not represent +inertia. `EXP-FUNC-SYN-000` uses them only as mathematical weights. + +## Measurability And Integration + +Borel measurability is background that makes continuous encoders and losses +legitimate random variables or integrands. It does not require a runtime +"Borel subsystem." + +Lebesgue integration is the main language for expected loss, almost-everywhere +equivalence, noisy signals, and $L^p$ spaces. Continuous and atomic components +are represented together by: + +$$ +d\mu(t)=\rho(t)\,dt+\sum_k w_k\delta_{t_k}, +$$ + +so: + +$$ +\int f\,d\mu += +\int f(t)\rho(t)\,dt+\sum_k w_k f(t_k). +$$ + +The implementation calls this a Lebesgue-Stieltjes mixed measure. It avoids +depending on unstated endpoint conventions for Riemann-Stieltjes jumps. + +## Three Derivative Questions + +These operations must not be conflated: + +```text +signal/time derivative + change of a function with respect to time + +weak derivative + derivative defined by integration against test functions + +numerical differentiation + estimate of a signal derivative from sampled values + +automatic differentiation + exact chain-rule derivative of implemented operations with respect + to model inputs or parameters +``` + +Differentiation is unstable under weak norms. For example: + +$$ +f_n(t)=\frac{\sin(nt)}n +\quad\Longrightarrow\quad +\|f_n\|_\infty\to0, +\qquad +\|f_n'\|_\infty=1. +$$ + +A fixed-step difference operator is bounded, but its norm grows approximately +as $1/h$. Decreasing the step can therefore amplify high-frequency +perturbations even while clean discretization error falls. + +ForwardDiff verifies derivatives of the finite parametric computation. It +does not stabilize or recover the time derivative of noisy sampled data. + +## Sobolev And Tikhonov Fitting + +For a finite basis, + +$$ +f_c(t)=\sum_{j=1}^m c_j\phi_j(t), +$$ + +the synthetic package solves: + +$$ +J_\lambda(c) += +\frac1n\sum_i |y_i-f_c(t_i)|^2 ++ +\lambda\int_a^b |f_c'(t)|^2\,dt. +$$ + +With a fixed Fourier basis and analytic derivative matrix, this is +deterministic penalized least squares. Regularization is selected using +training and inner-validation synthetic sessions only; held-out sessions are +untouched until evaluation. ForwardDiff verifies the analytic parameter +gradient but is not the primary solver. + +Passing this fixture means only that the finite implementation behaves as +registered. A physical EEG comparison would require a separate experiment and +must not silently change preprocessing. + +## Bounded Operators And Cauchy-Schwarz + +For a bounded linear operator $T$: + +$$ +\|Tx\|\le\|T\|\|x\|. +$$ + +Composition satisfies $\|ST\|\le\|S\|\|T\|$. Cauchy-Schwarz similarly bounds +inner products: + +$$ +|\langle f,g\rangle|\le\|f\|_2\|g\|_2. +$$ + +The D0 fixture measures induced L2 matrix norms for discrete derivative, +integration, and identity operators, then checks observed perturbation +amplification against those bounds. Undefined or infinite condition numbers +are recorded as such rather than replaced by finite values. + +## Convergence And Approximation Cautions + +### Egorov + +The canonical fixture is $f_n(x)=x^n$ on $[0,1]$. It converges almost +everywhere to zero except at $x=1$, but not uniformly on the full interval. +After removing: + +$$ +A_\varepsilon=[1-\varepsilon,1], +$$ + +the remaining uniform error is: + +$$ +\sup_{x\in[0,1-\varepsilon]}x^n=(1-\varepsilon)^n. +$$ + +This is a theorem illustration. It is not an early-stopping rule, anomaly +filter, exceptional-window remover, or physical threshold. + +### Riesz + +Riesz's lemma warns that finite-dimensional approximation success does not +show that an infinite-dimensional unit ball has been exhausted. A compact +feature set may approximate observed samples without capturing every possible +function-space direction. The omitted directions are not automatically +physiological. + +## Deferred Material + +Hamiltonian and symplectic dynamics remain Pass 4 possibilities for a +separately justified synthetic latent-dynamics or control experiment. There is +no current evidence that four-channel EEG or NeuralCompose dialogue follows a +conserved Hamiltonian. + +The source notation $R_0(f;(a,b))$ and the proposition that it is open remain +unresolved. The available material does not establish the definition, and no +definition, theorem connection, or implementation consequence is inferred. + +## Brown-Pearcy Study Checklist + +No proposition numbers are asserted. + +### Normed Linear Spaces + +- norm and induced metric; +- Cauchy sequences, completeness, and Banach spaces; +- finite-dimensional norm equivalence and closed subspaces; +- quotient spaces and quotient norms; +- $C[a,b]$, $\ell^p$, and $L^p$; +- distance to a closed subspace; +- Riesz's lemma; +- noncompactness of infinite-dimensional unit balls. + +### Bounded Linear Transformations + +- linear continuity if and only if boundedness; +- continuity at zero; +- operator norm and $\|Tx\|\le\|T\|\|x\|$; +- composition and $\|ST\|\le\|S\|\|T\|$; +- completeness of $\mathcal B(X,Y)$ when $Y$ is Banach; +- closed kernels and extension to completions; +- induced maps through $X/\ker T$; +- lower bounds and stable inversion; +- transition to open-mapping and bounded-inverse results. + +These are mathematical foundations, not runtime requirements. + +## Source Provenance + +All sources below are background evidence only. They do not define acceptance +criteria for `EXP-FUNC-SYN-000`. + +| Source | Version or page | Retrieved | Claim scope | +| --- | --- | --- | --- | +| [Encyclopedia of Mathematics: Delta-function](https://encyclopediaofmath.org/wiki/Dirac_delta-function) | web reference | 2026-07-24 | background evidence only | +| [MIT 18.125 Measure and Integration](https://ocw.mit.edu/courses/18-125-measure-and-integration-fall-2003/pages/lecture-notes/) | Fall 2003 notes | 2026-07-24 | background evidence only | +| [MIT 18.125 Egorov lecture](https://ocw.mit.edu/courses/18-125-measure-and-integration-fall-2003/resources/18125_lec13/) | Lecture 13 | 2026-07-24 | background evidence only | +| [ForwardDiff documentation](https://juliadiff.org/ForwardDiff.jl/) | package documentation; project pins 1.4.1 | 2026-07-24 | background evidence only | +| [Brown and Pearcy, Introduction to Operator Theory I](https://link.springer.com/book/10.1007/978-1-4612-9926-4) | public book metadata | 2026-07-24 | background evidence only | +| [MIT 18.102 Introduction to Functional Analysis](https://www.ocw.mit.edu/courses/18-102-introduction-to-functional-analysis-spring-2009/pages/lecture-notes/) | Spring 2009 notes | 2026-07-24 | background evidence only | + +## Fixed Disposition + +```yaml +experiment_id: EXP-FUNC-SYN-000 +status: foundational_study_only +data_gate: D0 +decision: insufficient_evidence +promotion_status: not_eligible +runtime_change: none +physical_eeg_used: false +dialogue_logs_used: false +fallback_capture_used: false +scientific_claim_allowed: false +``` diff --git a/docs/research/jepa-four-channel-eeg-decision-memo-v0.md b/docs/research/jepa-four-channel-eeg-decision-memo-v0.md new file mode 100644 index 0000000..14cf06c --- /dev/null +++ b/docs/research/jepa-four-channel-eeg-decision-memo-v0.md @@ -0,0 +1,373 @@ +# JEPA for Four-Channel EEG: D0 Decision Memo + +```yaml +status: scope_complete +track: Pass_1.5_non_runtime +data_gate: D0 +decision: foundational_study_only +promotion_status: not_eligible +live_control: false +modify_EXP_NC_EEG_ENC_001: false +implement_live_JEPA: false +download_or_train_Laya_now: false +``` + +## Decision + +Joint-embedding predictive learning is a credible future encoder-objective +comparison for NeuralCompose, but it is not part of the current first-capture +experiment. The proposed work is registered separately as +[`EXP-NC-EEG-JEPA-001`](../../NeuralComposeEEG/experiments/EXP-NC-EEG-JEPA-001.md), +a Pass 1.5 study that asks whether masked latent prediction yields +non-collapsed, transferable four-channel EEG representations under the same +complete-session evaluation discipline as the existing encoder benchmark. + +At D0 this memo authorizes only research scoping, deterministic validation of +the diagnostic contract, and review of synthetic collapse fixtures. It does +not authorize model training, checkpoint download, new data collection, +preprocessing changes, application inference, Core ML conversion, or dialogue +control. + +The next physical action remains one frozen `encoder-pilot-v1` Muse capture, +followed only by integrity, window, and deterministic replay validation. + +## Function-space foundational provenance + +[`EXP-FUNC-SYN-000`](function-space-foundations-decision-memo-v0.md) is a +synthetic mathematical and implementation reference for this track. Its +canonical report and contract are pinned in the +[JEPA diagnostics contract](../scoping/jepa-collapse-diagnostics-v0.json). It +does not supply a collapse threshold, physical-data result, runtime +dependency, or promotion argument. + +Its uses are deliberately narrow: + +- FS0 supplies the empirical-pseudometric vocabulary for describing when an + encoder maps distinct EEG windows to indistinguishable latent points. It + does not prove that a particular encoder has learned such an equivalence. +- FS4 supplies operator-stability language for singular spectra, effective + rank, condition numbers, and adapter perturbation analysis. JEPA still needs + its registered grouped folds and controls. +- FS2 is only a possible reference for a future, separately registered + smoothness objective. No Sobolev term is added to J0-J6 by this memo. +- FS3 motivates reporting small collapse-prone exceptional subsets as an + explanatory analogy. Egorov's theorem is never a threshold, epoch-selection + rule, stopping rule, or permission to remove difficult windows. + +The foundational package therefore strengthens definitions and numerical +fixtures while leaving all JEPA losses, gates, thresholds, and runtime +boundaries unchanged. + +## The Precise Architectural Claim + +NeuralCompose already uses staged lossy abstraction: + +```text +raw four-channel EEG + -> deterministic signal features or an encoder + -> a compact representation + -> a downstream classifier or visualization +``` + +That is not itself a JEPA. A joint-embedding predictive architecture requires +a context representation, a target representation, a predictor trained in +latent space, and an explicit mechanism that prevents representational +collapse. A more accurate statement is: + +> NeuralCompose already relies on staged lossy abstraction. A JEPA condition +> would test whether explicitly predictive latent learning can replace one +> part of that hand-designed compression without changing acquisition, +> observable labels, grouped evaluation, or rendering protocols. + +The protocol boundary is valuable engineering evidence: a renderer can consume +a vector without knowing whether it came from a deterministic or learned +encoder. It is not scientific evidence that a learned representation is +meaningful. + +## Encoder JEPA Versus World Model + +This proposal is an encoder experiment, not an action-conditioned world model. + +```text +Pass 1 + M0 deterministic features + M1 EEGNet + M2 random EEGPT control + M3 frozen EEGPT + M4 frozen BENDR encoder + +Pass 1.5 + matched reconstruction and JEPA objectives + frozen downstream probes + complete-session evaluation + +Pass 4 + state + action + next state + cost + legal interventions + world-model or MPC experiment +``` + +The existing +[`ADR-006`](../architecture/decision-log/ADR-006-jepa-transition-capture.md) +governs a separate, opt-in action-transition corpus for the synthetic +world-model research path. It does not make that corpus eligible for this +encoder study, and this study does not authorize MPC, latent anchoring, or +automatic generation adaptation. + +## What the Current Evidence Supports + +Laya is a March 2026 preprint, revised in May 2026, that adapts LeJEPA to EEG. +Its reported architecture uses independent temporal patch embedding, a +coordinate-aware dynamic channel mixer, masked latent prediction, +stop-gradient targets, and SIGReg geometric regularization. Its pretraining +corpus contains 913,314 samples, 29,109 hours, 20,940 subjects, and 17 channel +topologies. Those scale and montage conditions are materially different from +short local recordings at `TP9`, `AF7`, `AF8`, and `TP10`. + +The reported results are promising but mixed. Laya has the strongest mean +clinical balanced accuracy in its frozen linear-probe table and favorable +noise-ablation results, while other models remain stronger on several +individual BCI, artifact, epilepsy, OCD, schizophrenia, and sleep tasks. +These results support a hardware-specific comparison; they do not establish +that latent prediction is universally superior or that the result transfers +to a four-channel Muse. + +Reconstruction and latent prediction are also not a settled binary choice. +STST-JEPA combines masked latent prediction with an auxiliary raw-signal +reconstruction term. A bounded hybrid condition is therefore scientifically +appropriate, provided it is matched to the other conditions and preregistered. + +The papers are background evidence only. Their reported results do not define +an acceptance threshold or bind the experiment to a paper-specific +architecture. + +```json +{ + "paper_provenance": [ + { + "paper_title": "Laya: A LeJEPA Approach to EEG via Latent Prediction over Reconstruction", + "version": "v2", + "retrieved_date": "2026-07-23", + "source_url": "https://arxiv.org/abs/2603.16281v2", + "claim_scope": "background evidence only" + }, + { + "paper_title": "STST-JEPA: Shallow-Target Spatio-Temporal Joint Embedding Prediction Architecture For EEG Self-Supervised Learning", + "version": "v1", + "retrieved_date": "2026-07-23", + "source_url": "https://arxiv.org/abs/2607.06629v1", + "claim_scope": "background evidence only" + } + ] +} +``` + +## Collapse Is Measured Directly + +Raw-signal reconstruction is not the primary collapse detector. A model may +reconstruct well while learning an inconvenient downstream representation, +and reconstruction error alone cannot establish feature diversity or +transferability. + +For every trainable representation condition, the proposed experiment records +direct collapse diagnostics at every epoch on training and inner-validation +groups only: + +- per-dimension embedding variance; +- covariance off-diagonal magnitude; +- the complete singular-value spectrum; +- entropy effective rank and a pinned energy-rank summary; +- condition number only under a recorded numerical floor; +- mean pairwise embedding distance; +- nearest-neighbor session-identity rate; +- constant-output and feature-utilization checks; +- predictor and regularization losses; and +- a frozen-protocol linear probe. + +The machine-readable definitions live in +[`jepa-collapse-diagnostics-v0.json`](../scoping/jepa-collapse-diagnostics-v0.json). +No single geometry metric is an acceptance criterion. Representation +diagnostics support an interpretation only when session-held-out predictive, +calibration, artifact, and robustness evidence also survives. + +The anti-collapse term must be attached to the named encoder or projector +representation. Applying it only to predictor outputs does not establish that +the encoder itself avoided collapse. After model selection is frozen, the same +diagnostic bundle is computed once on the outer held-out group; those values +cannot choose an epoch, threshold, or condition. + +## Proposed Conditions + +| ID | Condition | Purpose | +| --- | --- | --- | +| J0 | unchanged EEGNet reference | retain the ordinary supervised reference | +| J1 | matched encoder with masked raw-signal reconstruction | reconstruction baseline | +| J2 | matched encoder with latent prediction and no SIGReg/VICReg term | collapse-pressure ablation | +| J3 | matched encoder with latent prediction plus SIGReg | LeJEPA-style regularization condition | +| J4 | matched encoder with latent prediction plus VICReg-style variance/covariance regularization | alternative anti-collapse condition | +| J5 | latent prediction plus a bounded reconstruction auxiliary | hybrid objective | +| J6 | frozen Laya-compatible transfer | optional only with an official checkpoint and reproducible implementation | + +J1 through J5 must match encoder capacity, parameter-count tolerance, +optimization steps, batch exposure, train partitions, mask schedule, and +downstream probe budget wherever the objective itself does not require a +difference. J6 is a transfer condition, not part of the matched-objective +causal comparison. + +## Required Controls + +The experiment retains: + +- temporal-target permutation; +- context and target drawn from different sessions; +- channel-coordinate shuffle; +- channel-order shuffle; +- mask-location shuffle; +- the no-regularization condition J2; +- a constant-embedding detector; +- a seeded random encoder; and +- the matched reconstruction condition J1. + +The different-session target is a particularly important negative control. If +it performs similarly to the correctly paired condition, the model may be +exploiting session or headset identity rather than temporal structure. + +## Four-Channel and Data-Volume Gate + +A model trained on only a few short Muse sessions can easily learn participant, +session, headset-fit, impedance, persistent muscle, device-noise, block-timing, +or stimulus signatures. Good training loss or a visually smooth embedding +does not distinguish those shortcuts from transferable EEG structure. + +Two defensible pretraining paths may be preregistered later: + +```text +licensed external public EEG + -> self-supervised pretraining + -> explicit four-channel Muse adapter + -> complete-session local probing +``` + +or: + +```text +substantial unlabeled local Muse corpus + -> build outer complete-session folds first + -> fit the encoder only on each fold's training sessions + -> probe the untouched held-out sessions +``` + +Label hiding is not data independence. Pretraining once on every local session +and later withholding only the labels of a test session is leakage for this +question. + +D0 permits contracts, synthetic fixtures, and validator tests only. D1 permits +optional read-only pipeline and collapse-metric smoke tests, with no JEPA +training. At D2, a separately preregistered tiny run may establish pipeline +execution only; it cannot support confirmation, selection, or promotion. The +first defensible session-grouped JEPA comparison remains D3 and additionally +requires a later amendment that pins: + +- the pretraining corpus, license, checksums, and participant/session overlap + audit; +- minimum independent participant, day, session, and headset-fit support; +- context/target duration and masking distributions; +- architecture and parameter-count matching tolerances; +- optimization and probe budgets; +- collapse thresholds and their training-only selection rules; and +- the complete grouped split manifest. + +Reaching D2 or D3 does not waive any of these requirements. Post-encoder +latent transition, world-model, or MPC work remains a separate +action-conditioned experiment. + +## Evaluation Outcomes + +The scientific endpoint remains held-out observable-state performance, not +latent appearance: + +- balanced accuracy; +- macro F1; +- Brier score; +- expected calibration error; +- artifact sensitivity and specificity; +- cross-session degradation; +- data-efficiency curves; and +- robustness to channel dropout, EMG-like noise, and movement artifacts. + +All conditions use identical complete-session outer folds. Participant and +recording-date grouping supersede session grouping when the cohort supports +the stricter unit. Every scaler, encoder fit, adapter, probe, class weight, +threshold, and model-selection decision is learned from the training +partition only. + +## Falsification and Interpretation + +Reject or suspend the latent-prediction hypothesis when any of the following +holds: + +- J3, J4, and J5 do not improve preregistered held-out outcomes over both J0 + and the matched reconstruction condition J1; +- apparent gains disappear under complete-session or stricter grouped splits; +- the correct temporal pairing does not beat different-session or permuted + targets; +- results are driven by session identity, headset fit, one model, one day, or + one participant; +- non-collapse diagnostics disagree materially across folds or survive only + under a post-hoc threshold; +- downstream gains disappear under channel, mask, noise, or data-efficiency + controls; or +- a simpler supervised or reconstruction baseline matches the result at lower + compute and complexity. + +A supported result means only that a predictive latent objective merits +further encoder science for this hardware and protocol. It does not validate +a cognitive state, a world model, or a production representation. + +Every result remains: + +```yaml +status: insufficient_evidence +promotion_status: not_eligible +runtime_change: none +``` + +until a separate confirmation and promotion review explicitly changes those +fields. + +## Three-Dimensional Workspace Boundary + +A JEPA embedding is not naturally three-dimensional. Any 3D display must use +one of: + +- a fixed random projection; +- a PCA projection fit within the training partition; or +- a separately frozen, provenance-bound projection artifact. + +A projection may never be fit using held-out sessions. Visual coherence, +clusters, trajectories, or attractive geometry are exploratory observations, +not encoder-selection metrics or evidence of physiological meaning. + +## D0 Deliverables + +D0 is complete when: + +1. the proposed experiment and diagnostic contract are internally consistent; +2. links and JSON structure validate deterministically; +3. no current M0-M4 condition, acquisition contract, preprocessing path, + runtime, dependency, or experiment budget changes; and +4. the roadmap keeps the first physical `encoder-pilot-v1` capture as the next + acquisition action. + +No Laya checkpoint or training corpus is downloaded under this decision. + +## Primary References + +- Panchavati et al., [Laya: A LeJEPA Approach to EEG via Latent Prediction + over Reconstruction](https://arxiv.org/abs/2603.16281), arXiv:2603.16281v2, + 2026. +- Segal et al., [STST-JEPA: Shallow-Target Spatio-Temporal Joint Embedding + Prediction Architecture for EEG Self-Supervised + Learning](https://arxiv.org/abs/2607.06629), arXiv:2607.06629v1, 2026. +- Bardes et al., [VICReg: Variance-Invariance-Covariance Regularization for + Self-Supervised Learning](https://arxiv.org/abs/2105.04906), + arXiv:2105.04906, 2021. diff --git a/docs/research/svd-four-channel-eeg-decision-memo_v1.md b/docs/research/svd-four-channel-eeg-decision-memo_v1.md new file mode 100644 index 0000000..a2cb31a --- /dev/null +++ b/docs/research/svd-four-channel-eeg-decision-memo_v1.md @@ -0,0 +1,628 @@ +# SVD for Four-Channel NeuralCompose EEG: Decision Memo v1 + +**Date:** 2026-07-23 +**Status:** foundational study only +**Applies to:** proposed SVD studies adjacent to, but not part of, +[`EXP-NC-EEG-ENC-001`](../../NeuralComposeEEG/experiments/EXP-NC-EEG-ENC-001.md) + +## Executive decision + +Singular value decomposition (SVD) is justified in the NeuralCompose EEG +program as a numerical diagnostic and as a *controlled, train-only* method to +test a small number of explicitly different representations. It is not +justified as an automatic preprocessing stage, a replacement for direct signal +quality checks, a source-localization method, or a live/runtime dependency. + +The immediate program remains at: + +```yaml +status: pipeline_ready_for_first_capture +data_gate: D0 +decision: insufficient_evidence +promotion_status: not_eligible +live_control: false +``` + +There are no eligible physical recordings at D0. Therefore this memo changes +no acquisition, labels, preprocessing, M0-M4 budget, or model architecture. +It creates four proposed studies only: + +| Proposal | Earliest gate | Decision it can change | Current disposition | +| --- | --- | --- | --- | +| `EXP-NC-SVD-DIAG-001` | D1 for physical reporting; D0 for synthetic fixtures | whether singular-spectrum reports add capture/feature diagnosis beyond direct checks | eligible to preregister, not yet run | +| `EXP-NC-SVD-M0-001` | D2 | whether a train-only PCA/truncated-SVD feature path merits a separate M0 sensitivity result | deferred until an eligible grouped cohort exists | +| `EXP-NC-SVD-REP-001` | D3 | whether representation effective-rank diagnostics are stable across held-out sessions and controls | deferred | +| `EXP-NC-SVD-INV-001` | D3 and a separate forward-model hypothesis | whether a synthetic inverse problem benefits from a specified regularizer | deferred Pass 2 | + +The primary conclusion is: + +```yaml +status: foundational_study_only +``` + +In particular, `EXP-NC-EEG-ENC-001` remains the unchanged four-channel, +observable-label, complete-session benchmark. A low-rank plot is not encoder +generalization, and an SVD result cannot alter the application. + +## Function-space foundational provenance + +[`EXP-FUNC-SYN-000`](function-space-foundations-decision-memo-v0.md) is a +synthetic mathematical and implementation reference for this track. Its +canonical report and contract are pinned in the +[SVD decision register](../scoping/svd-decision-register-v0.json). It supplies +no physical-data threshold, does not authorize a runtime dependency, and does +not change any gate or disposition in this memo. + +The relevant connection is FS4, which demonstrates finite-dimensional +operator norms and perturbation bounds under pinned synthetic fixtures. This +supports the language used here for: + +- operator norms and measured perturbation amplification; +- condition numbers and honest rank-deficient reporting; +- pseudoinverse instability along weak singular directions; +- retained-rank sensitivity rather than a post-hoc rank threshold; and +- controlled truncated-SVD versus ridge/Tikhonov comparisons. + +FS0 additionally illustrates that finite observations define an empirical +pseudometric: equality on sampled points does not imply equality off-grid. +That is a warning about observational identifiability, not a license to infer +unmeasured physiology. FS2 is a deterministic regularization reference only; +it does not select an SVD rank, ridge penalty, physical threshold, or EEG +preprocessing rule. + +The provenance relationship is one-way: + +```text +EXP-FUNC-SYN-000 synthetic fixtures + -> mathematical and implementation vocabulary + -> SVD experiment contracts retain their own physical-data gates +``` + +It does not establish physical EEG stability, justify source localization, or +promote any SVD method into the application. + +## Program boundary and data gates + +This memo uses the following study-local meanings. They name evidence gates; +they do not authorize a change to the acquisition contract. + +| Gate | Evidence available | Permitted SVD work | +| --- | --- | --- | +| D0 | frozen code/configuration and synthetic fixtures only | unit fixtures, numerical reconstruction checks, documentation, and benchmark design | +| D1 | one protocol-complete capture that passes integrity validation | read-only, per-capture diagnostic reports; no encoder training or physical-data model claim | +| D2 | multi-day, source-manifest-eligible pilot cohort | session-grouped, non-promotable controlled evaluations | +| D3 | confirmation-ready multi-session evidence sufficient for a separately registered follow-up | descriptive representation studies and a separately specified synthetic forward/inverse study | +| post_encoder | an encoder selected offline and exposed through a frozen structured-state contract | offline compression or computational tooling review only | + +All activity remains subject to the source and evaluation contracts in +[`NeuralComposeEEG/README.md`](../../NeuralComposeEEG/README.md), +[`PROTOCOL.md`](../../NeuralComposeEEG/PROTOCOL.md), and +[`ADAPTER_CONTRACT.md`](../../NeuralComposeEEG/ADAPTER_CONTRACT.md). In +particular, `TP9`, `AF7`, `AF8`, and `TP10` at 256 Hz, four-second/1,024-sample +windows, one-second stride, calibration-only normalization, and complete +recording-session splits remain fixed. + +## Mathematical primer tied to NeuralCompose matrices + +### Core factorization + +For a real or complex matrix `A` in `K^(m x n)`, where `K` is `R` or `C`, its +reduced SVD is + +```text +A = U Sigma V^H +``` + +where `k = min(m, n)`, `U` has orthonormal columns, `V` has orthonormal +columns, `V^H` is the transpose for real matrices and conjugate transpose for +complex matrices, and + +```text +sigma_1 >= sigma_2 >= ... >= sigma_k >= 0. +``` + +`rank(A)` is exactly the number of nonzero singular values in exact +arithmetic. `U` describes modes in the row-side space of `A`; `V` describes +modes in its column-side space. The singular values quantify the strength of +the paired modes. Singular-vector signs, and rotations inside a repeated +singular-value subspace, are not unique. A report may compare subspaces or +sign-invariant quantities, but must not treat the sign of one vector as a +scientific result. + +For the current raw spatial window, use a channel-centered matrix + +```text +X_window in R^(4 x 1024). +``` + +Its left singular vectors are four-channel spatial mixtures and its right +singular vectors are time-domain patterns *for that one window*. This does not +make `U` a source map, and it does not make `V` an oscillator model. + +The spectral norm is `||A||_2 = sigma_1`. The Frobenius norm is + +```text +||A||_F = sqrt(sum_i sigma_i^2). +``` + +For a full-column/full-row-rank rectangular matrix, the 2-norm condition +number is `kappa_2(A) = sigma_1 / sigma_k`; it is infinite when `A` is rank +deficient. These are exact definitions. Calling a finite singular value +"effectively zero" is a numerical decision, not an exact statement. + +### Low rank, pseudoinverse, and stability + +The rank-`r` truncated reconstruction is + +```text +A_r = U[:, :r] Sigma[:r, :r] V[:, :r]^H. +``` + +The Eckart-Young-Mirsky theorem exactly states that `A_r` is a best rank-`r` +approximation under both the spectral and Frobenius norms. Its errors are + +```text +||A - A_r||_2 = sigma_(r+1) +||A - A_r||_F^2 = sum_(i > r) sigma_i^2. +``` + +The theorem does *not* state that the retained components are physiological, +that a selected rank generalizes, or that a threshold represents signal rather +than artifact. + +The Moore-Penrose pseudoinverse is + +```text +A^+ = V diag(1 / sigma_i for sigma_i > 0) U^H. +``` + +It gives the minimum-norm least-squares solution `x = A^+ b`. Small singular +values make `1 / sigma_i` large, so a naively computed pseudoinverse can +amplify noise. That fact is exact. Whether a small value represents noise, a +redundant feature, a bad channel, or a real but weak mode depends on the +matrix construction and noise model. + +For an additive perturbation `E`, singular values obey the exact Weyl bound + +`|sigma_i(A + E) - sigma_i(A)| <= ||E||_2`. Singular *subspaces* can be much +less stable when neighboring singular values have a small gap. Wedin-style +subspace bounds formalize this dependency. Consequently, the program must +report spectra, gaps, precision, and threshold sensitivity rather than publish +one unstable singular vector as an interpretation. + +### SVD, eigendecomposition, PCA, and whitening + +For a centered data matrix `Z` with rows as observations, + +```text +Z^T Z = V Sigma^2 V^T. +``` + +Thus the right singular vectors are covariance eigenvectors, after the +appropriate `1/(n-1)` scaling. Computing SVD of `Z` avoids explicitly forming +`Z^T Z`, which squares its condition number. For a symmetric covariance or +cross-spectral density matrix, a Hermitian eigendecomposition (`eigh`) is +usually the more direct operation. On a positive semidefinite covariance +matrix, singular values and eigenvalues coincide; using SVD there is not a new +method. + +PCA projects onto leading right singular vectors. Whitening additionally +divides retained component scores by a singular-value-derived scale. Whitening +therefore magnifies low-variance directions unless an explicit floor is used. +PCA/whitening parameters are learned transformations: every mean, scale, +component, retained rank, and floor must be fit inside the training partition +only. A held-out session may only be transformed with the matching fold's +stored parameters. + +### Regularization as singular-value filtering + +For a linear inverse or least-squares problem `Ax approx b`, truncated SVD +uses filter factors + +```text +f_i = 1 / sigma_i for i <= r +f_i = 0 for i > r. +``` + +Tikhonov regularization with identity penalty solves + +`min_x ||Ax - b||_2^2 + lambda ||x||_2^2` and uses smooth filters + +```text +f_i(lambda) = sigma_i / (sigma_i^2 + lambda). +``` + +Both reduce amplification of weak directions, but they encode different bias +assumptions. The useful rank or `lambda` must be chosen from training/synthetic +evidence specified before test evaluation. Neither belongs in the current +multiclass logistic M0 merely because the formulas exist: logistic regression +does not solve the ordinary least-squares system `Ax = b`. + +### Randomized, incremental, block, weighted, and generalized variants + +Randomized SVD approximates a target subspace using random test vectors and +power iterations. It is an algorithmic approximation, not a different +scientific representation. It is only interesting when a full decomposition +is a profiled bottleneck on a large matrix. The `4 x 1024` spatial window and +the current roughly 52-column M0 feature matrix are too small to justify it. +If later used, seed, oversampling, power iterations, normalizer, residual +error, and exact rank must be part of the artifact. + +Incremental/streaming SVD trades exact batch results for update rules and +forgetting/ordering choices. NeuralCompose has no authorized online SVD path, +and a streaming decomposition would be a data-dependent stateful transform. +It is not justified for Pass 1. + +Block/batched SVD simply applies the same operation to many independent +matrices, for example a batch of `4 x 1024` diagnostic windows. It is a +computational implementation detail, not an aggregation across sessions and +not a license to fit a global transform. + +Weighted SVD is appropriate only after weights are identified with an explicit +measurement-noise covariance, reliability model, or loss. Per-channel contact +quality is not automatically such a covariance. A weight chosen after seeing +test performance would be leakage. + +The generalized SVD can be useful for a pair such as a forward operator `G` +and a nonidentity regularization operator `L` in a future inverse problem. +There is no present two-operator NeuralCompose problem that needs it, so it is +not recommended now. + +## Four-channel geometry: what can and cannot be learned + +### Raw spatial window + +`X_window` has at most four nonzero spatial singular values. That small bound +has practical consequences: + +| Observation | What a spatial spectrum can show | Stronger or necessary companion | +| --- | --- | --- | +| flat/disconnected channel | a near-zero mode or reduced rank | channel variance, missing mask, amplitude range | +| duplicated/near-duplicated channel | a small fourth singular value | pairwise correlation and difference RMS | +| common-mode contamination | large first energy fraction `sigma_1^2 / sum sigma_i^2` | all pairwise correlations and amplitude/saturation checks | +| left/right frontal or temporal relationship | one particular four-channel mixture for a window | fixed channel-order provenance; no anatomical/source claim | +| contact degradation | a changed spectrum relative to calibration | signal-quality fields and a protocol-defined fault check | +| movement/saturation | sometimes a large shared mode or rank change | explicit amplitude, clipping, transport, and protocol artifact labels | + +SVD contributes a compact *joint* redundancy measure. It does not contain +information that magically bypasses the direct checks, and, with only four +channels, it will often agree with variance and correlation. That is why +`EXP-NC-SVD-DIAG-001` must demonstrate incremental defect-detection value +before a spectrum is treated as a useful diagnostic. + +Condition number depends on centering and scaling. A raw-amplitude condition +number and a calibration-z-scored condition number answer different questions. +Any diagnostic report must label which it computed and preserve the complete +four-value spectrum. It must not turn a heuristic numerical-rank threshold +into automatic capture rejection without a separately approved contract +change. + +### Different matrices answer different questions + +| Matrix | Example shape | Modes mean | Current disposition | +| --- | --- | --- | --- | +| raw spatial window | `4 x 1024` | channel mixtures and time patterns within one window | optional D1 diagnostic only | +| time-lag/Hankel window | `L x (1025-L)` per channel | delay-coordinate/SSA components | Pass 3 study only | +| M0 feature matrix | `n_train x 52` approximately | collinear deterministic-feature directions | optional D2 controlled experiment | +| spectrogram | `frequency x time` per channel or stacked channels | time-frequency patterns of a chosen transform | Pass 3 study only | +| cross-spectral density | `4 x 4` complex Hermitian per band | coherent spatial spectral modes | use Hermitian eigendecomposition; Pass 3 only | +| encoder activation matrix | `n_windows x embedding_dim` | variation in a named model layer | descriptive Pass 3 analysis | +| participant/session by feature matrix | `n_sessions x p` | between-session structure | requires enough sessions; never a window-level substitute | +| lead-field/forward matrix | `n_sensors x n_sources` | observable and poorly observable source directions | Pass 2 synthetic work only | + +These factorizations are not interchangeable. In particular, a low rank in a +Hankel matrix is not evidence for a low rank in channel space, and an embedding +rank does not measure physical sensor rank. + +## Direct use versus deferral + +| SVD application | Classification | Earliest gate | Decision it could change | Why | +| --- | --- | --- | --- | --- | +| per-window spatial spectrum as a read-only diagnostic | B. Optional Pass 1 controlled experiment | D1 | retain or retire an additional report field, never current capture eligibility | only useful if it improves synthetic/known-defect detection beyond direct checks | +| M0 feature-matrix rank/conditioning report | B. Optional Pass 1 controlled experiment | D2 | whether an SVD-aware M0 sensitivity experiment is warranted | captures multi-feature collinearity that per-feature variance misses | +| train-only PCA / non-whitened truncated feature path | B. Optional Pass 1 controlled experiment | D2 | whether a separate M0 representation improves grouped calibration/generalization | must beat unchanged M0 and matched random projection | +| train-only whitening | B. Optional Pass 1 controlled experiment | D2 | whether scaling component scores adds value beyond PCA | likely to amplify weak directions; never a default | +| PCA followed by ICA | D. Pass 3 computational or analysis tooling | D3 | whether an explicitly registered artifact-inspection method adds value | independence and convergence assumptions need their own study | +| Hankel/SSA/DMD-style temporal study | D. Pass 3 computational or analysis tooling | D3 | whether a defined temporal diagnostic changes a later analysis decision | no existing short-horizon target or stationarity claim | +| spectrogram/CSD rank analysis | D. Pass 3 computational or analysis tooling | D3 | whether a defined artifact or frequency-structure report adds value | CSD should use `eigh`; phase must be preserved | +| SVD-stabilized pseudoinverse for current M0 logistic classifier | F. Not justified | none | none | pseudoinverse solves least squares, not the current logistic objective | +| EEGNet activation/kernel rank | D. Pass 3 computational or analysis tooling | D3 | whether representation collapse/stability warrants a later study | diagnostic only; cannot alter training from a plot | +| EEGPT/BENDR embedding effective rank | D. Pass 3 computational or analysis tooling | D3 | whether a descriptive representation follow-up is justified | not evidence of transfer without the existing controls/metrics | +| randomized SVD | F. Not justified now | post_encoder if profiled large matrix exists | computational implementation only | tiny matrices make exact reduced SVD cheaper and simpler | +| incremental/streaming SVD | F. Not justified | none | none | no authorized online adaptive transform | +| batched SVD | D. Pass 3 computational or analysis tooling | D1/D3 depending matrix | CPU implementation choice after a registered diagnostic exists | not a scientific method by itself | +| weighted SVD | C. Pass 2 inverse/forward-model method | D3 | stability of a specified noise-weighted synthetic inverse model | needs an explicit covariance/weight model | +| generalized SVD | F. Not justified now | future Pass 2 only if `G,L` are registered | none currently | no concrete paired-operator problem | +| low-rank deployment compression | D. Pass 3 computational or analysis tooling | post_encoder | whether a selected offline model can meet a stated resource budget within output tolerance | cannot precede model selection | +| TSVD/Tikhonov inverse regularization | C. Pass 2 inverse/forward-model method | D3 | a synthetic reconstruction decision only | no four-channel source-localization claim | + +No application is classified A, Required Pass 1 diagnostic. Existing variance, +range, missing-mask, packet-loss, signal-quality, and pairwise-correlation +checks already satisfy the current integrity contract. An SVD report earns a +required status only after a registered study establishes that it changes an +integrity decision more accurately or more reproducibly. + +## Leakage-safe use rules + +These rules apply to every data-derived factorization, including a convenient +one-off notebook. + +1. Build complete-session folds before any fit. Windows from one recording + never cross a train/test boundary. +2. For a learned transform, fit means, scales, component vectors, whiteners, + rank thresholds, and selection rules using training windows only. Store a + fold-specific artifact hash. +3. A held-out session may be transformed with that fold's stored transform. + It may not fit components, normalization, rank, numerical threshold, or a + global visualization geometry. +4. The current causal calibration normalization remains a fixed input + contract. An SVD study must not reuse held-out task windows to fit a new + normalizer or treat calibration-derived session normalization as permission + to fit a held-out PCA/whitener. +5. Rank selection cannot use final held-out performance. A training-only rule + must be fixed in the experiment contract and recorded per fold. +6. Bind each transform to the canonical `dataset_sha256`, preprocessing hash, + feature-extractor hash, split-manifest hash, train-window-hash digest, + retained rank, numerical threshold, dtype, library version, and seed where + randomness exists. +7. Full-corpus PCA and plots are exploratory only. They cannot tune labels, + rank, quality thresholds, or a confirmatory conclusion. +8. Preserve label shuffles, channel-map shuffles, no-SVD, and matched-rank + random-projection controls. An SVD benefit that vanishes under grouped + evaluation is retired. + +## Candidate matrix-specific analyses + +### Acquisition integrity + +For `X_window`, calculate a read-only tuple after direct integrity checks: + +```text +singular_values[4] +energy_fraction[4] +stable_rank_entropy = exp(-sum p_i log p_i), p_i = sigma_i^2 / sum sigma_j^2 +condition_number_if_sigma_4_above_pinned_floor +``` + +This tuple is *not* a label, rejection rule, or M0 feature in the first +study. The baseline is the currently available deterministic check set: +channel variance, amplitude range, pairwise correlation, packet loss, +signal-quality fields, and missing-channel mask. Synthetic fixtures create +known flat, duplicated, common-mode, and full-rank/noisy cases. A physical +capture report may only state agreement or disagreement with those checks; it +cannot infer an unobserved fault. + +### PCA and whitening + +The only justified near-term PCA matrix is the standardized deterministic M0 +feature matrix `Z_train` of a fold. The current feature extractor produces +per-channel variance/amplitude/spectral features, six correlations, +line-noise ratio, quality fields, and channel-observed flags. A PCA condition +would be evaluated only in a new study under the same grouped splits and +unchanged labels. + +The preregistered rank rule should be: smallest `r` whose *training-only* +explained-variance ratio reaches 0.95, bounded by `1 <= r <= min(p, +n_train - 1)`. The threshold is a heuristic design choice, not a theorem; its +0.90 and 0.99 sensitivity results must be reported without selecting the best +test result. A matched random orthogonal projection uses the same `r` for each +fold. Whitening uses the same training components plus a pinned singular-value +floor and is reported separately. + +`PCA -> ICA` may be compared only later with the identical folds and outputs. +It adds independence and convergence assumptions and must not be used to +relabel a protocol block or to repair artifact windows. It is not a current +preprocessing recommendation. + +### Temporal low-rank structure + +For one channel `x[0:1023]`, a Hankel embedding might have + +`H[i, j] = x[i + j]`, with an explicitly pinned lag `L`. SVD of `H` supports +singular spectrum analysis (SSA); reconstructed components require a stated +anti-diagonal averaging rule. Dynamic mode decomposition and subspace +identification require time-shifted state matrices and make stronger dynamical +and stationarity assumptions. Autoregressive modeling is often a simpler, +direct prediction baseline. + +None of these currently has a registered target or decision. A narrowband +looking singular spectrum is not enough to add a temporal denoiser, claim an +attractor, or infer a short-horizon state transition. Treat them as Pass 3 +study material only. + +### Spectral and time-frequency matrices + +SVD may summarize a real channel-by-frequency magnitude matrix or a +time-frequency spectrogram. It can show that a fixed transform is dominated by +a few modes, but cannot itself distinguish artifact from neural activity. +Broadband movement, stable rhythms, and line contamination must be tested +against known/protocol-observable artifact contexts and direct spectral +baselines. + +For a complex cross-spectral density `C(f)`, use a Hermitian eigendecomposition +because `C = C^H`; phase is in its complex off-diagonal terms. If an SVD API is +used, it must use the conjugate transpose and complex dtype, and report why an +eigendecomposition was not sufficient. `complex128` is the default for +condition/rank diagnostics; `complex64` is acceptable only after a recorded +agreement check on the decision statistic. + +### M0 numerical stability + +The present M0 is already an L2-regularized logistic regression with `C = 1.0` +and a training-fold `StandardScaler`; it is not an unregularized least-squares +model. SVD can diagnose the standardized `n_train x p` feature matrix and can +define a separate truncated-feature sensitivity condition. It should not be +introduced as a disguised replacement for the registered M0. + +A matched future comparison has four roles: + +| Condition | Mathematical role | Status | +| --- | --- | --- | +| unchanged M0 logistic regression | primary benchmark baseline | required baseline | +| unregularized logistic regression | conditioning stress diagnostic, if separability permits | secondary only | +| existing L2 logistic regression | ridge-like regularized discriminative baseline | unchanged M0 | +| train-only truncated-SVD/PCA features then same L2 logistic head | data-adaptive feature-reduction test | proposed condition | +| SVD/QR least-squares one-vs-rest score | numerical teaching/control only | not a replacement for calibrated logistic M0 | + +An SVD pseudoinverse may solve a continuous least-squares control problem or +verify a linear algebra implementation. It cannot claim mathematical +equivalence to the current multiclass logistic classifier, and must not be +selected by held-out accuracy. + +### Encoder and pretrained representation diagnostics + +For EEGNet, a study may collect named layer activations and learned weights +from each already-trained fold. For EEGPT/BENDR it may collect only the +contract-permitted, fold-scoped adapter outputs or frozen representation +outputs tied to the canonical split. Candidate summaries are singular spectrum, +energy rank, entropy effective rank, and reconstruction error at a +preregistered rank. + +The analysis must be descriptive. Activation matrices from held-out windows can +be summarized after scoring, but no global transform fit across held-out +sessions may steer model selection. Per-session comparisons need fixed sample +counts or a stated weighted estimator so a long session does not dominate. A +pretrained representation with a desirable rank profile has not transferred +unless it passes the existing M0, M1, random-init, shuffled-map, zero-fill, +calibration, and grouped-metric controls. + +## Adjacent-method comparison + +| Method | Overlap with SVD | Assumptions and robustness | Four-channel disposition | +| --- | --- | --- | --- | +| QR | stable least-squares and rank-revealing variants | no low-rank optimum by itself; usually cheaper for a full-rank solve | prefer for an explicitly full-rank least-squares solve; no M0 change | +| Cholesky | ridge/normal-equation solve | requires positive-definite system; normal equations worsen conditioning when unregularized | implementation choice for a well-regularized linear study, not diagnostic | +| eigendecomposition | PCA/covariance/CSD modes | square symmetric/Hermitian matrix; covariance formation squares conditioning | use for CSD/covariance where appropriate | +| NMF | low-rank components | requires nonnegative representation and nonnegative additive interpretation | raw signed EEG: not justified; spectral-power study later only | +| ICA | unmixing/artifact inspection | independence/non-Gaussianity, convergence, component ambiguity | Pass 3 only, after PCA if a registered question demands it | +| robust PCA | low-rank plus sparse decomposition | sparse-outlier model and optimization choices | not justified without synthetic artifact test and a decision target | +| tensor decompositions | multiway low-rank structure | enough independent modes/sessions and rank choices | deferred; not needed for `4 x 1024` windows | +| autoencoders | learned nonlinear compression | training data, architecture, optimization, and leakage risk | not justified for Pass 1 | +| random projections | dimension-reduction control | preserves distances probabilistically, not data-adaptive modes | required matched-rank control for PCA studies | +| ridge regression | smooth spectral shrinkage in a linear solve | loss/objective must match target | current M0 already uses L2 logistic regularization | +| CCA | paired-view correlation | requires a legitimate paired continuous view and grouped fitting | no current paired target; dialogue is prohibited | + +## Numerical implementation and reproducibility + +### Python + +No new Python dependency is justified. The benchmark already has NumPy, SciPy, +scikit-learn, and PyTorch. + +| Need | Recommendation | Reproducibility rule | +| --- | --- | --- | +| tiny diagnostics | `numpy.linalg.svdvals` or reduced `numpy.linalg.svd` | record NumPy/LAPACK version, dtype, matrix shape, threshold | +| explicit LAPACK driver or `compute_uv=False` | `scipy.linalg.svd` / `svdvals` | retain `lapack_driver`, finite-input policy, and library version | +| M0 PCA | `sklearn.decomposition.PCA(svd_solver="full")` | fit in fold pipeline only; artifact contains mean/components/rank hashes | +| randomized comparison, only if later profiled | `sklearn.utils.extmath.randomized_svd` | pin seed, rank, oversamples, power iterations, normalizer, residual error | +| neural activation spectra | `torch.linalg.svdvals` after a fixed model run | use values/subspaces, not signs; record device/dtype and copy timing | + +NumPy documents reduced and stacked SVD behavior; SciPy exposes the +`gesdd`/`gesvd` driver choice; scikit-learn documents full and randomized PCA +solvers; and PyTorch warns that singular vectors are nonunique and gradients +through close/repeated singular values can be unstable. See the linked +[NumPy](https://numpy.org/doc/stable/reference/generated/numpy.linalg.svd.html), +[SciPy](https://docs.scipy.org/doc/scipy/reference/generated/scipy.linalg.svd.html), +[scikit-learn PCA](https://scikit-learn.org/stable/modules/generated/sklearn.decomposition.PCA.html), +[randomized SVD](https://scikit-learn.org/stable/modules/generated/sklearn.utils.extmath.randomized_svd.html), +and [PyTorch](https://docs.pytorch.org/docs/stable/generated/torch.linalg.svd) +references. + +### Julia + +Julia is a reference environment, not a benchmark/runtime dependency. For a +separate scientific workspace, use `LinearAlgebra.svd(X; full=false)` and +`svdvals(X)` for exact reduced decompositions. Record `VERSION`, the active +BLAS/LAPACK backend, `Project.toml`/`Manifest.toml` hashes, dtype, and matrix +layout. `MultivariateStats.jl` is reasonable only for a separately registered +PCA/ICA study; its observation-orientation convention must be checked before +comparing it to Python. Do not introduce an incremental or randomized package +without a maintained implementation and a measured full-SVD bottleneck. + +`Optim.jl` is unnecessary for ordinary PCA, TSVD, or identity-Tikhonov +calculations; closed-form linear algebra is more transparent. JuMP with +MathOptInterface is appropriate only after a future study writes down an +actual constrained optimization problem. MathProgBase is not to be used. +The [Julia LinearAlgebra documentation](https://docs.julialang.org/en/v1/stdlib/LinearAlgebra/) +is the authoritative API reference. + +### Apple Silicon and cloud compute + +| Workload | Default executor | Why | Cloud/MPS rule | +| --- | --- | --- | --- | +| one/batched `4 x 1024` spatial diagnostics | local CPU through NumPy/SciPy or Julia BLAS/LAPACK | CPU launch and transfer overhead dominate | do not move to GPU merely for SVD | +| M0 `n_train x about-52` feature analysis | local CPU | small dense factorization and exact full SVD are cheap | no randomized/GPU SVD | +| activation/embedding matrix inspection | same executor that produced artifact, then profile local CPU | avoid transfer/copy and preserve provenance | MPS/GPU only after batch size and transfer measurement justify it | +| large, already-authorized public/external embedding matrix | profile CPU and GPU honestly | may become throughput-bound | cloud is never a reason to upload local EEG; follow existing data/provenance policy | +| Julia reference notebook | local Julia BLAS/LAPACK | reproducible small-matrix science | inspect actual BLAS backend; do not assume Accelerate | + +Apple's Accelerate framework supplies BLAS/LAPACK routines, but it does not +make every small decomposition GPU work. PyTorch's documented SVD driver +selection is CUDA-specific; an MPS request must be benchmarked rather than +assumed equivalent. A benchmark record must include matrix dimensions, batch +size, dtype, warm-up count, host/device transfer time, compute time, peak +memory, reconstruction error, algorithm/library versions, and determinism +status. Apple's [Accelerate](https://developer.apple.com/documentation/accelerate) +documentation is the relevant local CPU API reference. + +### Precision guide + +| Quantity | Default dtype | Escalate when | Do not do | +| --- | --- | --- | --- | +| raw/window diagnostic spectrum | float64 | small singular values or condition estimates affect a decision | report a float32 rank threshold as physical truth | +| M0 PCA transform | float64 fit; transform may be checked in float32 | fold rank/metrics disagree across dtype | fit or choose rank with held-out data | +| neural activation spectrum | float32 acceptable for descriptive scale | rank/condition conclusion changes against float64 | backpropagate through singular vectors as a stability feature | +| CSD/eigen diagnostic | complex128 | phase/near-null modes matter | drop conjugation or phase silently | +| deployment compression | selected model's native dtype plus float64 audit | output tolerance is near boundary | introduce float16 only for nominal speed | + +Machine epsilon is part of numerical-rank behavior. A conventional numerical +threshold such as `tau = eps(dtype) * max(m,n) * sigma_1` is a library-scale +heuristic, not a physiological cutoff. Record the full spectrum and report +sensitivity to a small preregistered threshold set. FP32 is usually sufficient +for ordinary activation summaries; FP64 is warranted for condition numbers, +weak modes, reconstruction audits, and any conclusion that depends on small +singular values. FP16 is not justified for condition/rank estimates. + +## Falsification and retirement rules + +Retire SVD from an active role in the relevant study when any registered +criterion is met: + +- direct variance/correlation/range/mask/transport checks match defect + detection and have equal or lower false-positive rate; +- a training-only retained-rank rule varies materially across grouped folds or + its apparent gain disappears under complete-session evaluation; +- a matched-rank random projection performs equivalently; +- a feature reduction helps only by reducing dimension, with no advantage over + a non-SVD matched control; +- calibration, artifact recognition, or abstention behavior worsens; +- a result depends on a held-out fit, selected threshold, or a global PCA; +- effective-rank conclusions reverse under reasonable dtype/threshold choices; +- a purported benefit is confined to one session, one configuration, or one + model condition; +- compute/memory complexity exceeds its independently measured benefit. + +## Glossary + +| Term | Meaning here | +| --- | --- | +| SVD | factorization `A = U Sigma V^H` for a real or complex matrix | +| PCA | training-data projection onto leading covariance eigenvectors/right singular vectors | +| ICA | model-based attempt to find statistically independent components; not equivalent to PCA or SVD | +| pseudoinverse | `A^+`, the minimum-norm least-squares operator; not a logistic classifier | +| truncated SVD | retain a fixed first `r` singular components and discard the rest | +| Tikhonov regularization | smooth least-squares regularization using factors `sigma/(sigma^2 + lambda)` for identity penalty | +| effective rank | a threshold- or entropy-derived summary of a spectrum, not exact algebraic rank | +| condition number | `sigma_1/sigma_min` in the 2-norm; measures sensitivity of a linear solve and depends on scaling | + +## References + +- G. H. Golub and C. F. Van Loan, *Matrix Computations*, 4th ed. (SVD, + conditioning, least squares, and perturbation foundations). +- P. C. Hansen, *Rank-Deficient and Discrete Ill-Posed Problems* (TSVD and + Tikhonov filter-factor interpretation). +- N. Halko, P.-G. Martinsson, and J. A. Tropp, "Finding structure with + randomness," *SIAM Review* 53(2), 2011, + [doi:10.1137/090771806](https://doi.org/10.1137/090771806). +- The current NeuralCompose four-channel scope and experimental contracts cited + above are authoritative over this educational primer. diff --git a/docs/science/function-space-operator-hypotheses.md b/docs/science/function-space-operator-hypotheses.md new file mode 100644 index 0000000..d6496a0 --- /dev/null +++ b/docs/science/function-space-operator-hypotheses.md @@ -0,0 +1,59 @@ +# Function-Space And Operator Hypotheses + +> Status: D0 foundations registered 2026-07-24. These are Science-layer +> hypotheses and mathematical fixtures, not runtime modes or EEG methods. + +## Placement + +Function-space work asks how observations, fitted functions, derivatives, and +representations relate under explicit measures and operators: + +```text +Science + empirical-measure questions + Sobolev/Tikhonov hypotheses + operator-stability hypotheses + +Computation + deterministic Julia reference fixtures + +Engineering + unchanged unless a later empirical experiment registers a measurement +``` + +`EXP-FUNC-SYN-000` is the D0 synthetic rehearsal. It establishes +implementation behavior only: + +- FS0 demonstrates finite-sample pseudometric equivalence. +- FS1 separates parameter AD from numerical signal differentiation. +- FS2 solves a finite-basis Sobolev/Tikhonov problem. +- FS3 illustrates Egorov's theorem. +- FS4 measures operator stability. +- FS5 integrates a synthetic continuous-plus-atomic measure. + +The governing [decision +memo](../research/function-space-foundations-decision-memo-v0.md) and +[machine-readable contract](../../configs/function-space-foundations-v0.json) +retain `decision: insufficient_evidence` and +`promotion_status: not_eligible`. + +## Later Hypotheses + +A later empirical hypothesis may ask whether a preregistered regularized +derivative estimate improves held-out prediction or robustness relative to +raw and filtered finite differences. It must define: + +- the measured target; +- the data and split unit; +- train-only regularization selection; +- baselines and failure criteria; +- whether any preprocessing behavior would change. + +No such physical-data study is authorized by the D0 package. + +## Representation Boundary + +The representation term in the operator objective is explanatory. Variance, +covariance, singular-spectrum, effective-rank, and collapse controls remain in +the existing JEPA contracts. This document does not create another +representation trainer. diff --git a/docs/science/sobolev-zpd-hypotheses.md b/docs/science/sobolev-zpd-hypotheses.md index 8651d66..7a96c2d 100644 --- a/docs/science/sobolev-zpd-hypotheses.md +++ b/docs/science/sobolev-zpd-hypotheses.md @@ -1,133 +1,12 @@ # Sobolev And ZPD Hypotheses -> Status: Draft (2026-07-22). This note places Sobolev learning -> and Zone of Proximal Development ideas inside the three-concern -> architecture. They are hypothesis generators, not runtime modes. +> Compatibility index. The former combined note was split on 2026-07-24 so +> estimation and policy questions retain separate evidence gates. -## Placement +- [Function-Space And Operator Hypotheses](function-space-operator-hypotheses.md) + covers empirical measures, differentiation, Sobolev/Tikhonov fitting, and + operator stability. +- [ZPD Intervention Hypotheses](zpd-intervention-hypotheses.md) covers deferred + dialogue intervention and policy/control questions. -Sobolev learning and ZPD belong in different concerns: - -```text -Science - Sobolev hypotheses - ZPD hypotheses - -Engineering - derivative telemetry - intervention-distance telemetry - feature contracts - -Computation - Julia reference models - Rust deterministic operators - Swift interaction policy -``` - -Neither idea should be baked into prompts or UI as an identity. Each -must become a falsifiable claim against measured telemetry. - -## Sobolev Learning - -Sobolev methods do not only learn a function: - -```text -f(x) -``` - -They also care about derivatives: - -```text -df/dx -``` - -For NeuralCompose, that means the scientific object is not only a -dialogue state point. It is the trajectory: - -```text -state - -> velocity - -> acceleration - -> stability -``` - -A Sobolev-flavored hypothesis might be: - -```text -Adding derivative-matching regularization to dialogue trajectory -fitting reduces held-out one-step prediction error by at least X% -without increasing instability. -``` - -Julia is the natural place to fit and falsify that model. Rust only -appears later if a validated derivative operator deserves a -deterministic implementation. - -## ZPD - -ZPD is not primarily about fitting the field. It is about choosing an -intervention: - -```text -What is the smallest useful move that advances the dialogue? -``` - -That belongs near interaction policy, between a world/dialogue model -and generation: - -```text -world model - -> intervention policy - -> generation -``` - -A ZPD-inspired policy can be parameterized by: - -```text -challenge_level -hint_strength -ambiguity_tolerance -silence_threshold -continuation_pressure -``` - -A falsifiable hypothesis might be: - -```text -A proximal intervention policy increases constructive continuation -while reducing abstraction drift compared with the baseline policy. -``` - -Swift may eventually host the interaction policy because Swift owns -orchestration and user-facing behavior. That should happen only after -Science defines the measurable policy and Engineering can record the -intervention-distance telemetry. - -## Shared Dynamical View - -The common frame is dialogue as motion on a measured state manifold: - -```text -telemetry - -> state reconstruction - -> local dynamics - -> intervention policy - -> runtime behavior -``` - -Sobolev learning asks: - -```text -Can we estimate a smooth local vector field? -``` - -ZPD asks: - -```text -Can we choose a short, productive control input along that field? -``` - -Those are estimation and control questions over the same system. That -is useful only if kept empirical: models must predict held-out -telemetry, policies must improve named metrics, and validated -operators must cross the Rust/Swift boundary deliberately. +Neither document defines a runtime mode or alters the physical EEG experiment. diff --git a/docs/science/zpd-intervention-hypotheses.md b/docs/science/zpd-intervention-hypotheses.md new file mode 100644 index 0000000..1d7db96 --- /dev/null +++ b/docs/science/zpd-intervention-hypotheses.md @@ -0,0 +1,34 @@ +# ZPD Intervention Hypotheses + +> Status: deferred policy research. Zone of Proximal Development is a source of +> intervention hypotheses, not an EEG construct or function-space operator. + +## Question + +ZPD-inspired work asks: + +```text +What is the smallest useful intervention that advances the dialogue? +``` + +Candidate policy quantities may include challenge level, hint strength, +ambiguity tolerance, silence threshold, and continuation pressure. They become +scientific variables only under a separately registered intervention +experiment with observable outcomes. + +## Boundary + +This is policy/control work: + +```text +frozen structured state + -> shadow intervention policy + -> measured dialogue outcome +``` + +It is not part of `EXP-FUNC-SYN-000`, the physical EEG encoder experiment, or +the Julia function-space package. It does not justify an app mode, prompt +identity, EEG label, runtime parameter, or live control path. + +Any future Swift orchestration requires prior held-out policy evidence and the +existing post-encoder structured-state gates. diff --git a/docs/scoping/eeg-mathematics-physics-methods-scope.md b/docs/scoping/eeg-mathematics-physics-methods-scope.md index c53d2dd..f175d38 100644 --- a/docs/scoping/eeg-mathematics-physics-methods-scope.md +++ b/docs/scoping/eeg-mathematics-physics-methods-scope.md @@ -77,6 +77,15 @@ Use this layer for a later, separately preregistered inverse-problem or regularization study. Do not call the four-channel encoder benchmark a source localization experiment. +The deterministic `EXP-FUNC-SYN-000` package rehearses empirical measures, +derivative distinctions, finite-basis Sobolev/Tikhonov fitting, Egorov's +theorem fixture, bounded-operator checks, and mixed-measure integration without +reading EEG or changing this experiment. See the [function-space decision +memo](../research/function-space-foundations-decision-memo-v0.md) and +[contract](../../configs/function-space-foundations-v0.json). A passing +synthetic fixture remains `insufficient_evidence`; it does not authorize a +preprocessing method. + #### Electroquasistatics and EEG forward models The physical model worth learning is the low-frequency EEG forward problem: @@ -236,3 +245,7 @@ Swift remains the interaction and orchestration layer. build the canonical dataset, and run M0/M1 as pipeline evidence only. 3. Record any mathematical question that cannot be answered by Pass 1 as a separate hypothesis before adding a package, model, or runtime path. + +`EXP-FUNC-SYN-000` may run in parallel because it uses embedded synthetic +parameters only. It is not a prerequisite for the first physical capture and +must not delay the D0-to-D1 acquisition sequence. diff --git a/docs/scoping/jepa-collapse-diagnostics-v0.json b/docs/scoping/jepa-collapse-diagnostics-v0.json new file mode 100644 index 0000000..f87117e --- /dev/null +++ b/docs/scoping/jepa-collapse-diagnostics-v0.json @@ -0,0 +1,193 @@ +{ + "schema_version": "nc-eeg-jepa-collapse-diagnostics-v0", + "experiment_id": "EXP-NC-EEG-JEPA-001", + "status": "scope_complete", + "track": "Pass_1.5_non_runtime", + "data_gate": "D0", + "decision": "foundational_study_only", + "promotion_status": "not_eligible", + "foundational_reference": { + "experiment_id": "EXP-FUNC-SYN-000", + "status": "foundational_study_only", + "report_sha256": "5625b616a846c58978ec4b965c2e717509fbc60990b129da36987813c0414b0e", + "contract_sha256": "6a08b3a3ff717f98bd40d37bcb35cc4745097e0a72013ec852fd081747c1d1c3", + "science_workspace_commit": "8eafaea", + "synthetic_jepa_isolation_commit": "ba7822b", + "claim_scope": "mathematical and implementation reference only", + "physical_thresholds_derived": false, + "runtime_dependency_authorized": false + }, + "live_control": false, + "runtime_dependency_authorized": false, + "live_inference_authorized": false, + "laya_download_or_training_authorized": false, + "execution_gates": { + "D0": "contracts_synthetic_fixtures_and_validator_tests_only", + "D1": "optional_read_only_pipeline_and_collapse_metric_smoke_tests_no_jepa_training", + "D2": "separately_preregistered_tiny_pipeline_evidence_only_no_confirmation_or_promotion", + "D3": "first_session_grouped_jepa_comparison_with_fold_local_pretraining_and_matched_controls", + "post_encoder": "separate_action_conditioned_transition_world_model_or_mpc_experiment" + }, + "j6_gate": { + "status": "blocked", + "requires": [ + "reproducible_pinned_implementation", + "pinned_checkpoint", + "compatible_channel_mapping", + "matched_compute_controls", + "license_review" + ] + }, + "measurement_frequency": { + "trainable_conditions": "every_epoch_on_training_and_inner_validation_groups", + "outer_held_out_group": "once_after_complete_condition_freeze", + "frozen_external_condition": "once_after_fold_local_adapter_and_probe_freeze" + }, + "threshold_policy": { + "d0": "record_raw_diagnostics_only", + "physical_execution": "thresholds_preregistered_from_training_partitions_only", + "held_out_threshold_fitting_allowed": false, + "outer_group_selects_epoch_or_condition": false + }, + "regularization_application_rule": "Anti-collapse pressure must include a named encoder or projector representation; predictor-output-only regularization invalidates the condition.", + "representation_scopes": [ + "encoder_embedding", + "projector_embedding", + "predictor_output", + "stopped_gradient_target_embedding" + ], + "diagnostics": [ + { + "id": "per_dimension_variance", + "value": "vector", + "definition": "Population variance of each named representation dimension over the registered diagnostic batch.", + "collapse_signal": "many dimensions approach a preregistered training-only floor", + "required": true + }, + { + "id": "covariance_off_diagonal_magnitude", + "value": "scalar_and_matrix_summary", + "definition": "Mean absolute off-diagonal covariance plus the complete covariance-diagonal summary for the named representation.", + "collapse_signal": "dimensions are redundant or covariance structure is unstable across grouped folds", + "required": true + }, + { + "id": "singular_value_spectrum", + "value": "vector", + "definition": "Complete reduced singular-value spectrum of the centered registered embedding matrix, with matrix shape and dtype.", + "collapse_signal": "one or a few modes dominate under a preregistered sensitivity rule", + "required": true + }, + { + "id": "entropy_effective_rank", + "value": "scalar", + "definition": "exp(-sum_i p_i log(p_i)), where p_i = sigma_i^2 / sum_j sigma_j^2 and zero-energy matrices are separately flagged.", + "collapse_signal": "effective rank approaches one or is unstable across grouped folds", + "required": true + }, + { + "id": "energy_rank", + "value": "scalar_and_sensitivity", + "definition": "Smallest rank reaching the preregistered cumulative singular-energy threshold, with threshold sensitivity recorded.", + "collapse_signal": "rank is degenerate or conclusion depends on a post-hoc threshold", + "required": true + }, + { + "id": "condition_number", + "value": "scalar_or_null", + "definition": "Largest divided by smallest retained singular value only when the smallest value exceeds the recorded numerical floor; otherwise null with rank-deficient flag.", + "collapse_signal": "extreme or nonfinite conditioning that is stable under dtype and floor sensitivity checks", + "required": true + }, + { + "id": "mean_pairwise_distance", + "value": "scalar", + "definition": "Mean Euclidean distance over a fixed seeded subset of distinct embedding pairs.", + "collapse_signal": "distance approaches a preregistered training-only floor", + "required": true + }, + { + "id": "nearest_neighbor_session_identity", + "value": "rate_and_confusion_matrix", + "definition": "Rate at which each embedding's nearest eligible neighbor shares its recording-session identity, excluding the same source window and overlap-defined neighbors.", + "collapse_signal": "not collapse by itself; a high rate indicates nuisance session identity and weak transfer evidence", + "required": true + }, + { + "id": "constant_output_check", + "value": "boolean_and_max_deviation", + "definition": "Detects an exactly or numerically constant registered representation using the preregistered dtype-aware floor.", + "collapse_signal": "true", + "required": true + }, + { + "id": "feature_utilization", + "value": "count_and_fraction", + "definition": "Count and fraction of dimensions whose training-partition variance exceeds the preregistered floor.", + "collapse_signal": "few dimensions are utilized or utilization is fold-unstable", + "required": true + }, + { + "id": "predictor_loss", + "value": "scalar", + "definition": "Objective-specific latent prediction loss with reduction, mask denominator, and target scope recorded.", + "collapse_signal": "not a collapse test alone; low loss with failed geometry checks is explicitly insufficient", + "required": true + }, + { + "id": "regularization_loss", + "value": "named_components", + "definition": "Every active SIGReg, VICReg-style, reconstruction, or query-specialization component reported separately and before weighting.", + "collapse_signal": "not a collapse test alone; missing or inactive expected pressure invalidates the condition", + "required": true + }, + { + "id": "linear_probe_performance", + "value": "metric_bundle", + "definition": "Frozen-protocol linear-probe balanced accuracy, macro F1, Brier score, and expected calibration error on training or inner-validation groups at each epoch; the outer held-out group is evaluated once after complete condition freeze.", + "collapse_signal": "chance-like or fold-unstable performance despite favorable geometry prevents a non-collapse interpretation", + "required": true + } + ], + "required_negative_controls": [ + "temporal_target_permutation", + "different_session_target", + "channel_coordinate_shuffle", + "channel_order_shuffle", + "mask_location_shuffle", + "no_regularization", + "constant_embedding", + "random_encoder", + "matched_reconstruction" + ], + "required_provenance": [ + "dataset_sha256", + "source_manifest_sha256", + "preprocessing_sha256", + "split_manifest_sha256", + "training_window_digest", + "held_out_group_id", + "condition_id", + "architecture_revision", + "checkpoint_sha256", + "adapter_revision", + "mask_schedule", + "optimizer_and_budget", + "seed", + "epoch", + "representation_scope", + "matrix_shape", + "dtype", + "library_versions", + "numerical_floors" + ], + "interpretation_rules": { + "geometry_alone_supports_model": false, + "reconstruction_error_proves_noncollapse": false, + "training_loss_proves_noncollapse": false, + "three_dimensional_visualization_is_metric": false, + "held_out_projection_fitting_allowed": false, + "supported_result_means": "supported_for_further_encoder_science_only", + "runtime_change": "none" + } +} diff --git a/docs/scoping/jepa-synthetic-generators-v0.json b/docs/scoping/jepa-synthetic-generators-v0.json new file mode 100644 index 0000000..27f3307 --- /dev/null +++ b/docs/scoping/jepa-synthetic-generators-v0.json @@ -0,0 +1,134 @@ +{ + "schema_version": "nc-eeg-jepa-synthetic-generators-v0", + "experiment_id": "EXP-NC-EEG-JEPA-SYN-000", + "status": "executable_pipeline_rehearsal", + "source_type": "deterministic_synthetic_fixture", + "physical_eeg_used": false, + "physical_threshold_tuning_allowed": false, + "fallback_acquisition_stream": { + "accepted": false, + "reason": "A fallback-degraded or synthetic acquisition stream is evidence of live transport failure, not a physical Muse capture or a dedicated deterministic fixture.", + "canonical_capture_manifest_allowed": false + }, + "generators": [ + { + "id": "S0", + "name": "known_low_rank_oscillatory_process", + "definition": "Two seeded latent oscillators are mixed into four observed channels with low independent noise.", + "expected_invariants": { + "raw_entropy_effective_rank_min": 1.5, + "raw_entropy_effective_rank_max": 2.6, + "correct_targets_should_beat_permutation": true, + "correct_to_permuted_ratio_max": 0.98 + } + }, + { + "id": "S1", + "name": "full_rank_independent_noise", + "definition": "Four independent noise channels have no temporal target structure.", + "expected_invariants": { + "raw_entropy_effective_rank_min": 3.2, + "correct_targets_should_beat_permutation": false, + "correct_to_permuted_ratio_min": 0.95 + } + }, + { + "id": "S2", + "name": "shared_common_mode_artifact", + "definition": "Low-amplitude oscillatory channels receive a large seeded shared transient.", + "expected_invariants": { + "first_singular_energy_min": 0.55, + "artifact_dominance_should_trigger": true + } + }, + { + "id": "S3", + "name": "session_identity_shortcut", + "definition": "Every session has a fixed offset and spectral signature unrelated to the synthetic state target.", + "expected_invariants": { + "raw_nearest_neighbor_session_identity_min": 0.75, + "session_identity_should_trigger": true + } + }, + { + "id": "S4", + "name": "channel_dropout_and_rank_collapse", + "definition": "A channel is copied, flattened, or explicitly removed with provenance retained.", + "expected_invariants": { + "raw_matrix_rank_max": 3, + "rank_collapse_detector_should_trigger": true, + "missing_channel_provenance_required": true + } + }, + { + "id": "S5", + "name": "state_transition_process", + "definition": "A seeded latent Markov process changes between observable synthetic states and drives four mixed channels.", + "expected_invariants": { + "correct_targets_should_beat_permutation": true, + "correct_to_permuted_ratio_max": 0.98, + "state_probe_above_chance_expected": true, + "state_probe_accuracy_min": 0.55 + } + }, + { + "id": "S6", + "name": "collapse_trap", + "definition": "Weak but real sample variation is paired with an objective geometry that rewards constant latent targets unless encoder-side anti-collapse pressure remains active.", + "expected_invariants": { + "T2_collapse_expected": true, + "T2_effective_rank_max": 2.0, + "T3_or_T4_should_reduce_collapse": true, + "anti_collapse_effective_rank_gain_min": 0.25, + "regularization_must_apply_to": "projector_embedding" + } + } + ], + "mode_extension": { + "experiment_id": "EXP-NC-EEG-JEPA-SYN-MODE-000", + "mode_labels_are": "externally_assigned_generator_conditions", + "mode_labels_are_not": "EEG_inferred_cognitive_states", + "modes": { + "mirror": "faster state tracking with short-range continuity", + "focus": "lower transition rate with a persistent latent component", + "reflective": "longer context dependence with delayed transitions", + "contemplative": "slower low-frequency latent dynamics" + }, + "independently_crossed_nuisance_variables": [ + "latent_rank", + "noise_strength", + "oscillation_frequency", + "transition_rate", + "artifact_probability", + "channel_mixing_matrix", + "session_offset", + "missing_channel_pattern" + ], + "controls": { + "C0": "mode_blind_latent_predictor", + "C1": "correct_mode_context", + "C2": "shuffled_mode_context", + "C3": "constant_uninformative_mode_context", + "C4": "leave_one_mode_out_mode_blind_evaluation", + "C5": "mode_available_to_probe_only" + }, + "split_rule": "Complete synthetic sessions are the split unit. Leave-one-mode-out uses complete sessions from the held-out mode.", + "physical_protocol_effect": "none" + }, + "interpretation": { + "supported_question": "Does the pipeline detect collapse and distinguish known synthetic structures under controlled conditions?", + "prohibited_claims": [ + "JEPA learns transferable Muse EEG representations", + "J3 J4 or J5 is superior to EEGNet on physical EEG", + "real artifact sensitivity or specificity", + "cross-session Muse generalization", + "participant or headset robustness", + "physical EEG data efficiency", + "production encoder selection", + "Core ML or NeuralCompose runtime integration", + "cognitive mode inference" + ], + "failed_expected_invariant_means": "pipeline_defect_or_inadequately_specified_generator", + "promotion_status": "not_eligible" + } +} diff --git a/docs/scoping/research-decision-register.schema.json b/docs/scoping/research-decision-register.schema.json new file mode 100644 index 0000000..4b52954 --- /dev/null +++ b/docs/scoping/research-decision-register.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "NeuralCompose Research Decision Register", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "topic", + "pass", + "owner", + "registered_question", + "decision_it_can_change", + "required_data_gate", + "falsification_criterion", + "implementation_status", + "runtime_dependency_authorized" + ], + "properties": { + "schema_version": { "const": "nc-research-decision-register-v0" }, + "topic": { "type": "string", "minLength": 1 }, + "pass": { "type": "integer", "minimum": 1, "maximum": 4 }, + "owner": { "enum": ["science", "engineering", "computation"] }, + "registered_question": { "type": "string", "minLength": 1 }, + "decision_it_can_change": { "type": "string", "minLength": 1 }, + "required_data_gate": { "enum": ["D0", "D1", "D2", "D3", "post_encoder"] }, + "falsification_criterion": { "type": "string", "minLength": 1 }, + "implementation_status": { "enum": ["deferred", "study_only", "eligible_for_experiment"] }, + "runtime_dependency_authorized": { "const": false } + } +} diff --git a/docs/scoping/svd-decision-register-v0.json b/docs/scoping/svd-decision-register-v0.json new file mode 100644 index 0000000..1b7605b --- /dev/null +++ b/docs/scoping/svd-decision-register-v0.json @@ -0,0 +1,291 @@ +{ + "schema_version": "nc-eeg-svd-decision-register-v0", + "document_status": "foundational_study_only", + "current_data_gate": "D0", + "promotion_status": "not_eligible", + "foundational_reference": { + "experiment_id": "EXP-FUNC-SYN-000", + "status": "foundational_study_only", + "report_sha256": "5625b616a846c58978ec4b965c2e717509fbc60990b129da36987813c0414b0e", + "contract_sha256": "6a08b3a3ff717f98bd40d37bcb35cc4745097e0a72013ec852fd081747c1d1c3", + "science_workspace_commit": "8eafaea", + "synthetic_jepa_isolation_commit": "ba7822b", + "claim_scope": "mathematical and implementation reference only", + "physical_thresholds_derived": false, + "runtime_dependency_authorized": false + }, + "entries": [ + { + "method": "per-window spatial singular-spectrum diagnostic", + "classification": "B", + "pass": 1, + "owner": "engineering", + "registered_question": "Do singular-spectrum summaries detect known capture defects beyond direct deterministic checks?", + "decision_it_can_change": "Whether to retain a read-only diagnostic report.", + "required_data_gate": "D1", + "matrix_or_operator": "channel-centered raw or calibration-z-scored X_window with shape 4 x 1024", + "fit_scope": "per_window_read_only; no learned threshold from held-out data", + "baseline": "variance, amplitude range, pairwise correlation, packet loss, signal-quality fields, and missing mask", + "negative_controls": ["flat channel", "duplicated channel", "near-collinear channel", "common-mode fixture", "full-rank noisy fixture"], + "primary_metrics": ["defect sensitivity", "false-positive rate", "deterministic reproducibility", "incremental detections over direct checks"], + "falsification_criterion": "Retire when direct checks detect the same defects with no higher false-positive rate, or conclusions are dtype/threshold unstable.", + "implementation_status": "eligible_for_experiment", + "runtime_dependency_authorized": false + }, + { + "method": "training-feature condition-number report", + "classification": "B", + "pass": 1, + "owner": "science", + "registered_question": "Does the standardized M0 feature matrix show fold-stable numerical redundancy requiring a controlled representation study?", + "decision_it_can_change": "Whether EXP-NC-SVD-M0-001 is warranted, not M0 configuration.", + "required_data_gate": "D2", + "matrix_or_operator": "training-fold standardized M0 feature matrix Z_train with approximately 52 columns", + "fit_scope": "training_partition_only", + "baseline": "per-feature variance and pairwise feature correlation report", + "negative_controls": ["no-SVD report", "rank threshold sensitivity", "float32 versus float64"], + "primary_metrics": ["smallest singular value", "condition number", "effective numerical rank", "fold stability"], + "falsification_criterion": "Retire when rank/conditioning adds no stable information beyond direct feature checks.", + "implementation_status": "eligible_for_experiment", + "runtime_dependency_authorized": false + }, + { + "method": "train-only PCA or truncated-SVD M0 feature path", + "classification": "B", + "pass": 1, + "owner": "science", + "registered_question": "Does a training-only truncated feature path improve grouped M0 calibration or generalization over unchanged M0?", + "decision_it_can_change": "Whether a separately registered M0 sensitivity result merits further science.", + "required_data_gate": "D2", + "matrix_or_operator": "training-fold standardized M0 feature matrix Z_train", + "fit_scope": "training_partition_only; transform held-out rows only with stored fold parameters", + "baseline": "unchanged M0 L2 logistic regression with C = 1.0", + "negative_controls": ["no-SVD M0", "matched-rank random orthogonal projection", "feature-count-matched deterministic selector", "shuffled session labels"], + "primary_metrics": ["balanced accuracy", "macro F1", "Brier score", "expected calibration error", "artifact metrics", "per-session results"], + "falsification_criterion": "Retire when grouped performance/calibration does not improve, rank is unstable, or matched random projection performs equivalently.", + "implementation_status": "eligible_for_experiment", + "runtime_dependency_authorized": false + }, + { + "method": "train-only PCA whitening", + "classification": "B", + "pass": 1, + "owner": "science", + "registered_question": "Does whitening add value beyond non-whitened train-only PCA features under identical grouped folds?", + "decision_it_can_change": "Whether whitening is worth retaining as a sensitivity condition.", + "required_data_gate": "D2", + "matrix_or_operator": "training-fold PCA component scores with a pinned singular-value floor", + "fit_scope": "training_partition_only", + "baseline": "non-whitened PCA with identical retained-rank rule", + "negative_controls": ["no-SVD M0", "matched-rank random projection", "singular-value-floor sensitivity"], + "primary_metrics": ["Brier score", "expected calibration error", "artifact metrics", "retained-rank stability"], + "falsification_criterion": "Retire when whitening worsens calibration/artifact behavior or has no advantage over non-whitened PCA.", + "implementation_status": "eligible_for_experiment", + "runtime_dependency_authorized": false + }, + { + "method": "PCA followed by ICA", + "classification": "D", + "pass": 3, + "owner": "science", + "registered_question": "Does a train-only PCA-plus-ICA artifact-inspection study add stable information beyond PCA and direct checks?", + "decision_it_can_change": "Whether a later analysis workflow merits a separate artifact study.", + "required_data_gate": "D3", + "matrix_or_operator": "training-fold standardized feature or explicitly defined channel-time matrix", + "fit_scope": "training_partition_only", + "baseline": "direct checks and PCA-only analysis", + "negative_controls": ["no-ICA", "component-order/sign sensitivity", "shuffled session labels"], + "primary_metrics": ["artifact diagnostic sensitivity", "false-positive rate", "component stability"], + "falsification_criterion": "Retire when ICA components are unstable or add no value beyond direct/PCA analysis.", + "implementation_status": "deferred", + "runtime_dependency_authorized": false + }, + { + "method": "Hankel or singular-spectrum temporal analysis", + "classification": "D", + "pass": 3, + "owner": "science", + "registered_question": "Does a preregistered temporal matrix diagnostic change a defined analysis decision beyond an autoregressive or spectral baseline?", + "decision_it_can_change": "Whether a later temporal diagnostic experiment is justified.", + "required_data_gate": "D3", + "matrix_or_operator": "per-channel Hankel H[i,j] = x[i+j] with fixed lag and reconstruction rule", + "fit_scope": "per_window_read_only or training_partition_only as preregistered", + "baseline": "ordinary spectral and autoregressive baselines", + "negative_controls": ["phase-randomized signal", "full-rank noise", "lag sensitivity", "no-SVD baseline"], + "primary_metrics": ["defined diagnostic accuracy", "reconstruction error", "stability across sessions"], + "falsification_criterion": "Retire when results are lag-sensitive, no better than simple baselines, or lack a prespecified decision target.", + "implementation_status": "deferred", + "runtime_dependency_authorized": false + }, + { + "method": "spectrogram or cross-spectral rank analysis", + "classification": "D", + "pass": 3, + "owner": "science", + "registered_question": "Does a predefined time-frequency rank summary diagnose a known artifact category beyond direct spectral checks?", + "decision_it_can_change": "Whether to retain a time-frequency diagnostic report.", + "required_data_gate": "D3", + "matrix_or_operator": "real spectrogram matrix or complex Hermitian 4 x 4 cross-spectral density matrix", + "fit_scope": "per_window_read_only with fixed transform parameters", + "baseline": "band power, line-noise ratio, amplitude and protocol artifact context", + "negative_controls": ["line-noise fixture", "broadband motion fixture", "phase-randomized fixture", "eigh versus SVD agreement for Hermitian CSD"], + "primary_metrics": ["defect sensitivity", "false-positive rate", "phase/dtype stability"], + "falsification_criterion": "Retire when direct spectral checks perform equally or phase/threshold handling changes conclusions.", + "implementation_status": "deferred", + "runtime_dependency_authorized": false + }, + { + "method": "SVD pseudoinverse for current M0 logistic classification", + "classification": "F", + "pass": 1, + "owner": "computation", + "registered_question": "Can an SVD pseudoinverse replace the current multiclass logistic M0 objective?", + "decision_it_can_change": "None.", + "required_data_gate": "D0", + "matrix_or_operator": "one-hot least-squares surrogate, not the logistic objective", + "fit_scope": "not applicable", + "baseline": "unchanged L2 logistic M0", + "negative_controls": ["objective mismatch check"], + "primary_metrics": ["not applicable"], + "falsification_criterion": "Already rejected because pseudoinverse least squares is not mathematically equivalent to calibrated multiclass logistic regression.", + "implementation_status": "rejected", + "runtime_dependency_authorized": false + }, + { + "method": "EEGNet activation and kernel rank diagnostics", + "classification": "D", + "pass": 3, + "owner": "science", + "registered_question": "Are named EEGNet activations or filters rank-stable across held-out sessions without changing model selection?", + "decision_it_can_change": "Whether a representation-collapse follow-up is scientifically warranted.", + "required_data_gate": "D3", + "matrix_or_operator": "fold-frozen model activations N_heldout x d and named weight tensors reshaped by a fixed rule", + "fit_scope": "fold_model_only; descriptive post-score analysis", + "baseline": "per-layer norm/variance summaries and random-init comparison", + "negative_controls": ["session-label shuffle", "fixed-size held-out subsampling", "rank-threshold sensitivity"], + "primary_metrics": ["entropy effective rank", "energy rank", "per-session stability"], + "falsification_criterion": "Retire when rank conclusions are session-specific, threshold-sensitive, or do not survive controls.", + "implementation_status": "deferred", + "runtime_dependency_authorized": false + }, + { + "method": "EEGPT and BENDR embedding effective-rank analysis", + "classification": "D", + "pass": 3, + "owner": "science", + "registered_question": "Do pretrained representations show stable effective-rank differences from random-init and mapping controls on identical grouped folds?", + "decision_it_can_change": "Whether representation analysis adds a later descriptive result.", + "required_data_gate": "D3", + "matrix_or_operator": "fold-scoped embedding or adapter-output matrix N_heldout x d", + "fit_scope": "fold_model_only; no global learned representation transform", + "baseline": "M2 random init, shuffled mapping, zero-fill negative control, M0 and M1 evidence", + "negative_controls": ["random initialization", "shuffled channel mapping", "zero-filled unmasked montage", "session-label shuffle"], + "primary_metrics": ["effective-rank stability", "reconstruction curve", "association with existing grouped metrics"], + "falsification_criterion": "Retire pretraining interpretation when control differences vanish or predictive/calibration evidence is absent.", + "implementation_status": "deferred", + "runtime_dependency_authorized": false + }, + { + "method": "low-rank factorization for compression", + "classification": "D", + "pass": 3, + "owner": "computation", + "registered_question": "Can a selected offline model meet a declared resource budget with low-rank factors within an output-drift tolerance?", + "decision_it_can_change": "Whether a later deployment review should compare compression methods.", + "required_data_gate": "post_encoder", + "matrix_or_operator": "selected linear head or adapter weight matrix", + "fit_scope": "offline selected-model analysis only", + "baseline": "uncompressed selected model, quantization, pruning, smaller architecture, and distillation", + "negative_controls": ["matched parameter-count factorization", "output-drift tolerance sweep"], + "primary_metrics": ["storage", "peak memory", "latency", "maximum output drift", "metric retention"], + "falsification_criterion": "Retire when it exceeds output tolerance or loses to simpler compression at equal resource budget.", + "implementation_status": "deferred", + "runtime_dependency_authorized": false + }, + { + "method": "TSVD and identity-Tikhonov synthetic inverse regularization", + "classification": "C", + "pass": 2, + "owner": "science", + "registered_question": "In a known synthetic forward problem, do TSVD or Tikhonov filters improve reconstruction and stability over pseudoinverse?", + "decision_it_can_change": "Whether a specified synthetic inverse regularizer warrants further study.", + "required_data_gate": "D3", + "matrix_or_operator": "discretized synthetic forward operator G and optional penalty L", + "fit_scope": "synthetic training/simulation selection only; unseen synthetic draws for evaluation", + "baseline": "unregularized pseudoinverse", + "negative_controls": ["known source/noise draws", "noise-level sweep", "geometry perturbation", "null-space source cases"], + "primary_metrics": ["source error", "forward residual", "noise stability", "resolution summary"], + "falsification_criterion": "Retire when it does not improve prespecified metrics across unseen noise/geometry conditions.", + "implementation_status": "deferred", + "runtime_dependency_authorized": false + }, + { + "method": "weighted SVD", + "classification": "C", + "pass": 2, + "owner": "science", + "registered_question": "Does an explicitly justified noise covariance improve a synthetic inverse estimate over unweighted regularization?", + "decision_it_can_change": "Whether measurement-noise weighting is warranted in a future inverse study.", + "required_data_gate": "D3", + "matrix_or_operator": "whitened forward system defined by a specified data-noise covariance", + "fit_scope": "synthetic problem with preregistered covariance only", + "baseline": "unweighted TSVD and identity-Tikhonov", + "negative_controls": ["identity covariance", "mis-specified covariance", "known noise draws"], + "primary_metrics": ["reconstruction error", "stability", "covariance sensitivity"], + "falsification_criterion": "Retire when weight assumptions do not improve unseen synthetic performance or are not independently defensible.", + "implementation_status": "deferred", + "runtime_dependency_authorized": false + }, + { + "method": "randomized SVD", + "classification": "F", + "pass": 3, + "owner": "computation", + "registered_question": "Does randomized SVD provide a measured advantage for a future profiled large matrix?", + "decision_it_can_change": "None for the current program.", + "required_data_gate": "post_encoder", + "matrix_or_operator": "future profiled matrix only; not a 4 x 1024 window or current M0 matrix", + "fit_scope": "not applicable at current size", + "baseline": "exact reduced SVD", + "negative_controls": ["exact SVD reconstruction", "seed sensitivity", "residual error"], + "primary_metrics": ["elapsed time", "peak memory", "reconstruction residual", "determinism"], + "falsification_criterion": "Not justified unless exact SVD is a measured bottleneck and approximation meets a preregistered error bound.", + "implementation_status": "rejected", + "runtime_dependency_authorized": false + }, + { + "method": "incremental or streaming SVD", + "classification": "F", + "pass": 3, + "owner": "computation", + "registered_question": "Is an online adaptive decomposition needed by an authorized offline study?", + "decision_it_can_change": "None.", + "required_data_gate": "post_encoder", + "matrix_or_operator": "stateful stream decomposition", + "fit_scope": "not applicable", + "baseline": "exact batch decomposition", + "negative_controls": ["ordering sensitivity", "forgetting-factor sensitivity"], + "primary_metrics": ["not applicable"], + "falsification_criterion": "Rejected: no online adaptive transform or runtime state is authorized.", + "implementation_status": "rejected", + "runtime_dependency_authorized": false + }, + { + "method": "generalized SVD", + "classification": "F", + "pass": 2, + "owner": "science", + "registered_question": "Does a registered nonidentity penalty operator L require a GSVD formulation?", + "decision_it_can_change": "None until a concrete paired-operator inverse problem exists.", + "required_data_gate": "D3", + "matrix_or_operator": "future pair G,L only", + "fit_scope": "not applicable without an approved inverse hypothesis", + "baseline": "identity-Tikhonov and ordinary SVD", + "negative_controls": ["identity penalty reduction", "synthetic known solution"], + "primary_metrics": ["not applicable"], + "falsification_criterion": "Rejected until a nonidentity L is necessary and improves a synthetic study.", + "implementation_status": "rejected", + "runtime_dependency_authorized": false + } + ] +} diff --git a/docs/scoping/svd-eeg-experiment-roadmap.md b/docs/scoping/svd-eeg-experiment-roadmap.md new file mode 100644 index 0000000..82f28b2 --- /dev/null +++ b/docs/scoping/svd-eeg-experiment-roadmap.md @@ -0,0 +1,274 @@ +# SVD EEG Experiment Roadmap + +**Status:** proposed studies only +**Current gate:** D0 +**Decision:** insufficient_evidence +**Promotion status:** not_eligible + +This roadmap operationalizes the SVD decision in +[the SVD decision memo](../research/svd-four-channel-eeg-decision-memo_v1.md). +It does not amend +[EXP-NC-EEG-ENC-001](../../NeuralComposeEEG/experiments/EXP-NC-EEG-ENC-001.md), +the observable acquisition protocol, preprocessing, labels, optimization +budgets, or runtime behavior. + +## Governing Boundaries + +- Input remains exactly four Muse channels: TP9, AF7, AF8, and TP10. +- Every encoder-related evaluation splits complete recording sessions before + any fitted SVD, PCA, or whitening operation. +- A diagnostic from one physical capture cannot establish a physical-data + model claim and cannot change capture eligibility. +- SVD does not infer thought, semantics, intent, source location, or dialogue + policy. It receives no dialogue data. +- A proposal that survives an experiment is at most + supported_for_further_science; it is never a promotion to Rust, Swift, + Core ML, or live control. + +## Shared Artifact Requirements + +Every executed proposal must publish a local-only, numeric artifact with these +fields: + +| Field | Requirement | +| --- | --- | +| dataset_sha256 | canonical dataset hash or synthetic-fixture hash | +| preprocessing_sha256 | fixed preprocessing contract hash | +| split_manifest_sha256 | grouped split hash, or null for a D0 synthetic fixture | +| matrix_definition | exact rows, columns, centering, scaling, and dtype | +| svd_backend | library, version, and LAPACK/BLAS driver when exposed | +| singular_value_threshold | exact rule or null | +| retained_rank | exact value per fold or null | +| fit_scope | synthetic, per-window read-only, training partition only, or fold model only | +| random_seed | explicit integer or null | +| reconstruction_error | measured norm and dtype | +| status | insufficient_evidence, supported_for_further_science, or rejected | +| promotion_status | always not_eligible | +| runtime_dependency_authorized | always false | + +The artifact stores hashes, numeric summaries, and provenance. Raw EEG, +private dialogue, model prompts, and local filesystem paths are not committed. + +## Cross-Study Leakage Controls + +| Risk | Required control | +| --- | --- | +| overlapping windows | split on complete recording session before fit; preserve split manifest | +| learned PCA/whitening | fit means, scales, components, floors, and rank only on train rows | +| rank chosen from result | use preregistered training-only rule; report sensitivity without selecting best test result | +| direct and SVD diagnostic comparison | synthetic faults have fixed truth labels; physical report does not revise capture eligibility | +| session imbalance | report per-session metric and aggregate without allowing one long session to define rank | +| random solver/control | pin seed, rank, oversamples, iterations, and random projection matrix hash | +| activation analysis | collect only after the fold model is frozen; analysis cannot change model prediction or selection | +| external representation | retain canonical dataset/split, fold-scoped adapter, checkpoint, mapping, and mask provenance | + +Minimum negative controls are shuffled session labels, shuffled channel mapping +where a channel-aware representation is used, random orthogonal projection at +the matched dimension, retained-rank permutation or matched-rank control, +no-SVD baseline, synthetic rank-deficient fixtures, and synthetic full-rank +noisy fixtures. A control may be inapplicable only when the contract says why. + +## EXP-NC-SVD-DIAG-001 + +### Question + +> Do read-only singular-spectrum diagnostics identify capture or +> feature-matrix defects not already identified by simpler deterministic +> checks? + +### Scope + +- **Classification:** B. Optional Pass 1 controlled experiment. +- **Earliest synthetic execution:** D0. +- **Earliest physical report:** D1, after an integrity-valid capture. +- **Owner:** Engineering for capture fixtures/reporting; Science for analysis. +- **No encoder behavior change:** every output is report-only. + +### Matrix, Baseline, and Controls + +For each channel-centered spatial window, X has shape 4 by 1024. The candidate +report contains the four singular values, energy fractions, entropy effective +rank, and condition number only when a pinned numerical floor makes it finite. +A second report may describe the training-only standardized M0 feature matrix +Z_train once D2 data exists. The two reports must never be combined into one +notion of rank. + +The direct baseline is channel variance, amplitude range, pairwise correlation, +difference RMS where defined, packet loss, signal-quality fields, and +missing-channel masks. D0 fixtures must contain known: + +- flat/disconnected channel +- duplicated channel +- near-collinear channel +- common-mode contamination +- full-rank noisy window +- nonfinite/missing channel, rejected before SVD + +Each fixture carries fixed defect truth. Float32 and float64 spectra are +compared; a conclusion that changes across the preregistered numerical +threshold sensitivity set is reported unstable. + +### Outcomes, Falsification, and Artifact + +Primary outcomes are deterministic reproducibility, defect sensitivity, +false-positive rate, and incremental value over the direct baseline. Reject the +SVD report if it detects no defect not already caught by direct checks, adds +false positives, or produces unstable conclusions across dtype/threshold +choices. A positive result supports retaining only a read-only report. + +The artifact is nc-eeg-svd-diagnostic-report-v0 with fixture/capture hashes, +matrix definition, complete spectrum, direct-check comparison, threshold +sensitivity, and capture_eligibility_changed: false. + +## EXP-NC-SVD-M0-001 + +### Question + +> Under identical complete-session splits, does a preregistered train-only +> truncated-SVD feature path improve grouped calibration or generalization over +> the unchanged M0 baseline? + +### Scope + +- **Classification:** B. Optional Pass 1 controlled experiment. +- **Earliest execution:** D2, after a source-manifest-eligible multi-day + cohort exists. +- **Owner:** Science with Engineering-owned canonical dataset. +- **Existing M0:** unchanged; this is a separate, named sensitivity study. + +### Conditions + +All conditions use the same canonical data, label order, grouped split +manifest, and existing M0 logistic-head budget. The baseline is current M0: +training-fold standardization plus L2 logistic regression with pinned C = 1. + +| ID | Condition | Fit scope | +| --- | --- | --- | +| B0 | unchanged M0 | existing train-fold scaler and logistic fit | +| B1 | non-whitened PCA/truncated-SVD features plus unchanged L2 logistic head | training fold only | +| B2 | whitened PCA features plus unchanged L2 logistic head | training fold only | +| B3 | random orthogonal projection with B1's per-fold output dimension plus unchanged head | training fold scaling; map pinned by seed | +| B4 | no-SVD feature-count-matched deterministic selector, if preregistered | training fold only | + +The primary rank is the smallest r reaching 0.95 cumulative explained variance +on the training standardized feature matrix, bounded by +1 <= r <= min(p, n_train - 1). The 0.90 and 0.99 rules are mandatory +sensitivity analyses, not a test-set rank search. Whitening uses a pinned +training-derived singular-value floor. No transformation is fit from test +windows, including calibration/task data in the held-out session. + +An unregularized logistic condition is an optional numerical stress check, not +a candidate production baseline. A pseudoinverse/least-squares score is only a +mathematical control because it optimizes a different objective from multiclass +logistic regression. + +### Outcomes, Falsification, and Artifact + +Primary outcomes match the encoder program: balanced accuracy where defined, +macro F1, Brier score, expected calibration error, artifact metrics, +per-session results, training/inference time, and checkpoint size. The primary +comparison is B1 versus B0; B2/B3/B4 test whether any effect is just dimension +reduction or whitening. + +Retire truncated-SVD features if they do not improve grouped held-out +calibration/generalization, lose to B3, have unstable fold ranks, worsen +artifact behavior, or rely on a test-informed choice. A positive result stays +supported_for_further_science; it cannot amend experiment-v0.json. + +The artifact is nc-eeg-svd-m0-evaluation-v0 with per-fold train-window digest, +component/scaler hashes, explained variance, retained rank, singular-value +floor, predictions keyed by canonical raw-window hashes, and the canonical +split-manifest hash. + +## EXP-NC-SVD-REP-001 + +### Question + +> Do selected-model EEGPT/BENDR representations show more stable effective +> rank across held-out sessions than random-init, shuffled-mapping, and +> zero-fill controls? + +### Scope and Contract + +- **Classification:** D. Pass 3 computational or analysis tooling. +- **Earliest execution:** D3, after encoder evidence and a frozen + representation contract; it does not select an encoder. +- **Owner:** Science and Computation. + +Use the same canonical dataset, grouped split manifest, model checkpoint, +adapter contract, and experiment budget as the representation being described. +For each fold, collect named activations or embeddings only after the model +has finished training. Build per-session matrices with rows as a fixed number +of held-out windows and columns as a named layer dimension. Use seeded +subsampling when session counts differ. + +Compare M2 random initialization, M3 EEGPT, M4 BENDR where eligible, plus the +required shuffled-map and zero-fill controls. Do not fit global PCA/whitening +across sessions. Do not use effective rank to tune adapter size, epochs, or +select a model. + +### Outcomes, Falsification, and Artifact + +Report spectral entropy effective rank, energy ranks at preregistered +thresholds, reconstruction curves, rank stability across session holdouts, and +association with existing held-out metrics as descriptive statistics. Reject a +pretraining interpretation if differences vanish under controls, are +threshold-sensitive, are session-specific, or lack the existing grouped +predictive/calibration evidence. A stable rank alone does not prove transfer. + +The artifact is nc-eeg-svd-representation-report-v0, bound to canonical +dataset/split, checkpoint, adapter/mask, held-out group, layer identifier, +activation shape, subsampling seed, spectrum, thresholds, and controls. + +## EXP-NC-SVD-INV-001 + +### Question + +> In a synthetic forward model with known source and noise structure, do TSVD +> or Tikhonov regularizers improve reconstruction error and stability relative +> to an unregularized pseudoinverse? + +### Scope and Required Preregistration + +- **Classification:** C. Pass 2 inverse/forward-model method. +- **Earliest execution:** D3 and only after a specific forward-model + hypothesis is approved. +- **Owner:** Science; Julia/Python reference implementation only. +- **Physical claim:** prohibited. This is not Muse source localization. + +Before execution, name the discretized forward operator G, source/sensor +geometry, conductivity model, boundary assumptions, source target, noise +distribution/covariance, penalty L, metrics, and synthetic generator seeds. +Explain why the study does not make a human source-localization claim. + +Compare unregularized pseudoinverse, simulation-selected TSVD, +identity-Tikhonov, and generalized SVD only when a real nonidentity L exists. +Evaluate unseen synthetic source/noise draws. Report source error, forward +residual, stability under noise/geometry perturbation, resolution/null-space +summaries, and regularization parameters. + +Reject a regularizer if it does not reduce preregistered reconstruction or +stability metrics against the pseudoinverse, only works at a hand-picked noise +level, or is not robust under the stated sensitivity study. No result +authorizes physical Muse source localization, app input, or production code. + +The artifact is nc-eeg-svd-inverse-synthetic-v0 with operator hash, +geometry/noise/penalty specification, precision, singular-value filters, +unseen synthetic seed sets, and physical_muse_claim: false. + +## Method Sequence and Deferrals + + D0: verify fixture math and artifact formats only + -> D1: optional read-only capture diagnostics + -> D2: optional grouped M0 sensitivity experiment + -> D3: representation diagnostics OR separately registered synthetic inverse study + -> post_encoder: offline compression profiling only + +The arrows are eligibility gates, not a commitment to run every method. A +failed or nonincremental study retires its SVD use instead of escalating it. + +The following are explicitly deferred or rejected: spatial PCA-based source +estimates, temporal denoisers/SSA/DMD without a target, ICA/robust PCA/NMF/ +tensor decomposition/autoencoders/CCA in Pass 1, randomized or incremental +SVD for current tiny matrices, online SVD-derived state, Swift/Rust/Core ML +integration, live control, and any upload of local EEG merely for SVD speed. diff --git a/docs/termux/healthcheck-neuralcompose-services.sh b/docs/termux/healthcheck-neuralcompose-services.sh new file mode 100644 index 0000000..e3f4062 --- /dev/null +++ b/docs/termux/healthcheck-neuralcompose-services.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ────────────────────────────────────────────────────────────── +# healthcheck-neuralcompose-services.sh — verify managed services +# are responding correctly on the Pixel 8a. +# ────────────────────────────────────────────────────────────── + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +RUNTIME_DIR="$REPO_ROOT/.runtime/neuralcompose" +CONFIG_FILE="$RUNTIME_DIR/neuralcompose-services.env" + +if [[ -f "$CONFIG_FILE" ]]; then + # shellcheck source=/dev/null + source "$CONFIG_FILE" +fi + +QWEN_PORT="${QWEN_PORT:-8081}" +QWEN_MODEL="${QWEN_MODEL:-qwen}" +EMBED_PORT="${EMBED_PORT:-8083}" +STT_PORT="${STT_PORT:-8084}" +METRO_PORT="${METRO_PORT:-8082}" + +failed=0 + +_check_qwen() { + local resp + resp="$(curl -s http://127.0.0.1:"$QWEN_PORT"/v1/models 2>/dev/null)" || { + echo " [qwen] FAIL: endpoint unreachable on port $QWEN_PORT" + return 1 + } + if echo "$resp" | grep -qi "$QWEN_MODEL" 2>/dev/null; then + echo " [qwen] OK: model $QWEN_MODEL identified" + else + echo " [qwen] WARN: endpoint reachable but model not confirmed" + echo " [qwen] response: $(echo "$resp" | head -c 200)" + fi +} + +_check_embed() { + if ! nc -z 127.0.0.1 "$EMBED_PORT" 2>/dev/null; then + echo " [embed] SKIP: not running on port $EMBED_PORT" + return 0 + fi + local resp + resp="$(curl -s http://127.0.0.1:"$EMBED_PORT"/v1/embeddings \ + -H 'Content-Type: application/json' \ + -d '{"input":"probe","model":"default"}' 2>/dev/null)" || { + echo " [embed] FAIL: endpoint unreachable" + return 1 + } + local dim + dim="$(echo "$resp" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d['data'][0]['embedding']))" 2>/dev/null)" || { + echo " [embed] FAIL: response did not contain expected embedding shape" + return 1 + } + echo " [embed] OK: dimension $dim" +} + +_check_stt() { + if ! nc -z 127.0.0.1 "$STT_PORT" 2>/dev/null; then + echo " [stt] SKIP: not running on port $STT_PORT" + return 0 + fi + local resp + resp="$(curl -s http://127.0.0.1:"$STT_PORT"/health 2>/dev/null)" || { + echo " [stt] FAIL: endpoint unreachable" + return 1 + } + echo " [stt] OK: health endpoint responded" +} + +_check_metro() { + if ! nc -z 127.0.0.1 "$METRO_PORT" 2>/dev/null; then + echo " [metro] SKIP: not running on port $METRO_PORT" + return 0 + fi + local resp + resp="$(curl -s http://127.0.0.1:"$METRO_PORT"/ 2>/dev/null)" || { + echo " [metro] FAIL: endpoint unreachable" + return 1 + } + echo " [metro] OK: port $METRO_PORT responding" +} + +echo "NeuralCompose services — healthcheck" +echo "" + +_check_qwen || failed=$((failed + 1)) +_check_embed || failed=$((failed + 1)) +_check_stt || failed=$((failed + 1)) +_check_metro || failed=$((failed + 1)) + +echo "" +if [[ "$failed" -eq 0 ]]; then + echo "All services healthy." +else + echo "$failed service(s) unhealthy." + exit 1 +fi diff --git a/docs/termux/neuralcompose-services.env.example b/docs/termux/neuralcompose-services.env.example new file mode 100644 index 0000000..fb18884 --- /dev/null +++ b/docs/termux/neuralcompose-services.env.example @@ -0,0 +1,23 @@ +# NeuralCompose Pixel 8a local service configuration +# Copy to .runtime/neuralcompose/neuralcompose-services.env and edit + +# Qwen generation server +QWEN_PORT=8081 +QWEN_MODEL=$HOME/models/qwen2.5-0.5b-instruct-q4_k_m.gguf +QWEN_BIN=$HOME/llama.cpp/build/bin/llama-server +QWEN_CONTEXT=1024 +QWEN_THREADS=2 + +# Sentence embedding server (not yet configured) +EMBED_PORT=8083 +# EMBED_BIN=$HOME/... +# EMBED_MODEL=$HOME/... + +# STT / Whisper server (not yet configured) +STT_PORT=8084 +# STT_BIN=$HOME/... +# STT_MODEL=$HOME/... + +# Metro bundler +METRO_PORT=8082 +# METRO_DIR=$HOME/neuralcompose-client diff --git a/docs/termux/snapshot-memory.sh b/docs/termux/snapshot-memory.sh new file mode 100644 index 0000000..f95fb9f --- /dev/null +++ b/docs/termux/snapshot-memory.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ────────────────────────────────────────────────────────────── +# snapshot-memory.sh — record system memory state on the Pixel 8a +# without capturing private content. +# +# Writes to .runtime/neuralcompose/state/memory-snapshot-*.txt +# ────────────────────────────────────────────────────────────── + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +RUNTIME_DIR="$REPO_ROOT/.runtime/neuralcompose" +SNAPSHOT_FILE="$RUNTIME_DIR/state/memory-snapshot-$(date +%Y%m%d-%H%M%S).txt" + +mkdir -p "$RUNTIME_DIR/state" + +{ + echo "=== Memory Snapshot: $(date -Iseconds) ===" + echo "" + + echo "--- /proc/meminfo ---" + cat /proc/meminfo 2>/dev/null || echo "unavailable" + + echo "" + echo "--- Top processes by RSS ---" + ps aux --sort=-%mem 2>/dev/null | head -20 || ps aux 2>/dev/null | head -20 || echo "unavailable" + + echo "" + echo "--- Managed service RSS ---" + for pid_file in "$RUNTIME_DIR/pids"/*.pid; do + [[ -f "$pid_file" ]] || continue + name="$(basename "$pid_file" .pid)" + pid="$(cat "$pid_file" 2>/dev/null)" || continue + if kill -0 "$pid" 2>/dev/null; then + rss="$(ps -p "$pid" -o rss= 2>/dev/null | awk '{printf "%.0f MB", $1/1024}')" || rss="?" + cmd="$(ps -p "$pid" -o comm= 2>/dev/null)" || cmd="?" + echo " $name (PID $pid): $rss — $cmd" + else + echo " $name: stale PID $pid" + fi + done + + echo "" + echo "--- Termux processes ---" + ps aux 2>/dev/null | grep -i "termux\|com.termux" | head -10 || echo "unavailable" + + echo "" + echo "--- Available storage ---" + df -h /data/data/com.termux/files/home 2>/dev/null || df -h / 2>/dev/null || echo "unavailable" + + echo "" + echo "--- Thermal ---" + cat /sys/class/thermal/thermal_zone*/temp 2>/dev/null | head -5 || echo "unavailable" + + echo "" + echo "=== End ===" +} > "$SNAPSHOT_FILE" + +echo "Snapshot written to $SNAPSHOT_FILE" diff --git a/docs/termux/start-neuralcompose-services.sh b/docs/termux/start-neuralcompose-services.sh new file mode 100644 index 0000000..31dd53c --- /dev/null +++ b/docs/termux/start-neuralcompose-services.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2317 +set -euo pipefail + +# ────────────────────────────────────────────────────────────── +# start-neuralcompose-services.sh — bring up local runtime services +# on the Pixel 8a for device validation. +# +# Usage: +# ./scripts/termux/start-neuralcompose-services.sh [--runtime|--dev|--all] +# +# --runtime Qwen generation + embeddings + STT (default) +# --dev Metro bundler only +# --all everything +# ────────────────────────────────────────────────────────────── + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +RUNTIME_DIR="$REPO_ROOT/.runtime/neuralcompose" +CONFIG_FILE="$REPO_ROOT/.runtime/neuralcompose/neuralcompose-services.env" +MODE="${1:---runtime}" + +mkdir -p "$RUNTIME_DIR/pids" "$RUNTIME_DIR/logs" "$RUNTIME_DIR/state" + +# Source local config if present +if [[ -f "$CONFIG_FILE" ]]; then + # shellcheck source=/dev/null + source "$CONFIG_FILE" +fi + +# Defaults — override in neuralcompose-services.env +QWEN_PORT="${QWEN_PORT:-8081}" +QWEN_MODEL="${QWEN_MODEL:-$HOME/models/qwen2.5-0.5b-instruct-q4_k_m.gguf}" +QWEN_BIN="${QWEN_BIN:-$HOME/llama.cpp/build/bin/llama-server}" +QWEN_CONTEXT="${QWEN_CONTEXT:-1024}" +QWEN_THREADS="${QWEN_THREADS:-2}" + +EMBED_PORT="${EMBED_PORT:-8083}" +EMBED_BIN="${EMBED_BIN:-}" +EMBED_MODEL="${EMBED_MODEL:-}" + +STT_PORT="${STT_PORT:-8084}" +STT_BIN="${STT_BIN:-}" +STT_MODEL="${STT_MODEL:-}" + +METRO_PORT="${METRO_PORT:-8082}" +METRO_DIR="${METRO_DIR:-$REPO_ROOT}" + +# ── Helpers ────────────────────────────────────────────────── + +_pid_file() { echo "$RUNTIME_DIR/pids/$1.pid"; } +_log_file() { echo "$RUNTIME_DIR/logs/$1.log"; } +_err_file() { echo "$RUNTIME_DIR/logs/$1.err"; } + +_is_running() { + local pid_file; pid_file="$(_pid_file "$1")" + [[ -f "$pid_file" ]] || return 1 + local pid; pid="$(cat "$pid_file" 2>/dev/null)" || return 1 + [[ -n "$pid" ]] || return 1 + kill -0 "$pid" 2>/dev/null || return 1 + # Verify it's the expected process + local cmd; cmd="$(ps -p "$pid" -o comm= 2>/dev/null)" || return 1 + [[ "$cmd" == *"$2"* ]] || return 1 + return 0 +} + +_start_service() { + local name="$1" match="$2" pid_file; pid_file="$(_pid_file "$name")" + if _is_running "$name" "$match"; then + echo " [$name] already running (PID $(cat "$pid_file"))" + return 0 + fi + shift 2 + echo " [$name] starting..." + nohup "$@" > "$(_log_file "$name")" 2> "$(_err_file "$name")" & + local pid=$! + echo "$pid" > "$pid_file" + echo " [$name] PID $pid" +} + +_wait_for_port() { + local port="$1" timeout="${2:-10}" + for i in $(seq 1 "$timeout"); do + if nc -z 127.0.0.1 "$port" 2>/dev/null; then + return 0 + fi + sleep 1 + done + return 1 +} + +_wait_for_model_ready() { + local port="$1" model_fragment="$2" timeout="${3:-30}" + for i in $(seq 1 "$timeout"); do + local resp + resp="$(curl -s http://127.0.0.1:"$port"/v1/models 2>/dev/null || true)" + if echo "$resp" | grep -qi "$model_fragment" 2>/dev/null; then + return 0 + fi + sleep 1 + done + return 1 +} + +# ── Start functions ───────────────────────────────────────── + +_start_qwen() { + if [[ ! -f "$QWEN_BIN" ]]; then + echo " [qwen] SKIP: $QWEN_BIN not found" + return 1 + fi + if [[ ! -f "$QWEN_MODEL" ]]; then + echo " [qwen] SKIP: $QWEN_MODEL not found" + return 1 + fi + _start_service "qwen" "llama-server" \ + "$QWEN_BIN" \ + -m "$QWEN_MODEL" \ + --port "$QWEN_PORT" --host 127.0.0.1 \ + -c "$QWEN_CONTEXT" -t "$QWEN_THREADS" + if _wait_for_port "$QWEN_PORT" 15; then + if _wait_for_model_ready "$QWEN_PORT" "qwen" 30; then + echo " [qwen] ready on port $QWEN_PORT" + else + echo " [qwen] WARNING: port open but model not confirmed" + fi + else + echo " [qwen] FAILED: port $QWEN_PORT not listening" + return 1 + fi +} + +_start_metro() { + if ! command -v npx &>/dev/null; then + echo " [metro] SKIP: npx not found" + return 1 + fi + if [[ ! -f "$METRO_DIR/package.json" ]]; then + echo " [metro] SKIP: no package.json in $METRO_DIR" + return 1 + fi + _start_service "metro" "node" \ + npx expo start --port "$METRO_PORT" --no-dev --minify + if _wait_for_port "$METRO_PORT" 30; then + echo " [metro] ready on port $METRO_PORT" + else + echo " [metro] WARNING: port $METRO_PORT not confirmed" + fi +} + +# ── Main ───────────────────────────────────────────────────── + +echo "NeuralCompose services — starting ($MODE)" + +case "$MODE" in + --runtime) + _start_qwen + # Embeddings and STT are not yet configured — add when binaries exist + echo " [embed] SKIP: no binary configured" + echo " [stt] SKIP: no binary configured" + ;; + --dev) + _start_metro + ;; + --all) + _start_qwen + _start_metro + echo " [embed] SKIP: no binary configured" + echo " [stt] SKIP: no binary configured" + ;; + *) + echo "Unknown mode: $MODE" >&2 + echo "Usage: $0 [--runtime|--dev|--all]" >&2 + exit 1 + ;; +esac + +echo "Done." diff --git a/docs/termux/status-neuralcompose-services.sh b/docs/termux/status-neuralcompose-services.sh new file mode 100644 index 0000000..295e2f0 --- /dev/null +++ b/docs/termux/status-neuralcompose-services.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ────────────────────────────────────────────────────────────── +# status-neuralcompose-services.sh — show managed service state +# on the Pixel 8a. +# ────────────────────────────────────────────────────────────── + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +RUNTIME_DIR="$REPO_ROOT/.runtime/neuralcompose" +CONFIG_FILE="$RUNTIME_DIR/neuralcompose-services.env" + +if [[ -f "$CONFIG_FILE" ]]; then + # shellcheck source=/dev/null + source "$CONFIG_FILE" +fi + +QWEN_PORT="${QWEN_PORT:-8081}" +EMBED_PORT="${EMBED_PORT:-8083}" +STT_PORT="${STT_PORT:-8084}" +METRO_PORT="${METRO_PORT:-8082}" + +_service_status() { + local name="$1" match="$2" port="$3" + local pid_file="$RUNTIME_DIR/pids/$name.pid" + local log_file="$RUNTIME_DIR/logs/$name.log" + local err_file="$RUNTIME_DIR/logs/$name.err" + local configured="yes" + local status="stopped" + local rss="" + local listening="" + + # Check if configured + case "$name" in + qwen) + if [[ ! -f "${QWEN_BIN:-}" ]]; then configured="no (no binary)"; fi + if [[ ! -f "${QWEN_MODEL:-}" ]]; then configured="no (no model)"; fi + ;; + embed) + if [[ -z "${EMBED_BIN:-}" ]]; then configured="no (not configured)"; fi + ;; + stt) + if [[ -z "${STT_BIN:-}" ]]; then configured="no (not configured)"; fi + ;; + metro) + if ! command -v npx &>/dev/null; then configured="no (no npx)"; fi + ;; + esac + + # Check PID + if [[ -f "$pid_file" ]]; then + local pid + pid="$(cat "$pid_file" 2>/dev/null)" || { status="stale PID"; } + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + local cmd + cmd="$(ps -p "$pid" -o comm= 2>/dev/null)" || cmd="" + if [[ "$cmd" == *"$match"* ]]; then + status="running (PID $pid)" + rss="$(ps -p "$pid" -o rss= 2>/dev/null | awk '{printf "%.0f MB", $1/1024}')" || rss="" + else + status="WRONG PROCESS: $cmd" + fi + else + status="stale PID" + fi + fi + + # Check port + if nc -z 127.0.0.1 "$port" 2>/dev/null; then + listening="port $port open" + fi + + # Check readiness for running services + local ready="" + if [[ "$status" == "running"* ]]; then + case "$name" in + qwen) + local resp + resp="$(curl -s http://127.0.0.1:"$port"/v1/models 2>/dev/null || true)" + if echo "$resp" | grep -qi "qwen" 2>/dev/null; then + ready="model confirmed" + else + ready="port open, model unconfirmed" + fi + ;; + metro) + if nc -z 127.0.0.1 "$port" 2>/dev/null; then + ready="port open" + fi + ;; + esac + fi + + # Log size + local log_size="" + if [[ -f "$log_file" ]]; then + log_size="$(du -h "$log_file" 2>/dev/null | cut -f1)" + fi + + printf " %-8s %-30s %-12s %-20s %s\n" "$name" "$status" "$rss" "$listening" "$ready" + if [[ -n "$log_size" ]]; then + printf " %-8s log: %s\n" "" "$log_file ($log_size)" + fi +} + +echo "NeuralCompose services — status" +echo "" +printf " %-8s %-30s %-12s %-20s %s\n" "Service" "Status" "RSS" "Network" "Readiness" +printf " %-8s %-30s %-12s %-20s %s\n" "--------" "------" "---" "-------" "--------" + +_service_status "qwen" "llama-server" "$QWEN_PORT" +_service_status "embed" "llama-server" "$EMBED_PORT" +_service_status "stt" "whisper" "$STT_PORT" +_service_status "metro" "node" "$METRO_PORT" + +# Total RSS +echo "" +total_rss=0 +for pid_file in "$RUNTIME_DIR/pids"/*.pid; do + [[ -f "$pid_file" ]] || continue + pid="$(cat "$pid_file" 2>/dev/null)" || continue + if kill -0 "$pid" 2>/dev/null; then + rss_kb="$(ps -p "$pid" -o rss= 2>/dev/null)" || continue + total_rss=$((total_rss + rss_kb)) + fi +done +echo " Total managed RSS: $(awk "BEGIN {printf \"%.0f MB\", $total_rss/1024}")" diff --git a/docs/termux/stop-neuralcompose-services.sh b/docs/termux/stop-neuralcompose-services.sh new file mode 100644 index 0000000..59bc4d3 --- /dev/null +++ b/docs/termux/stop-neuralcompose-services.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ────────────────────────────────────────────────────────────── +# stop-neuralcompose-services.sh — safely stop managed services +# on the Pixel 8a. +# +# Usage: +# ./scripts/termux/stop-neuralcompose-services.sh [--runtime|--all] +# +# --runtime stop Qwen + embeddings + STT (default) +# --all stop everything including Metro +# ────────────────────────────────────────────────────────────── + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +RUNTIME_DIR="$REPO_ROOT/.runtime/neuralcompose" +MODE="${1:---runtime}" + +_stop_service() { + local name="$1" match="$2" + local pid_file="$RUNTIME_DIR/pids/$name.pid" + local log_file="$RUNTIME_DIR/logs/$name.log" + + if [[ ! -f "$pid_file" ]]; then + echo " [$name] not running (no PID file)" + return 0 + fi + + local pid + pid="$(cat "$pid_file" 2>/dev/null)" || { echo " [$name] stale PID file"; rm -f "$pid_file"; return 0; } + + # Verify PID belongs to expected process + local cmd + cmd="$(ps -p "$pid" -o comm= 2>/dev/null)" || { echo " [$name] PID $pid not found (stale)"; rm -f "$pid_file"; return 0; } + if [[ "$cmd" != *"$match"* ]]; then + echo " [$name] PID $pid is $cmd, not $match — leaving untouched" + return 0 + fi + + echo " [$name] sending TERM to PID $pid" + kill -TERM "$pid" 2>/dev/null || true + + # Wait with bounded timeout + local waited=0 + while kill -0 "$pid" 2>/dev/null; do + sleep 1 + waited=$((waited + 1)) + if [[ "$waited" -ge 10 ]]; then + echo " [$name] timeout — sending KILL to PID $pid" + kill -KILL "$pid" 2>/dev/null || true + break + fi + done + + rm -f "$pid_file" + echo " [$name] stopped" +} + +echo "NeuralCompose services — stopping ($MODE)" + +_stop_service "qwen" "llama-server" +_stop_service "embed" "llama-server" # same binary, different port +_stop_service "stt" "whisper" + +if [[ "$MODE" == "--all" ]]; then + _stop_service "metro" "node" +fi + +echo "Done."