diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0718b12..5698cfb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.11", "3.12"] + python-version: ["3.11", "3.12", "3.13"] steps: - name: Checkout @@ -39,8 +39,18 @@ jobs: run: | pytest -q --cov=synapdrive_ai --cov-report=term-missing --cov-report=xml --cov-fail-under=50 + - name: Run release validation + run: | + python -m scripts.run_v1_validation --out validation.json + - name: Upload coverage artifact uses: actions/upload-artifact@v4 with: name: coverage-xml-py${{ matrix.python-version }} path: coverage.xml + + - name: Upload validation artifact + uses: actions/upload-artifact@v4 + with: + name: validation-py${{ matrix.python-version }} + path: validation.json diff --git a/.gitignore b/.gitignore index baaa07c..76efcd5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,14 @@ build/ *.egg-info/ .ruff_cache/ .pre-commit-cache/ +.coverage +coverage.xml +validation*.json +*.pem +*.key +*.p12 +*.pfx +.env +*.evidence.jsonl +*.signature.json +runs.jsonl diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..f24e4e9 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,18 @@ +# Code of Conduct + +SynapDrive-AI discussions should remain professional, technical, and respectful. + +## Expected behavior + +- Critique ideas, evidence, code, and claims rather than people. +- Keep technical disagreements specific and reproducible when possible. +- Respect privacy and do not publish personal, participant, credential, or device data without authorization. +- Correct inaccurate claims when evidence changes. + +## Unacceptable behavior + +Harassment, discrimination, threats, doxxing, personal attacks, or sustained disruption are not acceptable. + +## Enforcement + +Project maintainers may remove content or restrict participation when behavior violates this policy. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 21f1079..2c258ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,71 +1,44 @@ # Contributing to SynapDrive-AI -Thanks for your interest. - -## Project stance -SynapDrive-AI is **simulation-first**. We accept contributions that improve: -- reproducibility (install/run/test) -- safety gating behavior -- telemetry/logging consistency -- modular adapters (optional integrations that do not break the core demo) -- documentation accuracy - -We do **not** accept medical/clinical claims. +SynapDrive-AI is simulation-first research software. Contributions are expected to preserve the repository's claim boundaries and fail-closed behavior. ## Development setup + ```bash python -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt -pip install -r requirements-dev.txt -pre-commit install -pytest -q - -Pull Request checklist - - Tests pass locally (pytest -q) - - Lint passes (ruff check .) - - Type check passes (pyright) +source .venv/bin/activate # Windows PowerShell: .venv\Scripts\Activate.ps1 +python -m pip install --upgrade pip +python -m pip install -e ".[dev]" +``` - README/docs updated if behavior changes +Run the local quality surface: - No new hard dependencies without discussion - -Reporting issues - -Please include: - -OS + Python version - -exact command you ran - -traceback/logs +```bash +python -m compileall -q synapdrive_ai core scripts +python -m pytest -q +python -m scripts.run_v1_validation +ruff check . +pyright +``` -expected vs actual behavior +## Pull request requirements +- Tests cover every behavioral change, including a negative control when a failure mode matters. +- Unknown, malformed, unqualified, or analysis-only inputs fail closed. +- New decoders do not receive authority merely because they produce a class label. +- Synthetic fixtures are labeled as synthetic and are never described as participant performance. +- Hardware acquisition paths do not invent intent when no decoder is configured. +- Documentation states what is executed and what remains unvalidated. +- No private keys, participant data, device credentials, or generated build artifacts are committed. ---- +## Scientific and safety claims -## 2) `CODE_OF_CONDUCT.md` (NEW) -```markdown -# Code of Conduct +Do not submit claims of clinical validity, medical-device safety, participant generalization, safe physical actuation, or real-world BCI performance unless the corresponding external evidence actually exists and is cited separately from repository tests. -We are committed to providing a welcoming, professional environment. +Software tests establish software behavior only. -## Expected behavior -- Be respectful and constructive -- Assume good intent -- Focus on technical issues, not people -- Keep discussions on-topic +## Reporting a defect -## Unacceptable behavior -- Harassment, insults, or personal attacks -- Discrimination or hate speech -- Doxxing or sharing private information -- Sustained disruption of discussions +Include the operating system, Python version, exact command, traceback or logs, expected behavior, actual behavior, and the smallest reproducible input that can be shared safely. -## Enforcement -Project maintainers may remove content, lock threads, or restrict participation -when behavior violates this policy. +For security-sensitive reports, follow `SECURITY.md` rather than opening a public issue with exploit details. diff --git a/Dockerfile b/Dockerfile index bf03264..e707a9d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Dockerfile -# SynapDrive-AI — simulation-first, reproducible runtime container. +# SynapDrive-AI: simulation-first, reproducible runtime container. FROM python:3.12-slim @@ -23,11 +23,12 @@ ARG INSTALL_DEV=false RUN pip install -r requirements.txt && \ if [ "$INSTALL_DEV" = "true" ]; then pip install -r requirements-dev.txt; fi -# Copy the rest of the repo +# Copy the rest of the repo and install the package so console entry points are available. COPY . /app +RUN pip install --no-deps . # Default port used by the web dashboard EXPOSE 5055 # Default command = show CLI help (safe default) -CMD ["python", "-m", "synapdrive_ai", "-h"] +CMD ["synapdrive-ai", "-h"] diff --git a/Makefile b/Makefile index 570c2e8..750aab3 100644 --- a/Makefile +++ b/Makefile @@ -1,30 +1,29 @@ -# Makefile — fast, repeatable commands for SynapDrive-AI +# Makefile: repeatable SynapDrive-AI validation and research commands. PY ?= python -.PHONY: help install install-dev run run-signal dashboard test lint typecheck record replay \ - docker-build docker-build-dev docker-run docker-dashboard docker-test +.PHONY: help install install-dev run run-signal dashboard benchmark stress evidence-test \ + test test-core lint typecheck quality record replay docker-build docker-build-dev \ + docker-run docker-dashboard docker-test help: @echo "" - @echo "Targets:" - @echo " make install Install runtime deps" - @echo " make install-dev Install dev deps (ruff/pyright/pytest/pre-commit)" - @echo " make run Run one CLI cycle (text example)" - @echo " make run-signal Run one CLI cycle (signal example)" - @echo " make dashboard Run the Flask dashboard (localhost:5055)" - @echo " make test Run pytest" + @echo "Core:" + @echo " make install Install runtime dependencies" + @echo " make install-dev Install development dependencies" + @echo " make run Run a governed text cycle" + @echo " make run-signal Run an explicit synthetic fixture cycle" + @echo " make dashboard Run the local WSGI dashboard" + @echo " make test Run the complete pytest suite" + @echo " make test-core Run the core test subset" + @echo " make quality Compile + test + validation smoke" @echo " make lint Run ruff" @echo " make typecheck Run pyright" - @echo " make record Record a run to runs.jsonl (no-delay)" - @echo " make replay Replay runs.jsonl" @echo "" - @echo "Docker:" - @echo " make docker-build Build runtime image" - @echo " make docker-build-dev Build dev image (includes dev deps)" - @echo " make docker-run Run CLI in container (text example)" - @echo " make docker-dashboard Run dashboard in container (localhost:5055)" - @echo " make docker-test Run pytest in container" + @echo "Research tools:" + @echo " synapdrive-benchmark DATASET.npz" + @echo " synapdrive-stress --runs 100 --seed 7" + @echo " synapdrive-evidence verify-chain evidence.jsonl" @echo "" install: @@ -36,33 +35,46 @@ install-dev: $(PY) -m pip install -e ".[dev]" run: - $(PY) -m synapdrive_ai --text "move left" --image road + $(PY) -m synapdrive_ai --text "move left" --image road --no-delay run-signal: - $(PY) -m synapdrive_ai --signal walk + $(PY) -m synapdrive_ai --signal walk --no-delay dashboard: $(PY) -m synapdrive_ai.interface.web_dashboard +benchmark: + @echo "Usage: synapdrive-benchmark DATASET.npz [--decoder ensemble]" + +stress: + $(PY) -m synapdrive_ai.stress.cli --runs 25 --seed 7 + +evidence-test: + @echo "Usage: synapdrive-evidence verify-chain evidence.jsonl" + test: $(PY) -m pytest -q +test-core: + $(PY) -m pytest -q synapdrive_ai/tests --ignore=synapdrive_ai/tests/test_web_dashboard.py + lint: $(PY) -m ruff check . typecheck: $(PY) -m pyright +quality: + $(PY) -m compileall -q synapdrive_ai core scripts + $(PY) -m pytest -q + $(PY) -m scripts.run_v1_validation + record: $(PY) -m synapdrive_ai --text "move left" --image road --record runs.jsonl --no-delay replay: $(PY) -m synapdrive_ai --replay runs.jsonl -# ----------------------- -# Docker targets -# ----------------------- - docker-build: docker build -t synapdrive-ai:latest . @@ -70,7 +82,7 @@ docker-build-dev: docker build --build-arg INSTALL_DEV=true -t synapdrive-ai:dev . docker-run: docker-build - docker run --rm synapdrive-ai:latest python -m synapdrive_ai --text "move left" --image road + docker run --rm synapdrive-ai:latest python -m synapdrive_ai --text "move left" --no-delay docker-dashboard: docker-build docker run --rm -p 5055:5055 synapdrive-ai:latest python -m synapdrive_ai.interface.web_dashboard diff --git a/README.md b/README.md index 574e992..207cb56 100644 --- a/README.md +++ b/README.md @@ -1,401 +1,457 @@ # SynapDrive-AI -SynapDrive-AI is a **simulation-first BCI safety and control scaffold**. +SynapDrive-AI is a **simulation-first, evidence-producing neuroadaptive shared-autonomy research runtime**. -It is built around one practical question: +Its core question is deliberately narrow: -> If a system thinks the user intended an action, what should happen **before** anything is allowed to move? +> Given a declared or decoded action candidate, what evidence and gates should exist before a system is allowed to execute even a simulated action? -This repo answers that with a runnable pipeline: +The repository separates acquisition, decoding, uncertainty, permission, world-state prediction, action admission, simulated execution, feedback, memory consequence, adaptation, and evidence. It is designed so that missing knowledge produces an abstention or blocked cycle rather than an invented answer. -**intent → context adjustment → safety gate → simulated actuation → evaluation → assurance receipt** +## Claim boundary -It runs **without hardware**, supports **record/replay**, includes a **local dashboard**, and now also includes **offline EEG-style analysis tools** for session review and threshold tuning. +SynapDrive-AI is research software. It is **not**: -## What this repo is +- a medical device; +- a clinical neural decoder; +- proof that a decoded class equals a person's true intent; +- evidence of human BCI accuracy unless a real labeled dataset and appropriate study design are supplied externally; +- a safe physical-control system; +- a cybersecurity certification; +- an AGI, consciousness, or sentience system. -- A **runnable reference implementation** for safe intent handling -- A **simulation environment** for testing control logic without devices -- A **safety middleware layer** between decoded intent and downstream action -- A **small neuroscience tooling layer** for offline EEG-style analysis and task sequencing -- A **runtime assurance receipt layer** that records pass/fail invariants per cycle +The bundled actuator is simulated. The bundled deterministic synthetic signals are software verification fixtures, not physiological performance evidence. -## What this repo is not +See `docs/CLAIM_BOUNDARIES.md` before publishing results derived from this repository. -- Not a medical device -- Not a clinical decoder -- Not proof of real-world BCI control performance -- Not a claim that text prompts or synthetic signals equal real neural intent decoding +## What v1.0 actually implements -That boundary matters. +### Governed canonical runtime ---- +Every action candidate passes through a single canonical pipeline in `synapdrive_ai/pipeline.py`: -## Current working surface +```text +input packet + -> context enrichment without confidence inflation + -> class/action distribution + -> uncertainty estimation + -> signal-quality / drift / permission / world-model checks + -> fail-closed runtime admission + -> compatibility lexical guard + -> simulated action router, only when admitted + -> explicit symbolic world-state commit for modeled successful actions + -> result and optional user/ErrP feedback reconciliation + -> validated or quarantined evidence memory + -> assurance receipt + -> SHA-256 linked evidence entry +``` -The repaired repo now supports these paths cleanly: +Unknown, analysis-only, unmodeled, low-confidence, high-uncertainty, low-quality, excessive-drift, permission-denied, or excessive-risk packets cannot reach the simulated action router. -### 1) Canonical command path -Run a text command or simulated signal through the full pipeline. +### Explicit provenance -### 2) Safety-first execution -Low-confidence or prohibited intents are blocked before actuation. +Packets can state: -### 3) Reproducible record/replay -Sessions can be written to JSONL and replayed deterministically. +- `inference_authority`; +- `confidence_semantics`; +- `neural_decode_performed`; +- `analysis_only`; +- signal quality and drift measurements; +- action probabilities; +- a stricter task-level confidence requirement. -### 4) Local dashboard -A Flask dashboard exposes the pipeline and latest telemetry. +A deterministic text parser is labeled as a declared-command path. A synthetic fixture label is labeled as synthetic ground truth. Hardware acquisition with no decoder is labeled acquisition-only and abstains. -### 5) Offline EEG-style analysis -Analyze EDF, BDF, CSV, or NPY data using simple band-power logic, then run those epochs through the same safety pipeline. +### Signal integrity -### 6) Sequential task planning -Execute multi-step plans with per-step minimum confidence and fallback behavior. +`SignalQualityAnalyzer` checks: -### 7) Runtime assurance receipts -Every pipeline cycle now emits a compact assurance receipt showing whether the -simulated safety decision, actuation result, and evaluation stayed internally -consistent. This is review evidence, not a real-world safety certification. +- non-finite samples; +- flatline behavior; +- zero-valued dropout; +- observed-extrema clipping; +- line-frequency power. ---- +Its aggregate score is an engineering heuristic used by the software gate. It is not a clinical EEG quality index. -## Core pipeline +### Offline EEG-style analysis -``` -text / simulated signal / EEG-derived epoch - ↓ - intent packet - ↓ - cognitive optimizer - (memory + visual context) - ↓ - safety gate - (confidence + prohibited intent checks) - ↓ - simulated actuation - ↓ - meta evaluation - ↓ - assurance receipt -``` +The lightweight loader supports: -Default safety behavior is conservative: +- EDF; +- BDF with signed 24-bit sample decoding; +- CSV; +- NPY. -- **Minimum confidence threshold:** `0.45` -- **Hard block list:** unsafe phrases such as disabling brakes, overriding security, triggering launch, and similar prohibited actions -- **Assurance receipts:** each cycle records whether blocked intents stayed blocked and allowed intents reached the simulated router +`BandPowerAnalyzer` computes delta, theta, alpha, beta, and gamma power plus ratio features. Its historical `confidence` field is explicitly an **uncalibrated spectral heuristic score**. ---- +`SessionAnalyzer` without a decoder is analysis-only. Band ratios do not become movement commands. If an explicit decoder callback is supplied, its packet still passes through the canonical governed runtime. -## Quickstart +### Decoder benchmark arena -### Install +Built-in inspectable baselines: -``` -python -m venv .venv -source .venv/bin/activate -pip install . -``` +- spectral centroid decoder; +- log-Euclidean covariance centroid decoder; +- probability-averaging ensemble. -For development and tests: +The arena reports: -``` -pip install -e ".[dev]" -``` +- accuracy; +- balanced accuracy; +- multiclass Brier score; +- expected calibration error; +- abstention coverage; +- selective accuracy. ---- +The benchmark consumes explicit labeled epochs. Missing labels are not invented. -## Run the main CLI +### Locally qualified decoder bridge -### Text path +`QualifiedDecoderAdapter` connects a benchmark decoder to runtime use only after deterministic held-out qualification. -``` -python -m synapdrive_ai --text "move left" --image road --no-delay -python -m synapdrive_ai --text "stop" --image hazard --no-delay -``` +It requires: -### Simulated signal path +- configured balanced-accuracy, calibration, Brier, and coverage gates; +- a complete decoder-label to simulation-action map; +- matching runtime channel count; +- matching runtime sampling rate; +- confidence above the configured abstention threshold. -``` -python -m synapdrive_ai --signal walk --count 3 --interval 1 --no-delay -python -m synapdrive_ai --signal stop --no-delay -``` +Failure yields an explicit abstention. Passing these local gates does not establish clinical validity, participant generalization, or hardware safety. -### Record and replay +### Uncertainty and calibration -``` -python -m synapdrive_ai --text "move left" --image road --record runs.jsonl --no-delay -python -m synapdrive_ai --replay runs.jsonl -``` +The neuro runtime includes: ---- +- normalized class distributions; +- normalized entropy; +- top-class margin uncertainty; +- ensemble disagreement; +- a reliability calibrator fitted from supplied confidence/correctness observations. -## Dashboard +### Decoder drift -Run the local dashboard: +`FeatureDriftMonitor` uses a fitted Mahalanobis-distance baseline. It has no meaningful baseline until the caller supplies calibration features. -``` -python -m synapdrive_ai.interface.web_dashboard -``` +### Multimodal fusion -Then open: +`WeightedEvidenceFusion` combines caller-supplied probability distributions for modalities such as EEG, EMG, gaze, or context using explicit reliability weights. It does not fabricate modality reliability. -``` -http://127.0.0.1:5055 -``` +### ErrP research path -What the dashboard gives you: +`ErrPFeatureExtractor` produces event-window features. `ErrPLDAClassifier` must be trained on supplied labeled calibration features before use. -- Text command execution -- Simulated signal execution -- Latest telemetry log from the action router -- Runtime assurance report and latest assurance receipts +An externally derived ErrP probability or explicit rejection can contradict an otherwise successful simulated execution, which causes the corresponding evidence memory to be quarantined rather than reinforced. -You can also use the console script installed by `pip install .`: +### Symbolic world model -``` -synapdrive-dashboard -``` +The bundled world model predicts only registered simulation actions. An unknown action is infeasible, preserves state, and receives maximal software risk. ---- +Successful modeled actions commit their predicted symbolic state. The built-in risk values are transparent repository policy fixtures, not empirical physical-risk probabilities. -## Neuroscience tools +### Counterfactual planning and shared autonomy -The repo now includes an offline neuro-analysis layer under `synapdrive_ai/neuro/`. +`CounterfactualPlanner` ranks a bounded caller-supplied action set using explicit fixed software-policy weights over: -### Demo with synthetic EEG-like data +- alignment with the supplied request; +- decoder confidence; +- caller goal score; +- reversibility; +- registered software risk. -``` -python -m synapdrive_ai.neuro.cli demo -``` +`SharedAutonomyArbiter` proposes only. It does not bypass governance or directly actuate. -This runs a synthetic session with a motor-style burst and prints: +### Simplex and shadow control components -- Relative band power -- Engagement ratio -- Intent class -- Confidence -- Epoch-by-epoch pipeline status +`SimplexController` dispatches to an advanced controller only after an allowed runtime decision; otherwise it invokes a caller-supplied reversionary controller. -### Analyze a file +`ShadowController` lets an experimental policy produce comparison decisions while granting it no authority over the trusted action path. -``` -python -m synapdrive_ai.neuro.cli analyze session.edf --channel C3 --out results/ -``` +### Evidence-gated memory -### List channels in a file +Aligned successful cycles can enter evidence memory as `validated`. Contradicted cycles enter as `quarantined`. -``` -python -m synapdrive_ai.neuro.cli analyze session.edf --channels -``` +Historical successful episodes can be attached as review context, but they do **not** increase decoder/parser confidence. -### Sweep confidence thresholds +### Guarded adaptation -``` -python -m synapdrive_ai.neuro.cli threshold session.edf --min-conf 0.4 0.5 0.6 0.7 -``` +`GuardedThresholdAdapter` evaluates a candidate confidence threshold on caller-supplied held-out records. Promotion requires improved defined utility without increasing unsafe accepts on that supplied validation set. -### Run a sequential task plan +This is bounded threshold adaptation, not autonomous model self-rewriting. -``` -python -m synapdrive_ai.neuro.cli plan --list -python -m synapdrive_ai.neuro.cli plan --task reach_grasp -``` +### Evidence, replay, and signing ---- +Each canonical cycle produces: -## Supported EEG-style file formats +- an assurance receipt; +- a SHA-256 linked evidence-chain entry. -The lightweight loader supports: +The evidence CLI can: -- `.edf` -- `.bdf` -- `.csv` -- `.npy` +- verify a chain; +- generate Ed25519 keys; +- sign an evidence ledger; +- verify a detached signature. -Notes: +Hashing and signatures provide integrity/authenticity properties for recorded bytes. They do not prove sensor truth or scientific validity. -- CSV time columns are auto-detected when possible -- NPY can be 1-D or 2-D -- The loader is intentionally lightweight and does **not** try to replace full research toolchains +### Deterministic fault injection ---- +The stress surface includes deterministic seeded injection for: -## Neuro-analysis logic +- Gaussian noise; +- contiguous dropout; +- clipping; +- line-frequency contamination; +- channel swaps. -The offline analyzer uses a simple band-power approach: +Campaign results state which defined invariant was expected and whether the tested callback failed closed. -- **Delta:** 0.5–4 Hz -- **Theta:** 4–8 Hz -- **Alpha:** 8–13 Hz -- **Beta:** 13–30 Hz -- **Gamma:** 30–80 Hz +## Installation -It derives: +Python 3.11 or newer is required. -- **Engagement ratio** -- **Cognitive ratio** -- **Intent class:** `motor`, `cognitive`, or `unclear` -- **Signal confidence** +### Windows PowerShell -Those epochs are then run through the same SynapDrive safety pipeline so you can inspect: +```powershell +py -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install --upgrade pip +python -m pip install -e ".[dev]" +``` -- pass/block rate -- confidence distribution -- evaluation scores -- exported JSONL / CSV results +### Linux/macOS -This is useful as a **prototype analysis layer**, not a substitute for validated neuroscience tooling. +```bash +python -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install -e ".[dev]" +``` ---- +Optional acquisition dependencies are intentionally separate: -## Sequential task planning +```text +pip install -r requirements-brainflow.txt +pip install -r requirements-lsl.txt +``` -Single commands are easy to demo. Real control problems are not. +## Quick verification -`synapdrive_ai/neuro/task_planner.py` adds a simple sequential execution model with: +```text +python -m compileall -q synapdrive_ai core scripts +python -m pytest -q +python -m scripts.run_v1_validation +``` -- per-step labels -- per-step minimum confidence -- fallback policies: - - `freeze` - - `abort` - - `complete` +With development dependencies installed: -That lets you model the real failure question: +```text +ruff check . +pyright +``` -> What should happen when step 3 of a multi-step action becomes uncertain? +`make quality` runs compile, the complete pytest suite, and the release validator. ---- +## Main CLI -## Optional integrations +Declared text command: -The repo still keeps optional hardware-facing adapters separate from the default install. +```text +python -m synapdrive_ai --text "move left" --no-delay +python -m synapdrive_ai --text "stop" --no-delay +python -m synapdrive_ai --text "switch mode to assistive" --no-delay +``` -### BrainFlow +Explicit synthetic fixture label: +```text +python -m synapdrive_ai --signal walk --no-delay +python -m synapdrive_ai --signal stop --no-delay ``` -pip install -r requirements-brainflow.txt -python -m synapdrive_ai --brainflow --bf-board-id 0 --bf-seconds 2 --no-delay -``` -### LSL / pylsl +`--signal` uses bundled deterministic-by-seed synthetic fixtures. It is not a neural decoder demonstration. + +Optional caller-declared visual context: +```text +python -m synapdrive_ai --text "move left" --image road --no-delay ``` -pip install -r requirements-lsl.txt + +The bundled visual path maps a declared label only. It does not run image inference and it does not increase intent confidence. + +## BrainFlow and LSL acquisition + +```text +python -m synapdrive_ai --brainflow --bf-board-id -1 --bf-seconds 2 --no-delay python -m synapdrive_ai --lsl --lsl-type EEG --lsl-seconds 2 --no-delay ``` -These are optional and do not change the default simulation-first workflow. +The CLI deliberately has **no implicit hardware decoder**. These acquisition commands therefore abstain after collecting/quality-checking data. ---- +Library users can supply a decoder callback to `BrainFlowIntentSource` or `LSLIntentSource`. `QualifiedDecoderAdapter` is the provided path for connecting a locally evaluated built-in decoder. -## Tests +## Prepare a labeled benchmark dataset -Run the test suite: +Event CSV contract: +```csv +onset_s,label +1.0,left +3.0,right +5.0,left +7.0,right ``` -pytest -q + +Create epochs: + +```text +synapdrive-prepare-dataset recording.edf events.csv --out epochs.npz --tmin 0 --tmax 1 ``` -The repaired repo includes tests for: +Then benchmark: -- canonical pipeline execution -- blocked/allowed intent behavior -- action log contract -- public `run_intent_packet()` path -- runtime assurance receipts and health reports -- dashboard assurance API -- band-power analysis -- EEG array loading -- session analysis outputs -- task-plan execution behavior -- legacy top-level `core` router importability +```text +synapdrive-benchmark epochs.npz --decoder all --seed 7 --abstain 0.60 +``` ---- +See `docs/BENCHMARKING.md` for the complete contract and qualification boundary. -## Repository layout +## Offline spectral analysis + +Synthetic analysis demo: +```text +python -m synapdrive_ai.neuro.cli demo ``` -synapdrive_ai/ -├── pipeline.py # canonical pipeline wiring -├── bci/ # signal simulation + intent generation -├── agi/ # reasoning, optimization, evaluation -├── assurance/ # runtime assurance receipts -├── safety/ # confidence gate + prohibited intent blocking -├── action/ # routing / execution surface -├── control/ # simulated actuation engine -├── replay/ # JSONL record/replay -├── interface/ # Flask dashboard -├── neuro/ # offline EEG analysis + task planning -└── tests/ # regression and contract tests + +The demo injects a deterministic synthetic spectral burst. It does not claim neural intent. + +Analyze a file: + +```text +python -m synapdrive_ai.neuro.cli analyze session.edf --channel C3 --out results +``` + +List channels: + +```text +python -m synapdrive_ai.neuro.cli analyze session.edf --channels +``` + +Sweep the uncalibrated spectral heuristic score: + +```text +python -m synapdrive_ai.neuro.cli threshold session.edf --min-conf 0.4 0.5 0.6 0.7 +``` + +That command is descriptive only. It does not report decoder accuracy or successful control. + +## Sequential task plans + +```text +python -m synapdrive_ai.neuro.cli plan --list +python -m synapdrive_ai.neuro.cli plan --task reach_grasp ``` ---- +Per-step minimum confidence is enforced **before** the simulated action router. A rejected step can freeze/defer or abort according to the plan fallback. + +## Record and deterministic decision replay -## Why this repo is useful now +```text +python -m synapdrive_ai --text "move left" --record runs.jsonl --no-delay +python -m synapdrive_ai --replay runs.jsonl +``` -Before repair, the public repo surface was out of sync with the code path people were likely trying to run. +Replay reproduces the stored software decision input. It is not a re-acquisition of original neural signals. -Now it is back in a credible lane: +## Evidence chain -- the canonical pipeline contract is restored -- tests target the public runtime surface -- install/package behavior is cleaned up -- the dashboard path is aligned with the working pipeline -- the repo now emits assurance receipts for reviewable runtime evidence -- the repo has a real upgrade path beyond a toy command demo +```text +python -m synapdrive_ai --text "move left" --no-delay --evidence-out run.evidence.jsonl +synapdrive-evidence verify-chain run.evidence.jsonl +``` -That is the correct direction for this project. +Generate signing keys: ---- +```text +synapdrive-evidence keygen --private evidence-private.pem --public evidence-public.pem +``` -## Practical next upgrades +Sign and verify: -If this continues, the highest-value next steps are: +```text +synapdrive-evidence sign run.evidence.jsonl --private evidence-private.pem --out run.signature.json +synapdrive-evidence verify-signature run.evidence.jsonl --public evidence-public.pem --signature run.signature.json +``` -1. **Dashboard upgrade** - - session summary cards - - pass/block counters - - threshold controls - - neuro-analysis upload flow +Keep private keys out of source control. -2. **Replay inspection** - - compare two runs side by side - - diff threshold behavior - - export summaries directly from the UI +## Stress campaign -3. **Signal realism** - - artifact injection - - multi-channel analysis - - confidence decay over time - - stronger mapping between band features and task classes +```text +synapdrive-stress --runs 100 --seed 7 +``` -4. **Safer sequence control** - - rollback behavior - - explicit operator confirmation steps - - risk-tiered step policies +The campaign is a defined negative-control surface, not exhaustive proof of safety. ---- +## Local dashboard -## Assurance receipts +```text +python -m synapdrive_ai.interface.web_dashboard +``` -Each output packet includes an `assurance` object with: +Open `http://127.0.0.1:5055` locally. -- `receipt_id` -- `cycle_index` -- normalized `intent` and `confidence` -- `safety_allowed` and `safety_reason` -- `result_status` -- `evaluation_score` -- `passed` and any invariant `issues` +The dashboard routes text and synthetic fixture events through the canonical governed pipeline. It contains no fabricated external cloud/device routes. -The dashboard exposes the latest receipts at `/api/assurance`, and the CLI prints -the assurance object with every run. +## Repository layout ---- +```text +synapdrive_ai/ + adaptation/ guarded threshold adaptation + assurance/ receipts, hash chain, Ed25519 signing + bci/ declared command + synthetic fixture surfaces + benchmarking/ datasets, epoching, decoders, arena, runtime qualification + cognition/ world model, counterfactual planning, shared autonomy, expectation deltas + governance/ policy and permissions + integrations/ BrainFlow and LSL acquisition with explicit decoder boundary + memory/ episodic compatibility memory + evidence-state memory + neuro/ quality, uncertainty, drift, fusion, ErrP, EEG loading/analysis + replay/ JSONL decision replay + runtime/ governed admission, reality reconciliation, Simplex, shadow control + stress/ deterministic fault injection + vision/ declared visual-context mapping only + tests/ unit, integration, negative-control, format, and tamper tests +scripts/ + run_v1_validation.py +core/ + legacy compatibility imports routed to deterministic implementations +``` + +## CI + +GitHub Actions tests Python 3.11, 3.12, and 3.13. Each matrix job installs development dependencies, runs Ruff, Pyright, pytest with coverage, and the release validator. Coverage XML and validation JSON are uploaded as workflow artifacts. + +## Donor provenance + +The upgrade was informed by the supplied IX, IX-IntentRealityLoop, IX-Sally, IX-Autonomy-Assurance-Case-Runtime, and IX-Operator repositories. The donor code was not blindly merged. + +Licensing and import decisions are documented in `docs/DONOR_PROVENANCE.md`. In particular, source-available donors were not bulk-copied, and the evaluated IX-Operator transport path was not imported after a donor test exposed a tamper-detection failure in that evaluated environment. + +## Additional documentation + +- `docs/ARCHITECTURE.md` +- `docs/ARCHITECTURE_V1.md` +- `docs/BENCHMARKING.md` +- `docs/SAFETY_MODEL.md` +- `docs/EVIDENCE_AND_REPLAY.md` +- `docs/FAULT_INJECTION.md` +- `docs/CLAIM_BOUNDARIES.md` +- `docs/DONOR_PROVENANCE.md` +- `SECURITY.md` ## License -Apache-2.0 +Apache License 2.0. See `LICENSE`. diff --git a/RELEASE_AUDIT.md b/RELEASE_AUDIT.md new file mode 100644 index 0000000..3effbcd --- /dev/null +++ b/RELEASE_AUDIT.md @@ -0,0 +1,41 @@ +# Local Release Audit + +This file records checks executed against the source used for the SynapDrive-AI v1.0.0 handoff. It is not a hardware, clinical, or human-BCI certification. + +- Release: `1.0.0` +- Original isolated-build audit timestamp (UTC): `2026-08-26T17:12:37+00:00` +- Original isolated-build Python: `3.13.5` +- Windows verification Python: `3.13.2` +- Pytest: **138 passed, 0 failed, 0 skipped** +- Package-wide branch coverage: **69.699%** (`3052` statements, `608` branches), measured in the isolated build audit +- Canonical pipeline coverage: **97.2%**, measured in the same isolated coverage run +- Release validator: **24/24 checks passed** +- Ruff on Windows: **All checks passed** +- Pyright on Windows: **0 errors, 0 warnings, 0 informations** +- Python compileall: passed in the isolated build audit +- Offline wheel build: passed +- Wheel installed into an isolated target directory: passed +- Discovered installed runtime modules: **88**, import failures: **0** +- Canonical pipeline executed from isolated wheel install: passed +- Static source audit: 0 syntax, line-length, trailing-whitespace, tab, bare-except, ambiguous-name, or unused-import findings under the repository audit rules + +## Static-analysis verification + +Ruff and Pyright could not be obtained inside the original isolated build environment because outbound package acquisition was unavailable there. + +They were subsequently installed from the repository's declared development requirements and executed against the final v1.0.0 working tree on Windows 11 with Python 3.13.2. + +Final Windows results: + +- `python -m ruff check .` → **All checks passed** +- `python -m pyright` → **0 errors, 0 warnings, 0 informations** +- `python -m pytest -q` → **138 passed** +- `python -m scripts.run_v1_validation` → **passed: true, 24/24 checks passed** + +GitHub CI remains configured to independently run the repository's quality gates on its declared Python matrix. + +## Evidence boundary + +`RELEASE_VALIDATION.json` uses deterministic synthetic verification data for software-path checks. Its benchmark metrics are not human EEG performance, clinical validation, hardware safety evidence, or proof of physical actuation. + +No claim in this release should be interpreted as evidence of medical efficacy, clinical validation, physical BCI safety, or validated human neural-decoding performance. \ No newline at end of file diff --git a/RELEASE_MANIFEST.sha256 b/RELEASE_MANIFEST.sha256 new file mode 100644 index 0000000..42bac6b --- /dev/null +++ b/RELEASE_MANIFEST.sha256 @@ -0,0 +1,144 @@ +47fe105feff39a44479a7eae7595ca0cc759f8de5beb3b5f258045d53413fa22 .coveragerc +7a54d26f7da2c6784ed66c6c2ae36f1ce4418a2baa0774c273ca03e6e7acd885 .dockerignore +a22a007e91f3b1bafdcbc391c74f18a48eac704cb4dd5567781769dabe14ce63 .github/workflows/ci.yml +7e2a1296d2532ee4348ee662c47066e4b26c7e44a62988697f91fa02ebfe9a2c .gitignore +e7de56135d512129e2ba2c06367a6170d438cad70dbbc26ca93fbe94634fef75 .pre-commit-config.yaml +d8554fc8b4716d7f7e1596f34748d9c1da308b9822a52f8005283e4809f731fb CODE_OF_CONDUCT.md +97d2c3ab9f1b1fe3af73b88347d7dddbe46524860e4a6259c19e4bde158cd73c CONTRIBUTING.md +a9f79c824325b590ab3f57e3626aacd78326c821ce64cadfcbe221c8ca4eb6cc Dockerfile +4171efb75e07e0237aac242701c11ee35c20f87864ba562842fd14947c0edc05 LICENSE +621a28092df6ee4aa5d5f6254fb85950e3b167ded6f3e2e78e6ba4a140665bbc Makefile +5fc4c4399f95c851c3383641b56f58b39d47b6de3bd056b337f72cfe1c30cde0 README.md +b49f2cf3f69ad784aca0efaf23c669e536c7cdbe8b56dec37c19c878f350d304 RELEASE_AUDIT.md +132796f3cee927e187ef74002c567b3fb7a20de5ceacea4e2a7d51ddd37bf143 RELEASE_VALIDATION.json +30faffd6c74bf5bcbdd57581f55e440c32d59b9513c75deeec6a929bb63b7530 SECURITY.md +eee7c8b76fb91111fe9dc9ab88c59121fb9fa79c7c19880febda2d8f08bf44a3 core/__init__.py +43e5bc878c4e6ce00bcefeaa4a81b7443ad8f06114514f6cbc63d962bc68c04f core/execution/__init__.py +1902ddd2a4c6683d3f39efb8a392a0b4e5b39a4bfbfc0c741301a50228031581 core/execution/executor_bridge.py +aa6367922eac06061ef46abc70f977e65d49e3299df982a32f5522c15b070491 core/intent_router/__init__.py +02094fffe1de66dfffa9bb87677a98dc9930949c03c58aa34360bcabba413c60 core/intent_router/intent_parser.py +945cb93727aeae12cd47a4a57cba684397bd6e422cbdc96447a84b75895ed35b core/intent_router/intent_router.py +18a83738351f2dd60c9d7e03780f80b6cab31d9bc4b3efe1e1207e23ffbb1def core/planning/__init__.py +020fe5023ecd94f6f05a81233d213e3ff96d4c241173a5a2cfaa10aca5ba73fe core/planning/agi_planner.py +19cf7800c46648c91fac0bca1c1c6c5991789966745b76df0685e1d2ccf3846e docs/ARCHITECTURE.md +eb1c2b495489b5eb2d59dfcfd53407acad76a7cc3a780adb62e3e4447047f076 docs/ARCHITECTURE_V1.md +655eeda571cc32817531f6db06afde590150a3fd2dd1f163c06ee1659b81dd09 docs/BENCHMARKING.md +a13fb016f62842336d36a50bf28a806b93e911a833fcead08097122f220ccf8c docs/CLAIM_BOUNDARIES.md +9287f089fd9d429156b9685be2e8868262a137df76a97df2d3bfb39d287f8241 docs/DONOR_PROVENANCE.md +0b48ce86abdd7a0704c3d404a33bccc096ebc3c7bd0f2962b1cd56685d65d66f docs/EVIDENCE_AND_REPLAY.md +8345a8396fbd0b53c9082671a54935e7beffdcca95ea2565b9491aaa79ebcbce docs/FAULT_INJECTION.md +b4e1ce5819822746605205f5b7d97ae03f6774c7f9695071e5013fab5adc638d docs/SAFETY_MODEL.md +305f2bf0cbbbf65c64b8808436947a77d29a8832ccc4f81f5be8f5c855b3e587 pyproject.toml +5726a0cf68926715fe51e7082f48c6e61fe9339563d98b070e0a6d6023f9abe3 pyrightconfig.json +6cdd57a851c66c750e55a1e54f79930f1cf29fc9a82aabe6510c5970042c5f84 requirements-brainflow.txt +ade1ee2022a69114be20f217861ef893958e512afb1587bd9824fc2948cb5b5d requirements-dev.txt +70486caf765ace9a8b5fb2789937c93b4c3762343bede658de925540ae59d8f4 requirements-lsl.txt +7759a5169ee2a61093d50121ffcf116217584abbcb6de389197d86181fb036d7 requirements.txt +3474faebeae84b241e979b63410deb37976b8be6fafbbde801f2e1329684a92e ruff.toml +a9659e066f024fd74cfbf47bf7ddd8d68a5f12b5f9cd72636208f9be3a12c6b0 scripts/__init__.py +0c38a329d7aea804ffa3073846df5e1abc95a91674bae6d6dada64314775abca scripts/run_v1_validation.py +2694f88c91218477d5725b1207432bfec73ca3a11aca7eab3c5b7d5a38faaec7 synapdrive_ai/__init__.py +b071149200d7e5bf79dc1d3e68f9acd8a9a76415d0df10ab38fc96309adc1334 synapdrive_ai/__main__.py +dcd68a1066f0f6ed7d3d29632561a7ec1478bf7e2e4eb5ea52c75953a20b5112 synapdrive_ai/action/__init__.py +f3bd1a9542c36a85f53119effb6d02982744d3763365d5c69962a228dc438e35 synapdrive_ai/action/decision_router.py +38517c23c3af6d04de4a35cb274f8db956606ee934d6ac902bf3304011337bab synapdrive_ai/adaptation/__init__.py +e90e1961914bf57032bbf6d090d6dfc5a4c5817c43311cfc04949172b37b50f3 synapdrive_ai/adaptation/guarded.py +55e441e6cef139e0cf0abc60d9ccd4a0f9d3f7f83d38edb0196877991a9c5fff synapdrive_ai/agi/__init__.py +c21b419d5f13c13bb064c2ce1ea1c22773d063920b94e806853dcdd4ff1cbdca synapdrive_ai/agi/adaptive_memory.py +e39d70c4940a4d5c510ac2a4bc9ff28948afdda7c426e7c343b4fe4ce0cef7ac synapdrive_ai/agi/cognitive_optimizer.py +bc2e8906c9fdec2203bf2c9fcda926df1c171cfbbf2e9441e9dde5f11b303d39 synapdrive_ai/agi/core_reasoning.py +5a3ac0ab9683340defec3e66a57d1c27f4b8ab7555b744fa849804729f434253 synapdrive_ai/agi/feedback_learning.py +dc51328c6f2d5f8224c709c7f7d51c8c76537c0f3e81fcc6c1e63aeff07f4997 synapdrive_ai/agi/intent_classifier.py +d8c7b45e129d070f05f5e1c8543e9eb2f56ecff25ac03235c1c80da89aa6b7a7 synapdrive_ai/agi/meta_evaluator.py +02908087b6bb280e070521372f69748be5391bb4b357a274661dc861bcd8c821 synapdrive_ai/assurance/__init__.py +c4e63551a04736ed81fee5ee84a73f0c574da3208f5379425bd56e168000fca3 synapdrive_ai/assurance/cli.py +d307ecc271d0929ab16a5d0fb2ae5957536b3db905dbf99336c889d6302c38ab synapdrive_ai/assurance/contracts.py +8f11ac5ba7359c76a019fbb5de216d9c2a6479f6d6f2e22c1b25b1d6cee760f6 synapdrive_ai/assurance/evidence_ledger.py +8316f85e2ebad1cf9713ca8ea75bc0af1784c103bb7fd21bb0395e3b7ba5701d synapdrive_ai/assurance/hashchain.py +548f52e01bf9724c7312d8d43fd18018c72e4310a340bff0209e549babc3f0d1 synapdrive_ai/assurance/monitor.py +2c5505820f9ac46ad479baec5ec13a239ea80f7129e4a10f740e020124570fdb synapdrive_ai/assurance/signing.py +80975b71bca68b545b794b600fc79e22604b4b408d13d7e45b09287850362794 synapdrive_ai/bci/__init__.py +6efc2777deb1db7b123c2232c666aa4f6c8cf493813e54221f85744d97c0b03f synapdrive_ai/bci/intent_generator.py +921be5b97c4ab6eff51c311ccc48cd546c982d4cef2cb0d8137a149eae08573c synapdrive_ai/bci/signal_simulator.py +9bc68ba0b10a1a8f34d16a315171fd99b659bda819c3496c07a5a6e0a7c36ab2 synapdrive_ai/benchmarking/__init__.py +eb266183792dd277bf71cac1a3856867f70d8f1e4e26e1ad48134c1c4b024acf synapdrive_ai/benchmarking/cli.py +f0dfde5ef16f168dfca92868000818b92b3225e6354fc7f08ab8778b6ae2d77d synapdrive_ai/benchmarking/dataset.py +253f02d583e81c5a783eff7870d7acaf0f51841e7255be12c04454b82f69db5b synapdrive_ai/benchmarking/decoders.py +a7019155d0e8313c1367c5c38a59536f29512e94f901f6fafcfed29b22e3a79e synapdrive_ai/benchmarking/epoching.py +d35d9dac9bc0c5496aabdf7b7c12aae93bc98c36e488af9e210b6b381aafb17e synapdrive_ai/benchmarking/evaluation.py +9b205694a07a8f8ceb6eeb72e235d02e4e791f4a3d7ff215b117aa3ddf2d68ce synapdrive_ai/benchmarking/prepare_cli.py +bd3413642196a63127f784ffc68c1d2bbed5ab289b5cdea2470e3175e67878f7 synapdrive_ai/benchmarking/runtime_adapter.py +b6d53944b6424c75483e1fb450c3715f3fea3cf3a1237a47cf4d464c9fe8ef8a synapdrive_ai/cognition/__init__.py +6565a60a189d192b7721d1c5e52ccbcc857bb39715ee471496ecc9a810c0409a synapdrive_ai/cognition/expectation.py +ad5a01da9f7b02606a83c0900201b81e823d6aa279f317bc31d589e50223752c synapdrive_ai/cognition/planner.py +dfd4f151529ec0063b8c19fb394418a68aaf3a6b9df3596cd7312af4dd945ca0 synapdrive_ai/cognition/shared_autonomy.py +1855e3e67256f50f8720afcd75feb16865f0daddd4473986a89af25ea3be0895 synapdrive_ai/cognition/world_model.py +dc51aef4e9a4b469a8c9a649218cde4f2419a7ad94e81ebe49665d7b7ceaaea2 synapdrive_ai/control/__init__.py +bcf92bddd100b4a1a996297f6d1d53b4479f6a45c46ed6d6ee895046c2d6ed99 synapdrive_ai/control/actuation_engine.py +828457bd43de37ef48ce3b912cb8e095ec762d093fa11e0b5d479c172e85b4c6 synapdrive_ai/core/__init__.py +2528157af6efbe67a74b0cb54652df01616ff9edbc865b83a8a53d7c75fc08b1 synapdrive_ai/core/logger.py +32899e136ac96309f9de8f0df7d5f47e1ff99444b7573b9c1d11da9bdd9555d1 synapdrive_ai/dashboard/__init__.py +da48422e34e1c3b0cdd8bd074a16899092498d50ff8f74ce29fba8b638f1609d synapdrive_ai/dashboard/realtime_ui.py +ab8c822842030a9bb730b3e0b6bcc7d9349e53f319440890bce2beb318eea91d synapdrive_ai/governance/__init__.py +a545d23f0adfc8cb2b981e5c13ef95ca52ae441f75fb677c52e7ff47dc9d5fd5 synapdrive_ai/governance/permission.py +4bbba262872c7e06318d07e854e61a4f171a39e3f6bf1021a0c8477f136637ed synapdrive_ai/governance/policy.py +c881bd2bc23d11a37bf382cb87979d8eadad6efb74b0c72c95514cd24134adf4 synapdrive_ai/integrations/__init__.py +30b5463142a6ced01e14b573dea24ce7ef4583f9d8a2d3e19dcbf81a976c35d0 synapdrive_ai/integrations/brainflow_adapter.py +6a58fef0d5f9f4abdbc8a7a21c302d32269503119264c42bbc3079df39b42e61 synapdrive_ai/integrations/lsl_adapter.py +e0878d9e0aef2af55a00d885388a2f313851fba44e064281fa477cfeb7bb03e3 synapdrive_ai/intent/__init__.py +eda698941c48ebe118f2545a634b6060e4a0404d64d09e80a2e65372f3d64d4a synapdrive_ai/intent/intent_parser.py +81856cc43a3fc31d51aedb64624437bbca502a1769aef441d09198cd16b71a24 synapdrive_ai/interface/__init__.py +f54a2eb953cbf02e479cc5283a0c239b55952d8dfb00f852630fedc3cf3d82be synapdrive_ai/interface/bridge.py +f419eb1a6b73b0e024190bdaa579f636b149558a9debfb5ed662d2cd082f119d synapdrive_ai/interface/dashboard.py +b708b6a44f416131aa51d768a8cb6ddf3e2c5e7d3fadbac5bfba2f405472df21 synapdrive_ai/interface/templates/index.html +0e7aaede036be3be743930a686484e90edc4c8cba5bebfea13b53d2109ca2f56 synapdrive_ai/interface/web_dashboard.py +d01b0d0b559f791dd0fb1752c0cfd870c50dcd5889ac4b5890a66b0a17dff6e9 synapdrive_ai/main/__init__.py +f898124e8d118ac6568fe785a69f0e20ed4c128b542aa1a322483d62a4da6378 synapdrive_ai/main/cli.py +3332da52b1c4fe2e546c3c1c47a5064777dbdcc44406ca94b8aaec1d905e2b43 synapdrive_ai/main/integration_runner.py +d6b55a66ccd2b35fe40702f9bbc9ea3368c14b92dbcd3374c380c78a7b791da2 synapdrive_ai/main/test_simulation.py +f34ca549081c49bb12a07cecba7f5369067f6c65e60b0df0429d7138b501fb6c synapdrive_ai/memory/__init__.py +e1487b79116813637a8509a55f7dacf6fbcc711caf2746dcacded775c4eeee8d synapdrive_ai/memory/episodic_memory.py +42918d62f3f1e40d8fa333c31fb8bc5635437c52a01b720a2ef1a1013b250ecf synapdrive_ai/memory/evidence_memory.py +1c4b5256e7ec8f95e8e60fcb19f93b344827f95e9195100206c6b10aa915bfcf synapdrive_ai/neuro/__init__.py +fa8b694a18dab8bf77124be732df30708967b2dfa6cc4bf7381b20acb4276826 synapdrive_ai/neuro/band_analyzer.py +82b268a459a02121f0ffe597880035671b27f75609b993105e586ab253434f9c synapdrive_ai/neuro/cli.py +9cff74722784c68265fd284309bda4e10364cebb220431fc5db5e219e58ee10e synapdrive_ai/neuro/drift.py +68bb7058d246a49c8da7f9cbfa79ab7698607ca276ca8b965bf8568710412926 synapdrive_ai/neuro/eeg_loader.py +71da739512fd3852662847586f4edcecb25823b59bf60b3b24eeb1fbed64bbc5 synapdrive_ai/neuro/errp.py +d65b67e5f87418653042d8b0ec0e176f45bb0dd23e7ae73835365cbf596d3820 synapdrive_ai/neuro/fusion.py +8aa12cccc1edb3e1a9ab967c5d0b15a5ee2c33565735b2b18211acb79ca0652b synapdrive_ai/neuro/session_analyzer.py +37dde497871ee37b7b4012e9d53d7c6d79733e5e990159c110f9196bcea62ba8 synapdrive_ai/neuro/signal_quality.py +2010cb9a5281538051485ba5ef4f43ab2b47d24af7feb3fa9c4d74a97c1ca833 synapdrive_ai/neuro/task_planner.py +2c67657d5c9d9e526e27ed868a208f0e22ee03cb9ee565fe209b5c66f18fb6ba synapdrive_ai/neuro/uncertainty.py +3782846da09372bffb5400ed990379c3502f5c0bbfa3a903a598d5a55af9d472 synapdrive_ai/pipeline.py +5d46931471c60a7367720fdaacbd4530243aca727019804880926022eae75d9c synapdrive_ai/replay/__init__.py +de4b062a6958c25b395c4c4d9d0d1d91ac8b530e8665425e193ace942574dbdc synapdrive_ai/replay/recording.py +a14ae7f26c687f6b0010ee02844ac486ecb9fc1842e54fd875e34425945315cb synapdrive_ai/runtime/__init__.py +d04c7aeb5d13b684ae3510edce1ee5bc2db7e2fe95f25b1bace5518137a07bcd synapdrive_ai/runtime/contracts.py +4c1982771475aeefef9478ed6008e9398fefc476ead8c56870dc643734c300a9 synapdrive_ai/runtime/governed_runtime.py +c35a829f3d3916e7dfec8e5b12d45a1da65ff933560d1038efd1e24e14493ce9 synapdrive_ai/runtime/reality.py +5228d6949d0922466c88a98480655a84a1f506fcfe700fdf8aec2cf4430caa5e synapdrive_ai/runtime/shadow.py +045b7ebf68961344827a3d47ee5aa977b0fc74d183cbdd6e1b130b565b8ac9c4 synapdrive_ai/runtime/simplex.py +6e443bc5680981715378af0da0086086b0e9150cd2df5a9a856873bf2af2b88f synapdrive_ai/safety/__init__.py +1c3bf69af6dc2e5b18afd6e66e04b19c697c574a43ec36997e359d00ccb19513 synapdrive_ai/safety/safety_guard.py +166a8f87d36d05c926601ad1022ad40d35804ed81f8fa92cb4684ea4bffea2b3 synapdrive_ai/stress/__init__.py +91bb8d6ad47945d9bc2dc1d7b34973d61311469122f4fd8c72cb9f0f080749d0 synapdrive_ai/stress/campaign.py +9c6eea40b32a75d563d5ed412b3cc73fa4750f9b746dda413d897523d9b56761 synapdrive_ai/stress/cli.py +066b8d4e5d1705c5f33e4c8ca636bb687bc6b2ba6da4ef655eeb93be9f6f984c synapdrive_ai/stress/faults.py +3093dfb426a54e4d8ef67090383694951d6e10b82a689fd83998965b559abc06 synapdrive_ai/tests/conftest.py +d2ffb8d6adb680bee69a2fecfaa89ae7d4e97d503a2bb956aef5003b84d884ed synapdrive_ai/tests/test_assurance.py +945ba677350d881d6ad6bd89d2100c83ae60c7385227b8109dabf6732cd138ef synapdrive_ai/tests/test_final_hardening.py +849fcbd7601eebef795e057095391dd389da62fa9ec64aa44b0cf2103d7dd6af synapdrive_ai/tests/test_legacy_core.py +080901414d2edabd5d5d5343cfecea346b6f97483df56faef89f1113653e5820 synapdrive_ai/tests/test_neuro.py +c775a23fc30666e7b1c12dedfcb72b4607811dffce2f66ca2ad875e6ba99af55 synapdrive_ai/tests/test_pipeline.py +e8a797be6276ad282312391e185b384d68211b36b154b126bdc100b826b08420 synapdrive_ai/tests/test_replay.py +be13844da0f8352c3dd24c781c9dda48020f078f10ad9f3c7acff65c2dcbcbbe synapdrive_ai/tests/test_v1_benchmark_stress.py +af296780db2107d55add1d4589d842e15a10f0255231f88e97fa50821e68211f synapdrive_ai/tests/test_v1_cognition_evidence.py +a8bb0faf734a66b5ec434f165b2c352d52996756fb9fd89a5daa6c24931c25d2 synapdrive_ai/tests/test_v1_neuro.py +1fb8b4163b99d16625903183586329718009a9fe782a859ea882567f13d3958c synapdrive_ai/tests/test_v1_pipeline_integration.py +63f29be5b64ea543c70fb534f3116413fd25b30c1bef80e2d5c299a689bc75ce synapdrive_ai/tests/test_v1_runtime.py +5bbb153fc6302a27017544576912d4a3689824877425c65cf8222c4cecda28b1 synapdrive_ai/tests/test_v1_shared_autonomy.py +fbc022a6e4d20ea45fce401ccebc30f612f8e30d04d0685ae754a652ace95c68 synapdrive_ai/tests/test_v1_signing.py +058d26ffd5f192f9c38f0134e6d068a86edd3f31ba342668b42ebb2059b451bc synapdrive_ai/tests/test_web_dashboard.py +3c37f22c64626eb788b9d8bc609bf220f341c6b4947a88e1fb46f8b131d9f9b4 synapdrive_ai/vision/__init__.py +eb3ab615a41b325184e827274355bf714ac1c206e7621a0ea23244de21c8db27 synapdrive_ai/vision/visual_inference.py diff --git a/RELEASE_VALIDATION.json b/RELEASE_VALIDATION.json new file mode 100644 index 0000000..8a3a881 --- /dev/null +++ b/RELEASE_VALIDATION.json @@ -0,0 +1,216 @@ +{ + "schema": "synapdrive.validation.v1", + "version": "1.0.0", + "passed": true, + "python": "3.13.2", + "platform": "Windows-11-10.0.26200-SP0", + "claims": { + "human_eeg_used": false, + "physical_actuation_used": false, + "clinical_validation": false, + "hardware_safety_validation": false, + "synthetic_verification_data_used": true, + "benchmark_metrics_are_bci_performance_claims": false + }, + "checks": [ + { + "check": "governed_declared_command_path", + "passed": true, + "authority": "declared-command" + }, + { + "check": "unmodeled_action_fails_closed", + "passed": true, + "reason": "predicted-risk-above-policy; world-model-precondition-failed" + }, + { + "check": "analysis_only_inference_cannot_actuate", + "passed": true + }, + { + "check": "low_quality_fail_closed", + "passed": true, + "reason": "signal-quality-below-policy" + }, + { + "check": "high_uncertainty_abstains", + "passed": true, + "uncertainty": 0.85 + }, + { + "check": "decoder_drift_fail_closed", + "passed": true, + "drift_score": 204.205278, + "learned_threshold": 1.41413 + }, + { + "check": "permission_revoke_preserves_safe_stop", + "passed": true + }, + { + "check": "errp_contradiction_quarantines_memory", + "passed": true + }, + { + "check": "evidence_chain_valid", + "passed": true, + "entries": 7 + }, + { + "check": "evidence_tamper_detected", + "passed": true + }, + { + "check": "ed25519_evidence_signature", + "passed": true + }, + { + "check": "flatline_quality_detection", + "passed": true, + "score": 0.5, + "state": "unreliable" + }, + { + "check": "multimodal_reliability_weighted_fusion", + "passed": true, + "confidence": 0.8125, + "uncertainty": 0.525417 + }, + { + "check": "counterfactual_shared_autonomy_prefers_lower_risk_candidate", + "passed": true, + "selected_action": "detour" + }, + { + "check": "shadow_policy_has_no_actuation_authority", + "passed": true + }, + { + "check": "guarded_adaptation_requires_held_out_improvement", + "passed": true, + "old_unsafe_accepts": 2, + "candidate_unsafe_accepts": 0 + }, + { + "check": "deterministic_cycle_evidence_replay", + "passed": true, + "event_hash": "6435aeb43b55e2b8c3c0d20d89c917c43b2f17bc6cd59f935724b04488061be2" + }, + { + "check": "decoder_benchmark_executes", + "passed": true, + "dataset": "deterministic-synthetic-verification-only", + "metrics": { + "decoder": "EnsembleDecoder", + "n_samples": 48, + "n_train": 36, + "n_test": 12, + "accuracy": 1.0, + "balanced_accuracy": 1.0, + "brier_score": 0.090788, + "ece": 0.194664, + "coverage": 1.0, + "selective_accuracy": 1.0, + "abstain_threshold": 0.55, + "classes": [ + "class_10hz", + "class_20hz" + ] + } + }, + { + "check": "locally_qualified_decoder_reaches_governed_runtime", + "passed": true, + "qualification": { + "qualified": true, + "reason": "local qualification policy passed", + "report": { + "decoder": "SpectralCentroidDecoder", + "n_samples": 48, + "n_train": 36, + "n_test": 12, + "accuracy": 1.0, + "balanced_accuracy": 1.0, + "brier_score": 0.0, + "ece": 1e-06, + "coverage": 1.0, + "selective_accuracy": 1.0, + "abstain_threshold": 0.55, + "classes": [ + "class_10hz", + "class_20hz" + ] + }, + "policy": { + "min_balanced_accuracy": 0.6, + "max_ece": 0.3, + "max_brier_score": 0.8, + "min_coverage": 0.2, + "abstain_threshold": 0.55 + } + }, + "decoded_intent": "move_left" + }, + { + "check": "qualified_decoder_sampling_mismatch_abstains", + "passed": true, + "reason": "sampling-rate mismatch: got 129.0, expected 128.0" + }, + { + "check": "qualified_decoder_session_analyzer_bridge", + "passed": true, + "pipeline_status": "success", + "confidence": 0.9997651278837503 + }, + { + "check": "task_threshold_blocks_before_simulated_actuation", + "passed": true + }, + { + "check": "successful_action_commits_explicit_world_state", + "passed": true, + "world_state": { + "mode": "assistive", + "last_action": "switch_mode_assistive" + } + }, + { + "check": "fault_campaign_fail_closed", + "passed": true, + "report": { + "total_trials": 3, + "invariant_passes": 3, + "invariant_failures": 0, + "trials": [ + { + "trial": 1, + "fault": "dropout", + "quality_score": 0.596711, + "quality_state": "degraded", + "expected_block": true, + "blocked": true, + "invariant_passed": true + }, + { + "trial": 2, + "fault": "flatline", + "quality_score": 0.5, + "quality_state": "unreliable", + "expected_block": true, + "blocked": true, + "invariant_passed": true + }, + { + "trial": 3, + "fault": "noise", + "quality_score": 0.994053, + "quality_state": "good", + "expected_block": false, + "blocked": false, + "invariant_passed": true + } + ] + } + } + ] +} diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0407416 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Scope + +SynapDrive-AI is research software with simulated actuation. The repository does not claim cybersecurity certification or safe deployment on physical systems. + +Security-relevant surfaces include evidence signing, evidence-chain verification, input parsing, optional BrainFlow/LSL acquisition, the local WSGI dashboard, and any downstream decoder or hardware integration added by a user. + +## Reporting + +Do not publish secrets, private keys, participant data, or device credentials in an issue. Report a vulnerability privately through the repository owner's preferred private contact channel when one is available. + +A useful report includes the affected version, reproducible steps, expected security property, observed behavior, and whether the issue requires optional dependencies or hardware. + +## Key handling + +`synapdrive-evidence keygen` can create an Ed25519 private key. Keep private keys outside source control. The repository `.gitignore` cannot protect a key that is explicitly force-added. + +## Hardware boundary + +The built-in actuator is simulated. Connecting SynapDrive-AI to physical machinery requires an independently engineered hardware safety architecture, emergency-stop path, authorization model, threat model, and validation program appropriate to that system. Do not treat the bundled policy thresholds as hardware safety limits. diff --git a/core/intent_router/__init__.py b/core/intent_router/__init__.py index 2f138ad..0f7a621 100644 --- a/core/intent_router/__init__.py +++ b/core/intent_router/__init__.py @@ -1,3 +1,4 @@ from core.intent_router.intent_parser import IntentParser +from core.intent_router.intent_router import IntentRouter __all__ = ["IntentParser", "IntentRouter"] diff --git a/core/intent_router/intent_parser.py b/core/intent_router/intent_parser.py index 69a56c4..1fb1293 100644 --- a/core/intent_router/intent_parser.py +++ b/core/intent_router/intent_parser.py @@ -1,22 +1,15 @@ -""" -Intent Parser Module +"""Legacy rule-based text command parser.""" -This module takes raw user commands, text inputs, or BCI-decoded intents and converts them -into structured intent dictionaries for downstream AGI routing and planning. -""" +from __future__ import annotations import re from typing import Dict, Optional class IntentParser: - """ - A lightweight rule-based intent parser for transforming natural language or decoded - brain-computer signals into actionable structured commands. - """ + """Transform a small declared command grammar into structured intent fields.""" def __init__(self): - # Simple intent mapping dictionary (extendable) self.patterns = { r"\bmove\s+(left|right|forward|backward)\b": self._parse_move, r"\bpick\s+up\b": self._parse_pick_up, @@ -26,44 +19,24 @@ def __init__(self): } def parse(self, input_text: str) -> Optional[Dict]: - """ - Parse an input command into a structured intent dictionary. - - Parameters: - input_text (str): Raw user or BCI-decoded command text. - - Returns: - dict: Parsed intent dictionary or None if not understood. - """ - input_text = input_text.strip().lower() + normalized = (input_text or "").strip().lower() for pattern, handler in self.patterns.items(): - match = re.search(pattern, input_text) + match = re.search(pattern, normalized) if match: return handler(match) return None def _parse_move(self, match): - direction = match.group(1) - return {"intent": "move", "params": {"direction": direction}} + return {"intent": "move", "params": {"direction": match.group(1)}} - def _parse_pick_up(self, match): + def _parse_pick_up(self, _match): return {"intent": "pick_up", "params": {}} - def _parse_drop(self, match): + def _parse_drop(self, _match): return {"intent": "drop", "params": {}} - def _parse_stop(self, match): + def _parse_stop(self, _match): return {"intent": "stop", "params": {}} def _parse_mode_switch(self, match): - mode = match.group(1) - return {"intent": "switch_mode", "params": {"mode": mode}} - - -# Example usage -if __name__ == "__main__": - parser = IntentParser() - test_inputs = ["Move left", "Pick up", "Drop it", "Stop", "Switch mode to manual"] - for command in test_inputs: - parsed = parser.parse(command) - print(f"Input: {command} → Intent: {parsed}") + return {"intent": "switch_mode", "params": {"mode": match.group(1)}} diff --git a/core/intent_router/intent_router.py b/core/intent_router/intent_router.py index c3d66d1..d0f1cac 100644 --- a/core/intent_router/intent_router.py +++ b/core/intent_router/intent_router.py @@ -1,59 +1,28 @@ -""" -Intent Router Module +"""Legacy intent router retained for backward compatibility.""" -This module receives structured intents from the IntentParser and routes them to -the appropriate downstream modules (AGI planner, real-time executor, etc). -""" +from __future__ import annotations from typing import Dict, Optional from core.execution.executor_bridge import ExecutorBridge -from core.planning.agi_planner import AGIPlanner +from core.planning.agi_planner import DeterministicPlanner class IntentRouter: - """ - Routes structured intents to the correct subsystem: - - AGIPlanner: for high-level planning and reasoning - - ExecutorBridge: for direct low-latency execution - """ + """Route parsed legacy intents to deterministic simulation or plan templates.""" def __init__(self, use_realtime: bool = False): - self.use_realtime = use_realtime - self.planner = AGIPlanner() + # ``use_realtime`` is historical naming. The executor is still simulation-only. + self.use_realtime = bool(use_realtime) + self.planner = DeterministicPlanner() self.executor = ExecutorBridge() def route(self, intent: Dict) -> Optional[str]: - """ - Routes an intent to the proper module. - - Parameters: - intent (Dict): Parsed intent with 'intent' and 'params' fields - - Returns: - str: Response or status - """ if not intent or "intent" not in intent: return "Invalid intent format" - intent_type = intent["intent"] - params = intent.get("params", {}) - - if self.use_realtime or intent_type in ["move", "stop", "pick_up", "drop"]: + intent_type = str(intent["intent"]) + params = dict(intent.get("params", {})) + if self.use_realtime or intent_type in {"move", "stop", "pick_up", "drop"}: return self.executor.execute(intent_type, params) - else: - return self.planner.plan(intent_type, params) - - -# Example usage -if __name__ == "__main__": - from core.intent_router.intent_parser import IntentParser - - parser = IntentParser() - router = IntentRouter() - - inputs = ["move forward", "switch mode to autonomous", "pick up"] - for text in inputs: - parsed = parser.parse(text) - result = router.route(parsed) - print(f"Input: {text} → Output: {result}") + return self.planner.plan(intent_type, params) diff --git a/core/planning/__init__.py b/core/planning/__init__.py index 483d4a6..35305e5 100644 --- a/core/planning/__init__.py +++ b/core/planning/__init__.py @@ -1,3 +1,3 @@ -from core.planning.agi_planner import AGIPlanner +from core.planning.agi_planner import AGIPlanner, DeterministicPlanner, PlanStep -__all__ = ["AGIPlanner"] +__all__ = ["AGIPlanner", "DeterministicPlanner", "PlanStep"] diff --git a/core/planning/agi_planner.py b/core/planning/agi_planner.py index 223a22c..32b41ee 100644 --- a/core/planning/agi_planner.py +++ b/core/planning/agi_planner.py @@ -1,59 +1,74 @@ -""" -AGI Planner Module +"""Legacy deterministic planning compatibility surface. -Translates high-level intents into executable multi-step plans using a mix -of symbolic logic, predictive modeling, and reasoning memory. +The historical module name is retained for import compatibility. No AGI, learned model, +or predictive planner is implemented here. Plans are deterministic templates over parsed, +caller-declared intents. """ -from typing import Dict +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, List + + +@dataclass(frozen=True) +class PlanStep: + action: str + parameters: Dict[str, str] -class AGIPlanner: - """ - Generates action plans from abstract intents. - Uses symbolic reasoning + placeholder learned reasoning modules. - """ +class DeterministicPlanner: + """Create explicit, inspectable plan templates for legacy high-level intents.""" - def __init__(self): - # Symbolic rules or learned models could be plugged here - self.reasoning_memory = [] + def __init__(self) -> None: + self.reasoning_memory: List[Dict[str, object]] = [] def plan(self, intent: str, params: Dict) -> str: - """ - Generate a plan based on the intent and its parameters. - - Parameters: - intent (str): Intent type, e.g., "navigate", "analyze", "assist" - params (Dict): Additional parameters, such as target or method - - Returns: - str: A textual or serialized representation of the plan - """ - self._store_memory(intent, params) - - if intent == "navigate": - destination = params.get("location", "unknown") - return self._plan_navigation(destination) - elif intent == "analyze": - target = params.get("target", "environment") - return self._plan_analysis(target) - elif intent == "assist": - subject = params.get("subject", "human operator") - return self._plan_assistance(subject) - else: - return f"Unknown intent: {intent}" - - def _plan_navigation(self, location: str) -> str: - return f"Plan: move to '{location}' using obstacle avoidance and path optimization" - - def _plan_analysis(self, target: str) -> str: - return f"Plan: scan and model '{target}' using onboard sensors and AGI context reasoning" - - def _plan_assistance(self, subject: str) -> str: - return f"Plan: initiate dialogue with '{subject}' and query needs via AGIIntentModel" - - def _store_memory(self, intent: str, params: Dict): - """ - Stores historical planning data for reasoning context - """ - self.reasoning_memory.append({"intent": intent, "params": params}) + normalized = (intent or "unknown").strip().lower() + normalized_params = {str(k): str(v) for k, v in dict(params or {}).items()} + self.reasoning_memory.append({"intent": normalized, "params": normalized_params}) + + if normalized == "navigate": + destination = normalized_params.get("location") + if not destination: + return "Plan rejected: navigate requires a declared location" + steps = [ + PlanStep("verify_destination", {"location": destination}), + PlanStep("request_path_provider", {"location": destination}), + PlanStep("hold_until_path_validated", {}), + ] + return self._serialize(steps) + if normalized == "analyze": + target = normalized_params.get("target") + if not target: + return "Plan rejected: analyze requires a declared target" + return self._serialize( + [ + PlanStep("select_available_observation", {"target": target}), + PlanStep("compute_declared_metrics", {"target": target}), + PlanStep("record_evidence", {"target": target}), + ] + ) + if normalized == "assist": + subject = normalized_params.get("subject") + if not subject: + return "Plan rejected: assist requires a declared subject" + return self._serialize( + [ + PlanStep("request_operator_instruction", {"subject": subject}), + PlanStep("await_authorized_action", {"subject": subject}), + ] + ) + return f"Unknown intent: {normalized}" + + @staticmethod + def _serialize(steps: List[PlanStep]) -> str: + body = "; ".join( + f"{step.action}({','.join(f'{k}={v}' for k, v in sorted(step.parameters.items()))})" + for step in steps + ) + return f"Plan: {body}" + + +class AGIPlanner(DeterministicPlanner): + """Deprecated compatibility name for :class:`DeterministicPlanner`.""" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 045c006..e0a42e6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,74 +1,74 @@ -# Architecture (SynapDrive-AI) +# Architecture ## Purpose -SynapDrive-AI is a **simulation-first** reference implementation of an intent-to-action pipeline: -input → decode/intent → context optimization → safety gate → actuation → evaluation → assurance receipt → memory. - -## Canonical wiring -The single source of truth is: -- `synapdrive_ai/pipeline.py` - -## Data contracts -### Intent packet (minimum) -- `intent: str` -- `confidence: float (0..1)` -- `source: str` -- `memory_context: list` - -### Result packet (normalized) -Produced by `DecisionRouter.route()`: -- `status: "success" | "failed"` -- `intent: str` -- `confidence: float` -- `duration: float` -- `raw_status: str` - -### Assurance receipt (contract) -Produced by `AssuranceMonitor` for every pipeline cycle: -- `schema: "synapdrive.assurance.v1"` -- `receipt_id` -- `cycle_index` -- `intent` -- `confidence` -- `safety_allowed` -- `safety_reason` -- `result_status` -- `evaluation_score` -- `passed` -- `issues` - -### Assurance health report (contract) -Produced by `SynapDrivePipeline.get_assurance_report()`: -- `schema: "synapdrive.assurance.health.v1"` -- `total_cycles` -- `passed_receipts` -- `failed_receipts` -- `blocked_cycles` -- `executed_cycles` -- `average_confidence` -- `average_evaluation_score` -- `latest_receipt_id` - -### Telemetry log entry (contract) -Produced by `ActuationEngine`: -- `timestamp` -- `intent` -- `confidence` -- `status` -- `duration` -- `source` -- `memory` -- `memory_context` - -## Extensibility -Integrations are intentionally optional: -- `synapdrive_ai/integrations/brainflow_adapter.py` (BrainFlow) -- `synapdrive_ai/integrations/lsl_adapter.py` (pylsl / LSL) - -They both output an intent packet and then call the canonical pipeline. - -## Runtime assurance -The assurance layer is intentionally observational. It does not execute actions, -approve unsafe inputs, or certify hardware behavior. Its job is to catch internal -contract drift, especially cases where a blocked intent somehow reaches actuation -or an allowed cycle returns a blocked result. + +SynapDrive-AI is a simulation-first, evidence-producing runtime for studying how a declared or decoded action candidate moves through uncertainty, permission, prediction, execution, feedback, memory, and replay. + +The canonical implementation is `synapdrive_ai/pipeline.py`. Compatibility wrappers route into that pipeline rather than maintaining parallel control loops. + +## Canonical cycle + +```text +input packet + -> context enrichment without confidence inflation + -> probability distribution and uncertainty + -> permission + signal quality + drift + world-model risk + -> governed pre-action decision + -> lexical compatibility guard + -> simulated router, only when admitted + -> explicit world-state commit for modeled successful actions + -> reality reconciliation + -> validated or quarantined evidence memory + -> assurance receipt + -> SHA-256 linked evidence entry +``` + +Blocked, analysis-only, unknown, unmodeled, low-quality, high-uncertainty, excessive-drift, permission-denied, or excessive-risk inputs do not reach the simulated action router. + +## Intent packet + +The minimum runtime contract is: + +- `intent`: proposed simulation action name; +- `confidence`: numeric value in `[0, 1]` with explicit semantics supplied by the producer; +- `source`: provenance string. + +Important optional fields include: + +- `probabilities`: class/action probability distribution; +- `signal_quality`: engineering quality score; +- `drift_score`: fitted feature-drift score; +- `required_confidence`: caller-requested stricter pre-action threshold; +- `analysis_only`: prevents actuation even when other gates pass; +- `inference_authority`: provenance category such as `declared-command`, `synthetic-ground-truth`, or `locally-qualified-decoder`; +- `confidence_semantics`: explanation of what the numeric confidence means; +- `neural_decode_performed`: explicit boolean provenance flag. + +## Acquisition is not decoding + +`BrainFlowIntentSource` and `LSLIntentSource` acquire samples and compute signal-quality metadata. They do not infer intent from RMS amplitude or another hidden heuristic. + +Without an explicitly supplied decoder callback, both adapters emit an analysis-only abstention. A decoder callback must return a packet that still passes the canonical governed runtime. + +## Qualified decoder bridge + +`QualifiedDecoderAdapter` is the concrete bridge from the built-in decoder benchmark surface to runtime use. It requires: + +1. deterministic held-out evaluation; +2. local metric gates; +3. a complete explicit decoder-label to simulation-action map; +4. exact runtime channel-count agreement; +5. exact runtime sampling-rate agreement; +6. confidence above the configured abstention threshold. + +Failure at any point yields an abstention. Passing these local software gates is not clinical, participant, or physical-system validation. + +## Evidence + +Every canonical cycle produces an assurance receipt and an entry in a SHA-256 linked evidence chain. Exported ledgers can also be signed with Ed25519. + +The chain and signature establish integrity properties of recorded bytes. They do not prove that sensor measurements, decoder interpretations, or scientific claims are true. + +## Compatibility namespaces + +The historical `synapdrive_ai.agi` package name remains for import compatibility. Canonical v1 code does not claim AGI. Its retained classes are deterministic mappers, context handling, compatibility diagnostics, and disabled legacy adaptation surfaces. diff --git a/docs/ARCHITECTURE_V1.md b/docs/ARCHITECTURE_V1.md new file mode 100644 index 0000000..8bd1363 --- /dev/null +++ b/docs/ARCHITECTURE_V1.md @@ -0,0 +1,107 @@ +# SynapDrive-AI v1.0 Architecture + +## Design rule + +Observation, inferred class, declared goal, authorized action, execution result, user feedback, and memory consequence are separate states. The repository does not silently collapse them into one concept called "intent." + +## Runtime admission + +`GovernedRuntime` evaluates each proposed action against: + +- confidence and any stricter per-task confidence requirement; +- distribution-derived uncertainty; +- signal-quality score; +- fitted decoder-drift score when supplied; +- capability permission; +- explicit world-model feasibility; +- explicit software-policy risk. + +Unknown actions are infeasible and receive maximal software risk. Unknown or analysis-only inference fails closed. Policy values are testable software thresholds, not hardware safety limits. + +## Signal integrity + +`SignalQualityAnalyzer` checks finite samples, flatline behavior, zero-valued dropout, observed-extrema clipping, and line-frequency power. It exposes component measurements and an aggregate engineering score. + +The aggregate score is not a clinical signal-quality index. + +## Spectral analysis + +`BandPowerAnalyzer` calculates delta, theta, alpha, beta, and gamma power plus two ratio features. Its historical `confidence` field is an uncalibrated heuristic separation score retained for API compatibility. + +`SessionAnalyzer` without a decoder is analysis-only. Band ratios cannot become movement commands. With a decoder callback, the decoder output is quality-tagged and then passed through the governed runtime. + +## Decoder benchmarking + +Built-in inspectable baselines are: + +- `SpectralCentroidDecoder`; +- `RiemannianCentroidDecoder`, a compact log-Euclidean covariance baseline; +- `EnsembleDecoder`, which averages member probabilities. + +The arena reports accuracy, balanced accuracy, multiclass Brier score, expected calibration error, abstention coverage, and selective accuracy on an explicit labeled NPZ dataset. + +`QualifiedDecoderAdapter` can promote a decoder only after local held-out gates and a complete label-to-action map. Runtime shape or sampling-rate mismatch abstains. + +## Uncertainty and calibration + +`neuro.uncertainty` provides normalized distributions, normalized entropy, margin uncertainty, ensemble disagreement, and a histogram reliability calibrator fitted only from supplied confidence/correctness observations. + +## Drift + +`FeatureDriftMonitor` fits a Mahalanobis-distance baseline from supplied feature vectors. It has no baseline and no authority until fitted. + +## Multimodal fusion + +`WeightedEvidenceFusion` combines explicit modality probability distributions using caller-supplied reliability weights. It does not infer those reliability weights automatically. + +## ErrP path + +`ErrPFeatureExtractor` provides event-window features. `ErrPLDAClassifier` must be trained from supplied labeled calibration features before it can emit a probability. No pretrained participant-independent ErrP detector is bundled. + +## World model and counterfactual planning + +`WorldModel` predicts only explicitly registered actions. Unregistered actions fail closed. Successful modeled simulation actions commit their predicted symbolic state to the canonical world model. + +`CounterfactualPlanner` ranks a caller-bounded candidate set using transparent fixed software-policy weights over intent alignment, decoder confidence, caller goal score, reversibility, and registered risk. The weights are not learned and are not presented as optimal. + +`SharedAutonomyArbiter` proposes a bounded action. It does not bypass runtime admission or directly actuate. + +## Runtime assurance and reversion components + +`SimplexController` is an explicit two-controller utility that dispatches to an advanced controller only when a completed runtime decision is allowed; otherwise it selects the supplied reversionary controller. + +`ShadowController` records an experimental policy's proposed action without granting that policy authority over the trusted action path. + +The canonical pipeline itself fails closed before its simulated router and carries a `hold_position` safe-state recommendation on denied cycles. + +## Reality reconciliation and memory + +`RealityReconciler` compares predicted software success, execution result, and optional explicit rejection or externally supplied ErrP probability. + +Aligned successful cycles can enter evidence memory as `validated`. Contradicted cycles are `quarantined`. Historical successful episodes may be attached as context, but they do not inflate decoder/parser confidence. + +## Adaptation + +`GuardedThresholdAdapter` evaluates a candidate confidence threshold against caller-supplied held-out records. A candidate is promoted only when its defined utility improves without increasing unsafe accepts under that supplied validation set. + +It adapts one threshold only. It is not an autonomous self-modifying learning system. + +## Evidence and signing + +Every canonical cycle emits deterministic stable evidence content into a SHA-256 hash chain. Wall-clock timestamps are intentionally excluded from the evidence payload used for deterministic content comparison. + +The evidence CLI can generate an Ed25519 keypair, sign a ledger, verify a detached signature, and verify the hash chain. + +## Fail-closed defaults + +`RuntimePolicy` defaults: + +- minimum confidence: `0.45`; +- maximum uncertainty: `0.70`; +- minimum signal quality: `0.60`; +- maximum predicted software risk: `0.70`; +- maximum drift score: `6.0`; +- ErrP contradiction threshold: `0.70`; +- safe-state recommendation: `hold_position`. + +These values are repository policy fixtures for software evaluation. They are not medical, robotic, or industrial safety limits. diff --git a/docs/BENCHMARKING.md b/docs/BENCHMARKING.md new file mode 100644 index 0000000..42d6671 --- /dev/null +++ b/docs/BENCHMARKING.md @@ -0,0 +1,64 @@ +# Decoder Benchmarking + +## Explicit dataset contract + +The benchmark CLI consumes an NPZ file containing: + +- `X`: numeric array shaped `(epochs, channels, samples)`; +- `y`: one explicit label per epoch; +- `sampling_rate`: positive scalar; +- `channel_names`: optional one-dimensional string array. + +Missing labels or sampling rate cause failure. The loader does not synthesize them. + +## Create epochs from a recording and event table + +`SynapDrive-AI` can prepare a benchmark NPZ from a supported recording plus an event CSV containing `onset_s,label` columns: + +```text +synapdrive-prepare-dataset recording.edf events.csv --out participant_epochs.npz --tmin 0 --tmax 1 +``` + +Use `--channel` repeatedly when a subset of channels is required. CSV/NPY recordings need the correct `--sr` value because those formats do not carry the same sampling metadata contract as EDF/BDF. + +## Run the arena + +```text +synapdrive-benchmark participant_epochs.npz --decoder all --seed 7 --abstain 0.60 +``` + +Available built-in decoders are `spectral`, `riemannian`, `ensemble`, and `all`. + +Metrics include: + +- accuracy; +- balanced accuracy; +- multiclass Brier score; +- expected calibration error; +- coverage at the chosen abstention threshold; +- selective accuracy among accepted predictions. + +The deterministic split is stratified by class. It is a repository engineering baseline, not a publication protocol. Participant-level claims require experiment-appropriate participant/session grouping, preprocessing, statistical analysis, and external replication. + +## Runtime qualification + +`QualifiedDecoderAdapter` is available to library users who want to connect a benchmark decoder to BrainFlow, LSL, `SessionAnalyzer`, or the canonical pipeline. + +Construction performs deterministic held-out evaluation using `QualificationPolicy`. A decoder is not locally qualified unless it meets all configured metric gates and every decoder class has an explicit action mapping. A qualified adapter is then refit on the supplied dataset. + +At runtime it still abstains when: + +- channel count differs from the qualification dataset; +- sampling rate differs from the qualification dataset; +- the winning class has no action mapping; +- winning probability is below the abstention threshold. + +Local qualification means only that the model met the configured repository gates on the supplied data. It is not proof of participant generalization, neural-intent validity, clinical performance, or safe physical control. + +## Built-in decoder scope + +`SpectralCentroidDecoder` uses relative FFT band-power features and class centroids. + +`RiemannianCentroidDecoder` computes regularized channel covariance matrices, applies the symmetric matrix logarithm, vectorizes the upper triangle, standardizes features, and compares class centroids. It is an inspectable compact baseline, not a replacement for specialist Riemannian BCI libraries. + +`EnsembleDecoder` averages member class probabilities after verifying common class ordering. diff --git a/docs/CLAIM_BOUNDARIES.md b/docs/CLAIM_BOUNDARIES.md new file mode 100644 index 0000000..3622604 --- /dev/null +++ b/docs/CLAIM_BOUNDARIES.md @@ -0,0 +1,38 @@ +# Claim Boundaries + +SynapDrive-AI is a research software repository. The following boundaries are part of the design and should remain visible in publications, repository descriptions, demonstrations, and derived artifacts. + +## What executable tests can establish + +The test suite can establish software properties such as deterministic calculations, expected policy decisions, explicit permission denial, hash-chain integrity checks, correct quarantine behavior, dataset-contract enforcement, decoder metric calculation, and fail-closed behavior under defined injected faults. + +These are software claims only. + +## What the repository does not establish + +The repository does not establish any of the following: + +* that a decoded class equals a person's true intent; +* clinical sensitivity or specificity; +* medical-device safety or effectiveness; +* reliable real-time BCI control in humans; +* safe physical actuation; +* generalization to an untested participant, session, device, or environment; +* validated ErrP detection without labeled participant calibration; +* calibrated confidence before calibration is performed; +* accurate physical predictions for actions absent a validated plant/world model; +* cybersecurity certification; +* autonomous authority to act without a human-defined permission model; +* AGI, consciousness, sentience, or general intelligence. + +## Synthetic verification data + +Tests and `scripts/run_v1_validation.py` generate deterministic signals in order to verify software paths. Those signals are labeled as synthetic verification data. Their benchmark accuracy must never be cited as human EEG performance. + +## Real-data use + +The benchmark runtime accepts explicitly labeled epoch arrays. Scientific conclusions from a dataset remain the responsibility of the experiment design, dataset license, preprocessing choices, split design, participant/session grouping, statistical analysis, and external replication. + +## Physical control + +The built-in actuation layer is simulated. Any hardware integration requires an independently designed and validated safety architecture appropriate to the hardware, environment, user population, and applicable regulation. diff --git a/docs/DONOR_PROVENANCE.md b/docs/DONOR_PROVENANCE.md new file mode 100644 index 0000000..63424fa --- /dev/null +++ b/docs/DONOR_PROVENANCE.md @@ -0,0 +1,31 @@ +# Donor Repository Provenance + +The v1 upgrade was informed by the donor repositories supplied for the upgrade. The implementation deliberately avoids a blind repository merge. + +## IX-IntentRealityLoop + +Useful architectural ideas included separation of intent, permission, bounded action, feedback, outcome delta, memory consequence, and replay evidence. + +Its supplied license is source-available rather than permissive. SynapDrive v1 therefore does not bulk-copy its source. The corresponding SynapDrive mechanisms are clean implementations inside this repository. + +## IX-Sally + +Useful architectural ideas included explicit world state, planning, uncertainty, memory governance, and proposal/review separation. + +Its supplied license is also source-available. SynapDrive v1 does not bulk-copy its source. The world model, counterfactual planner, shared-autonomy arbiter, guarded adaptation, and evidence-memory code in this repo are local implementations. + +## IX-Autonomy-Assurance-Case-Runtime + +The donor is Apache-2.0 licensed. Its strongest contribution to the upgrade direction was evidence-first runtime assurance, provenance, negative controls, and fail-closed evaluation. SynapDrive retains its own compact assurance data model rather than importing the donor package as a runtime dependency. + +## IX + +The donor is MIT licensed. Its contract-oriented design informed the decision to keep policy rules explicit and machine-readable. SynapDrive uses typed policy objects and JSON loading rather than embedding a separate language runtime into the control loop. + +## IX-Operator + +The donor was not imported into SynapDrive's transport path. During donor inspection, its test surface exposed a tamper-detection failure in the evaluated environment. SynapDrive v1 therefore implements evidence signing independently with the maintained `cryptography` Ed25519 primitive and keeps networking/transport outside this upgrade. + +## General rule + +A donor name is not treated as evidence. SynapDrive v1 only claims mechanisms that exist in its own executable source and tests. diff --git a/docs/EVIDENCE_AND_REPLAY.md b/docs/EVIDENCE_AND_REPLAY.md new file mode 100644 index 0000000..a64663f --- /dev/null +++ b/docs/EVIDENCE_AND_REPLAY.md @@ -0,0 +1,45 @@ +# Evidence, Replay, and Signing + +## Two different records + +SynapDrive has two related but distinct recording mechanisms. + +`--record` stores replay inputs and a compact output summary. Replay is intended to reproduce the software decision path from the stored input intent packet. + +`--evidence-out` exports the v1 cycle evidence chain. Each entry includes the previous entry hash and its own SHA-256 digest. + +## Create evidence + +```text +python -m synapdrive_ai --text "move left" --no-delay --evidence-out run.evidence.jsonl +``` + +Verify the chain: + +```text +synapdrive-evidence verify-chain run.evidence.jsonl +``` + +## Detached Ed25519 signature + +Generate a keypair: + +```text +synapdrive-evidence keygen --private evidence-private.pem --public evidence-public.pem +``` + +Keep the private key out of source control. + +Sign an exported evidence ledger: + +```text +synapdrive-evidence sign run.evidence.jsonl --private evidence-private.pem --out run.signature.json +``` + +Verify: + +```text +synapdrive-evidence verify-signature run.evidence.jsonl --public evidence-public.pem --signature run.signature.json +``` + +The signature authenticates the bytes against possession of the private key. It does not validate the truth of sensor measurements or scientific claims. diff --git a/docs/FAULT_INJECTION.md b/docs/FAULT_INJECTION.md new file mode 100644 index 0000000..643d0a2 --- /dev/null +++ b/docs/FAULT_INJECTION.md @@ -0,0 +1,21 @@ +# Fault Injection and Stress Campaigns + +`synapdrive_ai.stress` contains deterministic signal fault injection for verification. It is designed to test whether defined bad-input conditions trigger the expected fail-closed behavior. + +Implemented injectors include: + +* Gaussian noise; +* contiguous dropout; +* amplitude clipping; +* line-frequency contamination; +* channel swaps. + +Run the built-in campaign: + +```text +synapdrive-stress --runs 100 --seed 7 +``` + +A campaign reports every trial, its measured signal-quality score/state, whether policy expected it to block, whether the callback actually blocked, and whether the invariant passed. + +The stress harness is not a proof of safety against every possible sensor or software failure. It is an extensible negative-control surface. diff --git a/docs/SAFETY_MODEL.md b/docs/SAFETY_MODEL.md index ac3b3ed..ac67184 100644 --- a/docs/SAFETY_MODEL.md +++ b/docs/SAFETY_MODEL.md @@ -1,39 +1,64 @@ -# Safety Model (Simulation-First) - -## Safety stance -This repo defaults to a conservative policy: -- Unknown or low-confidence intents are **blocked** -- The pipeline always returns a stable response shape (no crashing on drift) -- Telemetry is treated as a contract because UIs/tests depend on it - -## SafetyGuard policy (current) -- Confidence threshold gating (blocks below a threshold) -- Suspicious intent keyword blocks (if present) - -## Threat model (practical) -- Accidental actuation due to noisy input -- Misrouting due to schema drift -- “Demo inflation” where the README claims behaviors that do not exist - -## What we do about it -- Contract tests enforce output shapes and telemetry keys -- CI runs lint + type check + tests + coverage -- Replay artifacts allow reviewers to reproduce behavior without hardware -- Assurance receipts check that each simulated cycle obeyed the blocked/allowed invariant - -## Non-goals (explicit) -- Clinical diagnosis -- Medical device claims -- Safety certification for real-world actuation - -## Assurance invariants -For each completed cycle, the monitor records a receipt and checks: - -- blocked cycles return a `blocked` result -- blocked cycles do not reach simulated actuation -- allowed cycles do not return a `blocked` result -- allowed cycles reach the simulated router -- intent and confidence fields are normalized enough for review - -A failed receipt is a sign of internal contract drift and should be treated as a -red test target before adding features. +# Safety Model + +## Scope + +The safety model governs software simulation only. It does not certify physical machinery, a medical device, a BCI decoder, or a human participant protocol. + +## Primary rule + +A proposed action must satisfy every active pre-action gate before the simulated action router is reachable. + +The canonical runtime blocks on: + +- `analysis_only` inference; +- unknown intent/action; +- confidence below the repository policy or a stricter task requirement; +- uncertainty above policy; +- signal quality below policy; +- predicted software risk above policy; +- decoder drift above policy; +- permission denial; +- unmodeled action or failed world-model precondition. + +The historical `SafetyGuard` remains as a compatibility lexical guard for a small prohibited phrase set and a minimum confidence check. It is not the primary assurance model and is not represented as an ethical reasoning system. + +## Safe-state behavior + +Denied cycles do not reach the simulated action router. They carry the policy's `hold_position` safe-state recommendation in the result contract. + +`SimplexController` is separately available when an integration needs to select between an advanced and a caller-supplied reversionary controller after a runtime decision. + +## Permissions + +`PermissionGate` supports allow and deny glob patterns plus full non-safe capability revocation. Halt/stop/hold safe-state actions remain permitted after revocation. + +## World-model boundary + +Only explicitly registered actions are considered feasible. Unknown actions preserve state, are marked infeasible, and receive maximal software risk. Successful modeled simulation actions commit the explicit predicted state. + +Registered risk numbers are test policy fixtures. They are not empirical accident probabilities. + +## Neural-input boundary + +Hardware acquisition by itself has no action authority. BrainFlow and LSL without a decoder return an analysis-only abstention. + +Offline spectral band analysis without a decoder also remains analysis-only. RMS amplitude and spectral-band ratios are not converted into neural movement commands. + +## Evidence invariants + +For every canonical cycle, assurance verifies internal properties such as: + +- a denied cycle did not reach simulated actuation; +- an allowed cycle reached the simulated router; +- result status agrees with the recorded admission path; +- required intent/confidence fields remain reviewable. + +A SHA-256 evidence chain detects modification of recorded cycle content. Ed25519 can authenticate an exported ledger against possession of a signing key. + +Neither mechanism validates the truth of the underlying scientific interpretation. + +## Threats exercised by the repository + +Tests and the stress harness cover defined cases including flatline/dropout, clipping/noise, analysis-only inference, uncertainty, drift, permission revocation, unmodeled actions, decoder sampling mismatch, evidence tampering, contradictory feedback, and task-level pre-action confidence failure. + +The set is intentionally extensible and is not claimed to be exhaustive. diff --git a/pyproject.toml b/pyproject.toml index 0a7aa5c..77a7b93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "synapdrive-ai" -version = "0.1.0" -description = "Simulation-first intent-to-safety-to-actuation pipeline for BCI-inspired control" +version = "1.0.0" +description = "Governed neuroadaptive shared-autonomy runtime for simulation and labeled EEG research data" readme = "README.md" requires-python = ">=3.11" license = "Apache-2.0" dependencies = [ "numpy>=1.23,<3.0", - "flask>=2.3,<4.0", + "cryptography>=42,<48", ] [project.optional-dependencies] @@ -26,10 +26,15 @@ dev = [ [project.scripts] synapdrive-ai = "synapdrive_ai.main.cli:main" synapdrive-dashboard = "synapdrive_ai.interface.web_dashboard:main" +synapdrive-benchmark = "synapdrive_ai.benchmarking.cli:main" +synapdrive-stress = "synapdrive_ai.stress.cli:main" +synapdrive-evidence = "synapdrive_ai.assurance.cli:main" +synapdrive-prepare-dataset = "synapdrive_ai.benchmarking.prepare_cli:main" [tool.setuptools.packages.find] include = ["synapdrive_ai*", "core*"] exclude = ["synapdrive_ai.tests*"] +namespaces = false [tool.setuptools.package-data] synapdrive_ai = ["interface/templates/*.html"] diff --git a/requirements-dev.txt b/requirements-dev.txt index 3c5a75a..2b362f1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,6 @@ -ruff>=0.6.0 -pytest>=7.0 -pytest-cov>=5.0.0 -coverage>=7.6.0 -pre-commit>=3.7.0 -pyright>=1.1.380 +ruff>=0.6.0,<1.0 +pytest>=7.0,<10.0 +pytest-cov>=5.0.0,<8.0 +coverage>=7.6.0,<8.0 +pre-commit>=3.7.0,<5.0 +pyright>=1.1.380,<2.0 diff --git a/requirements.txt b/requirements.txt index 7d81796..cc56b6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -numpy>=1.23 -flask>=2.3 +numpy>=1.23,<3.0 +cryptography>=42,<48 diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..8c85017 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Repository validation scripts.""" diff --git a/scripts/run_v1_validation.py b/scripts/run_v1_validation.py new file mode 100644 index 0000000..fb3e6f0 --- /dev/null +++ b/scripts/run_v1_validation.py @@ -0,0 +1,472 @@ +from __future__ import annotations + +import argparse +import json +import platform +import sys +from pathlib import Path + +import numpy as np +from cryptography.hazmat.primitives import serialization + +from synapdrive_ai.adaptation.guarded import GuardedThresholdAdapter +from synapdrive_ai.assurance.hashchain import EvidenceHashChain +from synapdrive_ai.assurance.signing import Ed25519EvidenceSigner, verify_ed25519 +from synapdrive_ai.bci.intent_generator import generate_intent +from synapdrive_ai.benchmarking.dataset import EpochDataset +from synapdrive_ai.benchmarking.decoders import ( + EnsembleDecoder, + RiemannianCentroidDecoder, + SpectralCentroidDecoder, +) +from synapdrive_ai.benchmarking.evaluation import evaluate_decoder +from synapdrive_ai.benchmarking.runtime_adapter import QualifiedDecoderAdapter +from synapdrive_ai.cognition.planner import CandidateAction, CounterfactualPlanner +from synapdrive_ai.cognition.shared_autonomy import SharedAutonomyArbiter +from synapdrive_ai.cognition.world_model import ActionModel, WorldModel +from synapdrive_ai.neuro.drift import FeatureDriftMonitor +from synapdrive_ai.neuro.eeg_loader import EEGRecording +from synapdrive_ai.neuro.fusion import ModalityEvidence, WeightedEvidenceFusion +from synapdrive_ai.neuro.session_analyzer import SessionAnalyzer +from synapdrive_ai.neuro.signal_quality import SignalQualityAnalyzer +from synapdrive_ai.neuro.task_planner import ExecutorBridge, TaskPlan, TaskStep +from synapdrive_ai.pipeline import SynapDrivePipeline +from synapdrive_ai.runtime.shadow import ShadowController +from synapdrive_ai.stress.campaign import StressCampaign +from synapdrive_ai.stress.faults import FaultInjector + + +def _verification_dataset(seed: int = 41) -> EpochDataset: + """Deterministic synthetic verification data, never presented as human EEG.""" + rng = np.random.default_rng(seed) + sr = 128.0 + n = 128 + t = np.arange(n) / sr + epochs = [] + labels = [] + for label, freq in (("class_10hz", 10.0), ("class_20hz", 20.0)): + for _ in range(24): + a = np.sin(2 * np.pi * freq * t) + rng.normal(0, 0.10, n) + b = 0.8 * np.sin(2 * np.pi * freq * t + 0.2) + rng.normal(0, 0.10, n) + epochs.append(np.stack([a, b])) + labels.append(label) + return EpochDataset( + np.asarray(epochs), + np.asarray(labels), + sr, + ["SIM-A", "SIM-B"], + "deterministic-synthetic-verification-only", + ) + + +def _check(name: str, passed: bool, **details) -> dict: + return {"check": name, "passed": bool(passed), **details} + + +def run_validation() -> dict: + checks: list[dict] = [] + + pipe = SynapDrivePipeline(simulate_delay=False) + success = pipe.run_text_command("move left") + checks.append( + _check( + "governed_declared_command_path", + success["status"] == "success" and success["runtime"]["allowed"], + authority=success["intent"].get("inference_authority"), + ) + ) + + unmodeled = pipe.run_intent_packet( + { + "intent": "invented_action", + "confidence": 1.0, + "source": "validation", + "uncertainty": 0.0, + "signal_quality": 1.0, + } + ) + checks.append( + _check( + "unmodeled_action_fails_closed", + unmodeled["status"] == "blocked" + and "world-model-precondition-failed" in unmodeled["reason"], + reason=unmodeled.get("reason"), + ) + ) + + analysis_only = pipe.run_intent_packet( + { + "intent": "move_left", + "confidence": 1.0, + "source": "analysis-only-validation", + "analysis_only": True, + "uncertainty": 0.0, + "signal_quality": 1.0, + } + ) + checks.append( + _check( + "analysis_only_inference_cannot_actuate", + analysis_only["status"] == "blocked" + and "analysis-only-inference" in analysis_only["reason"], + ) + ) + + action_count_before = len(pipe.get_action_log()) + low_quality_packet = generate_intent("move left") + low_quality_packet["signal_quality"] = 0.0 + blocked = pipe.run_intent_packet(low_quality_packet) + checks.append( + _check( + "low_quality_fail_closed", + blocked["status"] == "blocked" + and len(pipe.get_action_log()) == action_count_before, + reason=blocked.get("reason"), + ) + ) + + uncertainty_packet = generate_intent("move left") + uncertainty_packet["probabilities"] = {"move_left": 0.5, "move_right": 0.5} + uncertain = pipe.run_intent_packet(uncertainty_packet) + checks.append( + _check( + "high_uncertainty_abstains", + uncertain["status"] == "blocked" + and "uncertainty-above-policy" in uncertain["reason"], + uncertainty=uncertain["intent"].get("uncertainty"), + ) + ) + + drift_monitor = FeatureDriftMonitor(quantile=0.95).fit( + [[0.0, 0.0], [0.1, -0.1], [-0.1, 0.1], [0.05, 0.02], [-0.05, -0.02]] + ) + drift = drift_monitor.evaluate([5.0, 5.0]) + drift_packet = generate_intent("move left") + drift_packet["drift_score"] = drift.score + drift_block = pipe.run_intent_packet(drift_packet) + checks.append( + _check( + "decoder_drift_fail_closed", + drift.drifted + and drift_block["status"] == "blocked" + and "decoder-drift-above-policy" in drift_block["reason"], + drift_score=drift.score, + learned_threshold=drift.threshold, + ) + ) + + permission_pipe = SynapDrivePipeline(simulate_delay=False) + permission_pipe.runtime.permissions.revoke_all() + denied = permission_pipe.run_text_command("move left") + stop = permission_pipe.run_text_command("stop") + checks.append( + _check( + "permission_revoke_preserves_safe_stop", + denied["status"] == "blocked" and stop["status"] == "success", + ) + ) + + contradicted = pipe.run_text_command("move right", feedback={"errp_probability": 0.99}) + checks.append( + _check( + "errp_contradiction_quarantines_memory", + contradicted["reality"]["aligned"] is False + and contradicted["memory"]["state"] == "quarantined", + ) + ) + + entries = pipe.evidence.chain.entries() + checks.append( + _check( + "evidence_chain_valid", + EvidenceHashChain.verify(entries), + entries=len(entries), + ) + ) + tampered = [entry.to_dict() for entry in entries] + tampered[-1]["payload"]["result"]["status"] = "tampered" + checks.append( + _check( + "evidence_tamper_detected", + not EvidenceHashChain.verify(tampered), + ) + ) + + signer = Ed25519EvidenceSigner.generate() + signing_payload = json.dumps(entries[-1].to_dict(), sort_keys=True).encode("utf-8") + signature = signer.sign(signing_payload) + public_pem = signer.private_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + signing_ok = verify_ed25519(public_pem, signing_payload, signature) + tamper_rejected = not verify_ed25519(public_pem, signing_payload + b"x", signature) + checks.append( + _check( + "ed25519_evidence_signature", + signing_ok and tamper_rejected, + ) + ) + + quality = SignalQualityAnalyzer(256).analyze(np.zeros(512)) + checks.append( + _check( + "flatline_quality_detection", + "flatline" in quality.issues and quality.score < 0.55, + score=quality.score, + state=quality.state, + ) + ) + + fusion = WeightedEvidenceFusion().fuse( + [ + ModalityEvidence("eeg", {"left": 0.7, "right": 0.3}, 0.7), + ModalityEvidence("emg", {"left": 0.9, "right": 0.1}, 0.9), + ] + ) + checks.append( + _check( + "multimodal_reliability_weighted_fusion", + fusion.intent == "left" and fusion.weights == {"eeg": 0.7, "emg": 0.9}, + confidence=fusion.confidence, + uncertainty=fusion.uncertainty, + ) + ) + + plan_world = WorldModel({"goal": 0}) + plan_world.register_many( + [ + ActionModel("direct", effects={"goal": 1}, predicted_risk=0.8), + ActionModel("detour", effects={"goal": 1}, predicted_risk=0.1), + ] + ) + proposal = SharedAutonomyArbiter(CounterfactualPlanner(plan_world)).propose( + user_intent="direct", + user_confidence=0.8, + machine_candidates=[CandidateAction("detour", 0.95, 0.9)], + goal=lambda state: float(state.get("goal", 0)), + ) + checks.append( + _check( + "counterfactual_shared_autonomy_prefers_lower_risk_candidate", + proposal.selected_action == "detour" and proposal.preserved_user_goal, + selected_action=proposal.selected_action, + ) + ) + + shadow_calls: list[dict] = [] + + def shadow_policy(context: dict) -> tuple[str, dict]: + shadow_calls.append(dict(context)) + return "experimental_action", {"score": 0.9} + + shadow = ShadowController(shadow_policy).evaluate("trusted_action", {"cycle": 1}) + checks.append( + _check( + "shadow_policy_has_no_actuation_authority", + shadow.shadow_action == "experimental_action" + and shadow.trusted_action == "trusted_action" + and len(shadow_calls) == 1, + ) + ) + + adaptation_rows = [ + {"confidence": 0.95, "safe": True}, + {"confidence": 0.80, "safe": True}, + {"confidence": 0.60, "safe": False}, + {"confidence": 0.55, "safe": False}, + ] + adaptation = GuardedThresholdAdapter(0.45).evaluate_candidate(0.65, adaptation_rows) + checks.append( + _check( + "guarded_adaptation_requires_held_out_improvement", + adaptation.promoted + and adaptation.candidate_unsafe_accepts < adaptation.old_unsafe_accepts, + old_unsafe_accepts=adaptation.old_unsafe_accepts, + candidate_unsafe_accepts=adaptation.candidate_unsafe_accepts, + ) + ) + + first_replay = SynapDrivePipeline(simulate_delay=False).run_intent_packet( + generate_intent("move left") + ) + second_replay = SynapDrivePipeline(simulate_delay=False).run_intent_packet( + generate_intent("move left") + ) + checks.append( + _check( + "deterministic_cycle_evidence_replay", + first_replay["evidence"]["event_hash"] == second_replay["evidence"]["event_hash"], + event_hash=first_replay["evidence"]["event_hash"], + ) + ) + + dataset = _verification_dataset() + decoder = EnsembleDecoder([SpectralCentroidDecoder(), RiemannianCentroidDecoder()]) + benchmark = evaluate_decoder(decoder, dataset, seed=7) + checks.append( + _check( + "decoder_benchmark_executes", + benchmark.n_test > 0 and 0.0 <= benchmark.accuracy <= 1.0, + dataset=dataset.source, + metrics=benchmark.to_dict(), + ) + ) + + qualified_adapter = QualifiedDecoderAdapter( + SpectralCentroidDecoder(), + dataset, + {"class_10hz": "move_left", "class_20hz": "move_right"}, + seed=7, + ) + decoded_packet = qualified_adapter( + dataset.epochs[0], + {"sampling_rate": dataset.sampling_rate}, + ) + decoded_result = SynapDrivePipeline(simulate_delay=False).run_intent_packet(decoded_packet) + checks.append( + _check( + "locally_qualified_decoder_reaches_governed_runtime", + qualified_adapter.qualification.qualified + and decoded_packet["neural_decode_performed"] is True + and decoded_result["status"] == "success", + qualification=qualified_adapter.qualification.to_dict(), + decoded_intent=decoded_packet["intent"], + ) + ) + + mismatch_packet = qualified_adapter( + dataset.epochs[0], + {"sampling_rate": dataset.sampling_rate + 1.0}, + ) + checks.append( + _check( + "qualified_decoder_sampling_mismatch_abstains", + mismatch_packet["intent"] == "unknown" + and mismatch_packet["analysis_only"] is True, + reason=mismatch_packet.get("abstention_reason"), + ) + ) + + one_channel_dataset = EpochDataset( + dataset.epochs[:, :1, :], + dataset.labels.copy(), + dataset.sampling_rate, + ["SIM-A"], + "deterministic-synthetic-verification-only/session-bridge", + ) + session_adapter = QualifiedDecoderAdapter( + SpectralCentroidDecoder(), + one_channel_dataset, + {"class_10hz": "move_left", "class_20hz": "move_right"}, + seed=7, + ) + session_recording = EEGRecording( + channels=["SIM-A"], + data=one_channel_dataset.epochs[0], + sampling_rate=one_channel_dataset.sampling_rate, + duration_s=one_channel_dataset.epochs.shape[-1] / one_channel_dataset.sampling_rate, + source_file="deterministic-synthetic-verification-only/session-bridge", + ) + session_report = SessionAnalyzer( + channel="SIM-A", + window_s=session_recording.duration_s, + step_s=session_recording.duration_s, + decoder=session_adapter, + ).run(session_recording) + checks.append( + _check( + "qualified_decoder_session_analyzer_bridge", + session_adapter.qualification.qualified + and session_report.n_epochs == 1 + and session_report.n_success == 1, + pipeline_status=session_report.epochs[0].pipeline_status, + confidence=session_report.epochs[0].pipeline_confidence, + ) + ) + + plan_bridge = ExecutorBridge(simulate_delay=False) + plan_trace = plan_bridge.execute( + TaskPlan( + "pre-action-threshold-validation", + [TaskStep("move left", min_confidence=1.1, fallback="freeze")], + ) + ) + checks.append( + _check( + "task_threshold_blocks_before_simulated_actuation", + plan_trace.steps[0].pipeline_status == "deferred" + and plan_bridge._pipe.get_action_log() == [], + ) + ) + + state_pipe = SynapDrivePipeline(simulate_delay=False) + state_result = state_pipe.run_text_command("switch mode to assistive") + checks.append( + _check( + "successful_action_commits_explicit_world_state", + state_result["status"] == "success" + and state_pipe.runtime.world_model.state.get("mode") == "assistive", + world_state=dict(state_pipe.runtime.world_model.state), + ) + ) + + base = np.sin(2 * np.pi * 10 * np.arange(512) / 256) + injector = FaultInjector(9) + faults = [ + ("dropout", injector.dropout(base, 0.8).signal), + ("flatline", np.zeros_like(base)), + ("noise", injector.gaussian_noise(base, 2.0).signal), + ] + stress_pipe = SynapDrivePipeline(simulate_delay=False) + campaign = StressCampaign(256, min_quality=stress_pipe.runtime.policy.min_signal_quality) + + def callback(_signal: np.ndarray, quality_score: float) -> bool: + stress_packet = generate_intent("move left") + stress_packet["signal_quality"] = quality_score + return stress_pipe.run_intent_packet(stress_packet)["status"] == "blocked" + + stress = campaign.run(faults, callback) + checks.append( + _check( + "fault_campaign_fail_closed", + stress.invariant_failures == 0, + report=stress.to_dict(), + ) + ) + + passed = all(bool(item["passed"]) for item in checks) + return { + "schema": "synapdrive.validation.v1", + "version": "1.0.0", + "passed": passed, + "python": sys.version.split()[0], + "platform": platform.platform(), + "claims": { + "human_eeg_used": False, + "physical_actuation_used": False, + "clinical_validation": False, + "hardware_safety_validation": False, + "synthetic_verification_data_used": True, + "benchmark_metrics_are_bci_performance_claims": False, + }, + "checks": checks, + } + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description="Run SynapDrive-AI release validation checks") + parser.add_argument("--out", default=None) + args = parser.parse_args(argv) + report = run_validation() + text = json.dumps(report, indent=2) + print(text) + if args.out: + path = Path(args.out) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text + "\n", encoding="utf-8") + return 0 if report["passed"] else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/synapdrive_ai/__init__.py b/synapdrive_ai/__init__.py index 3e2143a..b15e253 100644 --- a/synapdrive_ai/__init__.py +++ b/synapdrive_ai/__init__.py @@ -1,18 +1,9 @@ -# synapdrive_ai/__init__.py +"""SynapDrive-AI neuroadaptive shared-autonomy research runtime. +The package is simulation-first and research-oriented. It does not claim clinical neural +intent decoding, medical-device status, validated physical control, or autonomous authority. """ -SynapDrive-AI: Brain-to-System Symbiotic Intelligence Framework -This package enables simulated integration of brain-computer interface data with -generalized artificial intelligence models for real-time control of physical systems. - -Submodules: - - bci: Brain signal simulation and preprocessing - - agi: Core cognitive modules (planning, inference, reward) - - control: System actuation and feedback simulation - - interface: Interaction layer and visualization -""" - -__version__ = "0.1.0" +__version__ = "1.0.0" __author__ = "SynapDrive-AI Contributors" -__license__ = "Apache 2.0" +__license__ = "Apache-2.0" diff --git a/synapdrive_ai/adaptation/__init__.py b/synapdrive_ai/adaptation/__init__.py new file mode 100644 index 0000000..306fb19 --- /dev/null +++ b/synapdrive_ai/adaptation/__init__.py @@ -0,0 +1,3 @@ +from .guarded import AdaptationDecision, GuardedThresholdAdapter + +__all__ = ["AdaptationDecision", "GuardedThresholdAdapter"] diff --git a/synapdrive_ai/adaptation/guarded.py b/synapdrive_ai/adaptation/guarded.py new file mode 100644 index 0000000..df65e78 --- /dev/null +++ b/synapdrive_ai/adaptation/guarded.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, Mapping + + +@dataclass(frozen=True) +class AdaptationDecision: + promoted: bool + old_threshold: float + candidate_threshold: float + old_utility: float + candidate_utility: float + old_unsafe_accepts: int + candidate_unsafe_accepts: int + reason: str + + +class GuardedThresholdAdapter: + def __init__(self, threshold: float = 0.45) -> None: + self.threshold = float(threshold) + + @staticmethod + def _numeric_field(row: Mapping[str, object], key: str) -> float: + value = row.get(key) + if isinstance(value, (int, float, str)): + return float(value) + raise TypeError(f"{key} must be numeric") + + @staticmethod + def _score(records: Iterable[Mapping[str, object]], threshold: float) -> tuple[float, int]: + tp = tn = fp = fn = 0 + for row in records: + conf = GuardedThresholdAdapter._numeric_field(row, "confidence") + safe = bool(row["safe"]) + accepted = conf >= threshold + if accepted and safe: + tp += 1 + elif not accepted and not safe: + tn += 1 + elif accepted and not safe: + fp += 1 + else: + fn += 1 + total = tp + tn + fp + fn + utility = (tp + tn - 3 * fp - 0.5 * fn) / max(total, 1) + return utility, fp + + def evaluate_candidate( + self, + candidate_threshold: float, + validation_records: Iterable[Mapping[str, object]], + ) -> AdaptationDecision: + rows = list(validation_records) + if not rows: + raise ValueError("validation_records must not be empty") + candidate_threshold = max(0.0, min(1.0, float(candidate_threshold))) + old_utility, old_unsafe = self._score(rows, self.threshold) + new_utility, new_unsafe = self._score(rows, candidate_threshold) + promoted = new_unsafe <= old_unsafe and new_utility > old_utility + reason = ( + "candidate improves held-out utility without increasing unsafe accepts" + if promoted + else "candidate rejected: no safe held-out improvement" + ) + old = self.threshold + if promoted: + self.threshold = candidate_threshold + return AdaptationDecision( + promoted, + old, + candidate_threshold, + round(old_utility, 6), + round(new_utility, 6), + old_unsafe, + new_unsafe, + reason, + ) diff --git a/synapdrive_ai/agi/__init__.py b/synapdrive_ai/agi/__init__.py new file mode 100644 index 0000000..3fd660a --- /dev/null +++ b/synapdrive_ai/agi/__init__.py @@ -0,0 +1 @@ +"""Legacy cognitive helpers retained for compatibility and explicit evaluation.""" diff --git a/synapdrive_ai/agi/cognitive_optimizer.py b/synapdrive_ai/agi/cognitive_optimizer.py index 8b2eb17..da984f2 100644 --- a/synapdrive_ai/agi/cognitive_optimizer.py +++ b/synapdrive_ai/agi/cognitive_optimizer.py @@ -1,43 +1,32 @@ +from __future__ import annotations + from synapdrive_ai.memory.episodic_memory import EpisodicMemory from synapdrive_ai.vision.visual_inference import VisualInferenceEngine class CognitiveOptimizer: - """ - Refines AGI decisions by integrating memory recall and visual feedback, - dynamically adjusting confidence and context for action refinement. + """Outcome-aware context enrichment for an intent packet. + + This component never treats a declared visual label as additional decoder evidence. + Previously successful, reality-aligned episodes are attached as review context only. + They never modify decoder/parser confidence: confidence calibration belongs to the + decoder evaluation path, not an arbitrary memory-count heuristic. """ - def __init__(self): + def __init__(self) -> None: self.memory = EpisodicMemory() self.visual = VisualInferenceEngine() def optimize(self, intent_packet, image_label=None): - """ - Adjusts the intent packet based on visual and memory signals. - - Args: - intent_packet (dict): Original AGI intent decision - image_label (str): Optional simulated camera label for visual input - - Returns: - dict: Optimized intent packet - """ visual_context = self.visual.infer(image_label) if image_label else None - memory_context = self.memory.find_by_intent(intent_packet["intent"]) - - # Boost confidence if memory has positive outcomes - boost = 0.05 * len(memory_context) if memory_context else -0.1 - visual_boost = 0.05 if visual_context and visual_context["certainty"] > 0.8 else 0 - - adjusted_confidence = round(min(1.0, intent_packet["confidence"] + boost + visual_boost), 2) + finder = getattr(self.memory, "find_successful_by_intent", self.memory.find_by_intent) + memory_context = finder(intent_packet["intent"]) optimized_packet = intent_packet.copy() - optimized_packet["confidence"] = adjusted_confidence optimized_packet["memory_context"] = memory_context - + optimized_packet["history_support_count"] = len(memory_context) + optimized_packet["history_adjustment"] = 0.0 if visual_context: optimized_packet["visual_tag"] = visual_context["visual_tag"] - optimized_packet["visual_certainty"] = visual_context["certainty"] - + optimized_packet["visual_context"] = visual_context return optimized_packet diff --git a/synapdrive_ai/agi/core_reasoning.py b/synapdrive_ai/agi/core_reasoning.py index bc94d28..43146ed 100644 --- a/synapdrive_ai/agi/core_reasoning.py +++ b/synapdrive_ai/agi/core_reasoning.py @@ -1,62 +1,74 @@ +from __future__ import annotations + from collections import deque +from typing import Any import numpy as np -class AGICoreReasoner: - """ - Core cognitive engine that interprets brain signals and emits structured action plans. +class LabeledSignalMapper: + """Map an explicitly supplied synthetic label into the simulation action namespace. - IMPORTANT FIX: - - Using np.mean(signal_data) on a sinusoid trends toward ~0, which collapses confidence. - - We use RMS magnitude instead, which reflects signal energy and produces stable confidence. + This is not a neural decoder. The caller already supplies the class label. The + ``confidence`` field is therefore a ground-truth fixture value used only to exercise + downstream governance. Raw waveform magnitude never changes it. """ - def __init__(self, memory_length=5): + def __init__(self, memory_length: int = 5) -> None: self.memory = deque(maxlen=memory_length) self.intent_weights = { - "left_arm": {"motor": "move_left_arm", "priority": 0.8}, - "right_arm": {"motor": "move_right_arm", "priority": 0.8}, - "walk": {"motor": "initiate_walk", "priority": 0.9}, + "left_arm": {"motor": "move_left_arm", "priority": 1.0}, + "right_arm": {"motor": "move_right_arm", "priority": 1.0}, + "walk": {"motor": "initiate_walk", "priority": 1.0}, "stop": {"motor": "halt_all_motion", "priority": 1.0}, - "calculate": {"cognitive": "initiate_computation", "priority": 0.7}, - "recall": {"cognitive": "retrieve_memory", "priority": 0.6}, - "explore": {"cognitive": "expand_context", "priority": 0.85}, + "calculate": {"cognitive": "initiate_computation", "priority": 1.0}, + "recall": {"cognitive": "retrieve_memory", "priority": 1.0}, + "explore": {"cognitive": "expand_context", "priority": 1.0}, } - def receive_signal(self, label, signal_data): - """Store signal and trigger reasoning logic.""" - self.memory.append((label, signal_data)) + def receive_signal(self, label: str, signal_data: Any): + summary = self._signal_summary(label, signal_data) + self.memory.append(summary) return self.reason(label, signal_data) - def reason(self, label, signal_data): - """Reason over the current label and signal using internal logic.""" + def reason(self, label: str, signal_data: Any): + del signal_data # The waveform is intentionally not treated as decoder evidence. if label not in self.intent_weights: return { "intent": "unknown", "confidence": 0.0, - "source": label, + "source": f"synthetic_label/{label}", "memory_context": list(self.memory), + "neural_decode_performed": False, + "confidence_semantics": "no-decoder", + "inference_authority": "synthetic-ground-truth", } intent_data = self.intent_weights[label] - priority = float(intent_data["priority"]) + return { + "intent": intent_data.get("motor") or intent_data.get("cognitive"), + "source": f"synthetic_label/{label}", + "confidence": 1.0, + "memory_context": list(self.memory), + "neural_decode_performed": False, + "confidence_semantics": "synthetic-ground-truth-label", + "inference_authority": "synthetic-ground-truth", + } - # RMS magnitude = sqrt(mean(x^2)) — robust signal energy proxy + @staticmethod + def _signal_summary(label: str, signal_data: Any) -> dict[str, Any]: try: - rms = float(np.sqrt(np.mean(np.square(signal_data)))) + arr = np.asarray(signal_data, dtype=float).ravel() + rms = float(np.sqrt(np.mean(np.square(arr)))) if arr.size else 0.0 + n_samples = int(arr.size) except Exception: rms = 0.0 + n_samples = 0 + return {"label": label, "n_samples": n_samples, "rms": round(rms, 6)} - # Confidence modulated by signal energy and priority - confidence = rms * priority - # Clamp to sane range - confidence = max(0.10, min(1.0, confidence)) +class AGICoreReasoner(LabeledSignalMapper): + """Deprecated compatibility name for :class:`LabeledSignalMapper`. - return { - "intent": intent_data.get("motor") or intent_data.get("cognitive"), - "source": label, - "confidence": confidence, - "memory_context": list(self.memory), - } + No AGI capability is claimed or implemented by this class. + """ diff --git a/synapdrive_ai/agi/feedback_learning.py b/synapdrive_ai/agi/feedback_learning.py index 48bdb76..7a1cb34 100644 --- a/synapdrive_ai/agi/feedback_learning.py +++ b/synapdrive_ai/agi/feedback_learning.py @@ -1,58 +1,28 @@ +from __future__ import annotations + + class FeedbackLearner: - """ - Adaptive learner that adjusts AGI intent priorities based on outcomes. + """Deprecated compatibility logger for historical feedback calls. - It simulates reward-based reinforcement by analyzing intent results and - feeding back into the reasoning system to increase or decrease priority weights. + Earlier versions modified intent priorities from execution confidence alone. That is + not evidence that an adaptation is beneficial, so automatic mutation is disabled. + Use ``synapdrive_ai.adaptation.GuardedThresholdAdapter`` with held-out labeled records + for the supported adaptation path. """ - def __init__(self, reasoner): + def __init__(self, reasoner) -> None: self.reasoner = reasoner - self.feedback_log = [] - - def apply_feedback(self, intent_packet, result): - """ - Adjusts internal weights in AGICoreReasoner based on execution results. - - Args: - intent_packet (dict): The original intent. - result (dict): { - 'status': 'executed', - 'intent': str, - 'confidence': float, - 'duration': float - } - """ - intent = result.get("intent") - confidence = result.get("confidence", 0.0) - label = intent_packet.get("source") - - if label not in self.reasoner.intent_weights: - return - - old_priority = self.reasoner.intent_weights[label]["priority"] - - # Reward if confidence > 0.8, punish if < 0.5 - if confidence > 0.8: - adjustment = 0.05 - elif confidence < 0.5: - adjustment = -0.05 - else: - adjustment = 0.0 - - new_priority = min(max(old_priority + adjustment, 0.0), 1.0) - self.reasoner.intent_weights[label]["priority"] = new_priority - - feedback_record = { - "intent": intent, - "source": label, - "old_priority": old_priority, - "new_priority": new_priority, - "confidence": confidence, - "timestamp": result.get("duration"), + self.feedback_log: list[dict[str, object]] = [] + + def apply_feedback(self, intent_packet, result) -> None: + record = { + "intent": result.get("intent"), + "source": intent_packet.get("source"), + "confidence": float(result.get("confidence", 0.0)), + "promoted": False, + "reason": "legacy confidence-only adaptation disabled; use held-out guarded adaptation", } - self.feedback_log.append(feedback_record) + self.feedback_log.append(record) def get_feedback_history(self): - """Returns a history of all feedback adjustments.""" - return self.feedback_log + return list(self.feedback_log) diff --git a/synapdrive_ai/agi/intent_classifier.py b/synapdrive_ai/agi/intent_classifier.py index 79644dc..9da59d2 100644 --- a/synapdrive_ai/agi/intent_classifier.py +++ b/synapdrive_ai/agi/intent_classifier.py @@ -1,10 +1,15 @@ -import random +from __future__ import annotations + +from typing import Mapping class IntentClassifier: - """ - Lightweight classifier that maps brain signal labels to high-level intent categories. - Currently rule-based, but supports plug-in of ML models in future. + """Compatibility label mapper for explicitly declared synthetic labels. + + This class does not decode physiology. Historical versions derived confidence from + signal RMS, which falsely converted amplitude into semantic certainty. The compatibility + path now treats a recognized caller-supplied label as injected ground truth for software + tests and marks that provenance explicitly. """ def __init__(self): @@ -15,27 +20,22 @@ def __init__(self): "think_grab": "activate_claw", } - def classify(self, signal_data): - """ - Classify raw signal input into an intent packet. - - Args: - signal_data (dict): { - 'label': str, - 'signal_strength': float, - 'raw': any - } - - Returns: - dict: { - 'intent': str, - 'confidence': float, - 'source': str, - 'memory_context': [] - } - """ - label = signal_data.get("label") - intent = self.rules.get(label, "unknown") - confidence = round(random.uniform(0.6, 0.99), 2) if intent != "unknown" else 0.3 +def classify(self, signal_data: Mapping[str, object] | None) -> dict[str, object]: + signal_data = signal_data or {} + raw_label = signal_data.get("label") + label = raw_label if isinstance(raw_label, str) else None + intent = self.rules.get(label or "", "unknown") + recognized = intent != "unknown" - return {"intent": intent, "confidence": confidence, "source": label, "memory_context": []} + return { + "intent": intent, + "confidence": 1.0 if recognized else 0.0, + "source": f"declared-synthetic-label/{label or 'unknown'}", + "memory_context": [], + "inference_authority": "synthetic-ground-truth" if recognized else "none", + "confidence_semantics": ( + "synthetic-ground-truth-label" if recognized else "unrecognized-label" + ), + "neural_decode_performed": False, + "analysis_only": not recognized, + } diff --git a/synapdrive_ai/agi/meta_evaluator.py b/synapdrive_ai/agi/meta_evaluator.py index 4bc0a5a..33fa036 100644 --- a/synapdrive_ai/agi/meta_evaluator.py +++ b/synapdrive_ai/agi/meta_evaluator.py @@ -1,39 +1,34 @@ +from __future__ import annotations + import statistics class MetaEvaluator: - """ - Scores AGI behavior post-hoc to drive long-term system reliability, - model tuning, and intelligent self-reflection. + """Small deterministic diagnostic score for simulation traces. + + The score is not a measure of intelligence, BCI accuracy, or real-world safety. It is + retained as a compact regression signal for the historical pipeline surface. """ - def __init__(self): - self.scores = [] + def __init__(self) -> None: + self.scores: list[float] = [] def evaluate(self, intent_packet, result_packet): - """ - Scores a completed AGI action and appends to the score log. - - Returns: - dict: Evaluation summary - """ score = 0.0 if result_packet["status"] == "success": score += 1.0 - if intent_packet["confidence"] > 0.8: + if float(intent_packet.get("confidence", 0.0)) > 0.8: score += 0.5 - if "memory_context" in intent_packet and intent_packet["memory_context"]: - score += 0.2 - if "visual_certainty" in intent_packet and intent_packet["visual_certainty"] > 0.8: + if intent_packet.get("memory_context"): score += 0.2 self.scores.append(score) - return { "score": round(score, 2), + "score_semantics": "simulation-regression-diagnostic", "total_actions": len(self.scores), "avg_score": round(statistics.mean(self.scores), 2), } - def reset(self): + def reset(self) -> None: self.scores.clear() diff --git a/synapdrive_ai/assurance/__init__.py b/synapdrive_ai/assurance/__init__.py index eaf1c2c..15ce346 100644 --- a/synapdrive_ai/assurance/__init__.py +++ b/synapdrive_ai/assurance/__init__.py @@ -1,4 +1,13 @@ -from synapdrive_ai.assurance.contracts import ASSURANCE_SCHEMA, AssuranceReceipt -from synapdrive_ai.assurance.monitor import AssuranceMonitor +from .contracts import ASSURANCE_SCHEMA, AssuranceReceipt +from .evidence_ledger import SessionEvidenceLedger +from .hashchain import ChainEntry, EvidenceHashChain +from .monitor import AssuranceMonitor -__all__ = ["ASSURANCE_SCHEMA", "AssuranceMonitor", "AssuranceReceipt"] +__all__ = [ + "ASSURANCE_SCHEMA", + "AssuranceMonitor", + "AssuranceReceipt", + "ChainEntry", + "EvidenceHashChain", + "SessionEvidenceLedger", +] diff --git a/synapdrive_ai/assurance/cli.py b/synapdrive_ai/assurance/cli.py new file mode 100644 index 0000000..caf8e3f --- /dev/null +++ b/synapdrive_ai/assurance/cli.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from synapdrive_ai.assurance.evidence_ledger import SessionEvidenceLedger +from synapdrive_ai.assurance.signing import Ed25519EvidenceSigner, verify_ed25519 + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser( + description="SynapDrive evidence-chain and signature utilities." + ) + sub = parser.add_subparsers(dest="command", required=True) + + verify_chain = sub.add_parser("verify-chain") + verify_chain.add_argument("evidence") + + keygen = sub.add_parser("keygen") + keygen.add_argument("--private", required=True) + keygen.add_argument("--public", required=True) + + sign = sub.add_parser("sign") + sign.add_argument("evidence") + sign.add_argument("--private", required=True) + sign.add_argument("--out", required=True) + + verify_sig = sub.add_parser("verify-signature") + verify_sig.add_argument("evidence") + verify_sig.add_argument("--public", required=True) + verify_sig.add_argument("--signature", required=True) + + args = parser.parse_args(argv) + if args.command == "verify-chain": + rows = SessionEvidenceLedger.load_jsonl(args.evidence) + ok = SessionEvidenceLedger.verify(rows) + print(json.dumps({"chain_valid": ok, "entries": len(rows)}, indent=2)) + return 0 if ok else 2 + if args.command == "keygen": + signer = Ed25519EvidenceSigner.generate() + signer.save_private_pem(args.private) + signer.save_public_pem(args.public) + print(json.dumps({"private": args.private, "public": args.public}, indent=2)) + return 0 + if args.command == "sign": + signer = Ed25519EvidenceSigner.load_private_pem(args.private) + signature = signer.sign_file(args.evidence) + Path(args.out).write_text(json.dumps(signature, indent=2), encoding="utf-8") + print(json.dumps(signature, indent=2)) + return 0 + + signature = json.loads(Path(args.signature).read_text(encoding="utf-8")) + payload = Path(args.evidence).read_bytes() + ok = verify_ed25519(Path(args.public).read_bytes(), payload, signature["signature_b64"]) + print(json.dumps({"signature_valid": ok, "algorithm": signature.get("algorithm")}, indent=2)) + return 0 if ok else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/synapdrive_ai/assurance/evidence_ledger.py b/synapdrive_ai/assurance/evidence_ledger.py new file mode 100644 index 0000000..4bface8 --- /dev/null +++ b/synapdrive_ai/assurance/evidence_ledger.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, Iterable, List + +from synapdrive_ai.assurance.hashchain import ChainEntry, EvidenceHashChain + + +class SessionEvidenceLedger: + """Tamper-evident append-only evidence ledger for pipeline cycles.""" + + def __init__(self) -> None: + self.chain = EvidenceHashChain() + + def record(self, payload: Dict[str, Any]) -> ChainEntry: + return self.chain.append(payload) + + def export_jsonl(self, path: str | Path) -> None: + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + with p.open("w", encoding="utf-8") as handle: + for entry in self.chain.entries(): + handle.write(json.dumps(entry.to_dict(), sort_keys=True, default=str) + "\n") + + @staticmethod + def load_jsonl(path: str | Path) -> List[Dict[str, Any]]: + rows = [] + with Path(path).open(encoding="utf-8") as handle: + for line in handle: + if line.strip(): + rows.append(json.loads(line)) + return rows + + @staticmethod + def verify(entries: Iterable[ChainEntry | Dict[str, Any]]) -> bool: + return EvidenceHashChain.verify(entries) diff --git a/synapdrive_ai/assurance/hashchain.py b/synapdrive_ai/assurance/hashchain.py new file mode 100644 index 0000000..6f29bf0 --- /dev/null +++ b/synapdrive_ai/assurance/hashchain.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List + + +def canonical_json(payload: Dict[str, Any]) -> bytes: + return json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8") + + +@dataclass(frozen=True) +class ChainEntry: + index: int + previous_hash: str + event_hash: str + payload: Dict[str, Any] + + def to_dict(self) -> Dict[str, Any]: + return { + "index": self.index, + "previous_hash": self.previous_hash, + "event_hash": self.event_hash, + "payload": self.payload, + } + + +class EvidenceHashChain: + GENESIS = "0" * 64 + + def __init__(self) -> None: + self._entries: List[ChainEntry] = [] + + def append(self, payload: Dict[str, Any]) -> ChainEntry: + previous = self._entries[-1].event_hash if self._entries else self.GENESIS + event_hash = self._digest(len(self._entries) + 1, previous, payload) + entry = ChainEntry(len(self._entries) + 1, previous, event_hash, dict(payload)) + self._entries.append(entry) + return entry + + def entries(self) -> List[ChainEntry]: + return list(self._entries) + + @classmethod + def verify(cls, entries: Iterable[ChainEntry | Dict[str, Any]]) -> bool: + previous = cls.GENESIS + for expected_index, raw in enumerate(entries, 1): + if isinstance(raw, ChainEntry): + entry = raw + else: + entry = ChainEntry( + index=int(raw["index"]), + previous_hash=str(raw["previous_hash"]), + event_hash=str(raw["event_hash"]), + payload=dict(raw["payload"]), + ) + if entry.index != expected_index or entry.previous_hash != previous: + return False + expected = cls._digest(entry.index, entry.previous_hash, entry.payload) + if entry.event_hash != expected: + return False + previous = entry.event_hash + return True + + @staticmethod + def _digest(index: int, previous_hash: str, payload: Dict[str, Any]) -> str: + h = hashlib.sha256() + h.update(str(index).encode("ascii")) + h.update(b"|") + h.update(previous_hash.encode("ascii")) + h.update(b"|") + h.update(canonical_json(payload)) + return h.hexdigest() diff --git a/synapdrive_ai/assurance/signing.py b/synapdrive_ai/assurance/signing.py new file mode 100644 index 0000000..e5c2653 --- /dev/null +++ b/synapdrive_ai/assurance/signing.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import base64 +import hashlib +from pathlib import Path + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey + + +class Ed25519EvidenceSigner: + """Detached Ed25519 signatures for exported evidence bytes.""" + + def __init__(self, private_key: Ed25519PrivateKey) -> None: + self.private_key = private_key + + @classmethod + def generate(cls) -> "Ed25519EvidenceSigner": + return cls(Ed25519PrivateKey.generate()) + + @classmethod + def load_private_pem(cls, path: str | Path) -> "Ed25519EvidenceSigner": + key = serialization.load_pem_private_key(Path(path).read_bytes(), password=None) + if not isinstance(key, Ed25519PrivateKey): + raise TypeError("private key is not Ed25519") + return cls(key) + + def save_private_pem(self, path: str | Path) -> None: + raw = self.private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + Path(path).write_bytes(raw) + + def save_public_pem(self, path: str | Path) -> None: + raw = self.private_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + Path(path).write_bytes(raw) + + def sign(self, payload: bytes) -> str: + return base64.b64encode(self.private_key.sign(payload)).decode("ascii") + + def sign_file(self, path: str | Path) -> dict[str, str]: + payload = Path(path).read_bytes() + return { + "algorithm": "Ed25519", + "sha256": hashlib.sha256(payload).hexdigest(), + "signature_b64": self.sign(payload), + } + + +def verify_ed25519(public_pem: bytes, payload: bytes, signature_b64: str) -> bool: + key = serialization.load_pem_public_key(public_pem) + if not isinstance(key, Ed25519PublicKey): + raise TypeError("public key is not Ed25519") + try: + key.verify(base64.b64decode(signature_b64), payload) + except (InvalidSignature, ValueError): + return False + return True diff --git a/synapdrive_ai/bci/__init__.py b/synapdrive_ai/bci/__init__.py new file mode 100644 index 0000000..dd061f2 --- /dev/null +++ b/synapdrive_ai/bci/__init__.py @@ -0,0 +1 @@ +"""BCI simulation fixtures and explicit intent-generation helpers.""" diff --git a/synapdrive_ai/bci/intent_generator.py b/synapdrive_ai/bci/intent_generator.py index 794fe01..6c0a430 100644 --- a/synapdrive_ai/bci/intent_generator.py +++ b/synapdrive_ai/bci/intent_generator.py @@ -5,44 +5,29 @@ from synapdrive_ai.intent.intent_parser import IntentParser -def generate_intent(simulated_input: str) -> Dict[str, Any]: - """ - Simulation-first intent extraction. - - Accepts: - - freeform text ("move left", "stop", "switch mode to manual") - - simple labels (anything else) +def generate_intent(command_text: str) -> Dict[str, Any]: + """Parse a caller-declared text command into the simulation action namespace. - Returns a structured intent packet compatible with the existing stack: - { - "intent": str, - "confidence": float, - "source": str, - "raw_text": str, - "params": dict, - "memory_context": list - } + This path does not infer a user's intent from physiology. A recognized grammar match + receives confidence 1.0 because the mapping itself is deterministic; the packet marks + that semantic explicitly. Unknown text fails closed with zero confidence. """ - raw = (simulated_input or "").strip() - parser = IntentParser() + raw = (command_text or "").strip() + parser = IntentParser() parsed = parser.parse(raw) - # Conservative default for unknown inputs intent = "unknown" - confidence = 0.40 + confidence = 0.0 params: Dict[str, str] = {} if parsed is not None: params = dict(parsed.params) - - # Map parsed intents into a simple actuator-command namespace if parsed.intent == "move": intent = f"move_{params.get('direction', 'unknown')}" elif parsed.intent == "turn": intent = f"turn_{params.get('direction', 'unknown')}" elif parsed.intent == "stop": - # Align with existing AGI mapping name when possible intent = "halt_all_motion" elif parsed.intent == "pick_up": intent = "pick_up" @@ -52,14 +37,18 @@ def generate_intent(simulated_input: str) -> Dict[str, Any]: intent = f"switch_mode_{params.get('mode', 'unknown')}" else: intent = parsed.intent - - confidence = 0.80 + confidence = 1.0 return { "intent": intent, - "confidence": float(confidence), + "confidence": confidence, "source": "text_input", "raw_text": raw, "params": params, "memory_context": [], + "inference_authority": "declared-command", + "confidence_semantics": ( + "deterministic-parser-match" if parsed is not None else "no-parser-match" + ), + "neural_decode_performed": False, } diff --git a/synapdrive_ai/bci/signal_simulator.py b/synapdrive_ai/bci/signal_simulator.py index ecf3e73..81248da 100644 --- a/synapdrive_ai/bci/signal_simulator.py +++ b/synapdrive_ai/bci/signal_simulator.py @@ -1,75 +1,73 @@ +from __future__ import annotations + import threading import time +from typing import Callable import numpy as np class BrainSignalSimulator: - """ - Simulates EEG-like brain signals and emits structured event signals - that can be consumed by AGI modules. - - Simulated 'thought events': - - motor_intent: left_arm, right_arm, walk, stop - - cognitive_intent: calculate, recall, explore + """Deterministic-by-seed EEG-like waveform fixture generator. - Each signal has amplitude, frequency, and optional noise components. + Labels are injected ground truth for software testing. Generated waveforms are not + asserted to reproduce physiological EEG or to validate a neural decoder. """ - def __init__(self, sampling_rate=256, noise_level=0.05): - self.sampling_rate = sampling_rate # in Hz - self.noise_level = noise_level + EVENT_FREQUENCIES = { + "left_arm": 10.0, + "right_arm": 12.0, + "walk": 8.0, + "stop": 3.0, + "calculate": 25.0, + "recall": 18.0, + "explore": 30.0, + } + + def __init__(self, sampling_rate: int = 256, noise_level: float = 0.05, seed: int = 0): + if sampling_rate <= 0: + raise ValueError("sampling_rate must be > 0") + if noise_level < 0: + raise ValueError("noise_level must be >= 0") + self.sampling_rate = int(sampling_rate) + self.noise_level = float(noise_level) self.running = False - self.subscribers = [] + self.subscribers: list[Callable[[str, np.ndarray], None]] = [] + self._rng = np.random.default_rng(seed) - def generate_waveform(self, frequency, duration=1.0): + def generate_waveform(self, frequency: float, duration: float = 1.0) -> np.ndarray: + if frequency <= 0 or duration <= 0: + raise ValueError("frequency and duration must be > 0") t = np.linspace(0, duration, int(self.sampling_rate * duration), endpoint=False) signal = np.sin(2 * np.pi * frequency * t) - noise = np.random.normal(0, self.noise_level, signal.shape) + noise = self._rng.normal(0.0, self.noise_level, signal.shape) return signal + noise - def emit_event(self, label): - """Emit a synthetic brain signal for a labeled thought event.""" - patterns = { - "left_arm": 10, - "right_arm": 12, - "walk": 8, - "stop": 3, - "calculate": 25, - "recall": 18, - "explore": 30, - } - if label not in patterns: + def random_label(self) -> str: + labels = tuple(self.EVENT_FREQUENCIES) + return str(self._rng.choice(labels)) + + def emit_event(self, label: str) -> None: + if label not in self.EVENT_FREQUENCIES: raise ValueError(f"Unknown signal label: {label}") - signal = self.generate_waveform(patterns[label]) - for callback in self.subscribers: + signal = self.generate_waveform(self.EVENT_FREQUENCIES[label]) + for callback in tuple(self.subscribers): callback(label, signal) - def subscribe(self, callback): - """Register a callback to receive signals: callback(label, signal)""" + def subscribe(self, callback: Callable[[str, np.ndarray], None]) -> None: self.subscribers.append(callback) - def start_real_time_stream(self, interval=1.0): - """Starts a background thread that emits random events in real time.""" + def start_real_time_stream(self, interval: float = 1.0) -> None: + if interval <= 0: + raise ValueError("interval must be > 0") self.running = True - def stream_loop(): - event_labels = [ - "left_arm", - "right_arm", - "walk", - "stop", - "calculate", - "recall", - "explore", - ] + def stream_loop() -> None: while self.running: - label = np.random.choice(event_labels) - self.emit_event(label) + self.emit_event(self.random_label()) time.sleep(interval) threading.Thread(target=stream_loop, daemon=True).start() - def stop(self): - """Stop the real-time simulation.""" + def stop(self) -> None: self.running = False diff --git a/synapdrive_ai/benchmarking/__init__.py b/synapdrive_ai/benchmarking/__init__.py new file mode 100644 index 0000000..53f924c --- /dev/null +++ b/synapdrive_ai/benchmarking/__init__.py @@ -0,0 +1,26 @@ +from synapdrive_ai.benchmarking.dataset import EpochDataset, load_npz_dataset +from synapdrive_ai.benchmarking.decoders import ( + EnsembleDecoder, + RiemannianCentroidDecoder, + SpectralCentroidDecoder, +) +from synapdrive_ai.benchmarking.evaluation import BenchmarkReport, evaluate_decoder, run_arena +from synapdrive_ai.benchmarking.runtime_adapter import ( + DecoderQualification, + QualificationPolicy, + QualifiedDecoderAdapter, +) + +__all__ = [ + "BenchmarkReport", + "DecoderQualification", + "EnsembleDecoder", + "EpochDataset", + "QualificationPolicy", + "QualifiedDecoderAdapter", + "RiemannianCentroidDecoder", + "SpectralCentroidDecoder", + "evaluate_decoder", + "load_npz_dataset", + "run_arena", +] diff --git a/synapdrive_ai/benchmarking/cli.py b/synapdrive_ai/benchmarking/cli.py new file mode 100644 index 0000000..7d676ce --- /dev/null +++ b/synapdrive_ai/benchmarking/cli.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import argparse +import json + +from synapdrive_ai.benchmarking.dataset import load_npz_dataset +from synapdrive_ai.benchmarking.decoders import ( + EnsembleDecoder, + RiemannianCentroidDecoder, + SpectralCentroidDecoder, +) +from synapdrive_ai.benchmarking.evaluation import evaluate_decoder, run_arena + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser( + description="Benchmark SynapDrive decoders on an explicit labeled NPZ dataset." + ) + parser.add_argument("dataset") + parser.add_argument( + "--decoder", + choices=("spectral", "riemannian", "ensemble", "all"), + default="all", + ) + parser.add_argument("--test-fraction", type=float, default=0.25) + parser.add_argument("--seed", type=int, default=7) + parser.add_argument("--abstain", type=float, default=0.55) + args = parser.parse_args(argv) + dataset = load_npz_dataset(args.dataset) + + if args.decoder == "all": + reports = run_arena( + dataset, + test_fraction=args.test_fraction, + seed=args.seed, + abstain_threshold=args.abstain, + ) + print(json.dumps({"arena": [report.to_dict() for report in reports]}, indent=2)) + return 0 + + if args.decoder == "spectral": + decoder = SpectralCentroidDecoder() + elif args.decoder == "riemannian": + decoder = RiemannianCentroidDecoder() + else: + decoder = EnsembleDecoder( + [SpectralCentroidDecoder(), RiemannianCentroidDecoder()] + ) + report = evaluate_decoder( + decoder, + dataset, + test_fraction=args.test_fraction, + seed=args.seed, + abstain_threshold=args.abstain, + ) + print(json.dumps(report.to_dict(), indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/synapdrive_ai/benchmarking/dataset.py b/synapdrive_ai/benchmarking/dataset.py new file mode 100644 index 0000000..707f8eb --- /dev/null +++ b/synapdrive_ai/benchmarking/dataset.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import List + +import numpy as np + + +@dataclass(frozen=True) +class EpochDataset: + epochs: np.ndarray + labels: np.ndarray + sampling_rate: float + channel_names: List[str] + source: str + + def __post_init__(self) -> None: + if self.epochs.ndim != 3: + raise ValueError("epochs must have shape (n_epochs, n_channels, n_samples)") + if len(self.epochs) != len(self.labels): + raise ValueError("epochs and labels length mismatch") + if self.sampling_rate <= 0: + raise ValueError("sampling_rate must be positive") + if len(self.channel_names) != self.epochs.shape[1]: + raise ValueError("channel_names must match epoch channel count") + + +def load_npz_dataset(path: str | Path) -> EpochDataset: + """Load a labeled EEG epoch dataset from an explicit NPZ contract. + + Required arrays: X (epochs x channels x samples), y (labels), sampling_rate. + Optional: channel_names. No demo or fabricated labels are substituted. + """ + + p = Path(path) + with np.load(p, allow_pickle=False) as data: + missing = {"X", "y", "sampling_rate"} - set(data.files) + if missing: + raise ValueError(f"dataset missing required arrays: {sorted(missing)}") + x = np.asarray(data["X"], dtype=float) + y = np.asarray(data["y"]) + sr = float(np.asarray(data["sampling_rate"]).reshape(-1)[0]) + if "channel_names" in data.files: + names = [str(v) for v in np.asarray(data["channel_names"]).tolist()] + else: + names = [f"CH{i + 1}" for i in range(x.shape[1] if x.ndim >= 2 else 0)] + return EpochDataset(x, y, sr, names, str(p)) diff --git a/synapdrive_ai/benchmarking/decoders.py b/synapdrive_ai/benchmarking/decoders.py new file mode 100644 index 0000000..7f22dd7 --- /dev/null +++ b/synapdrive_ai/benchmarking/decoders.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +from typing import Dict, List, Protocol, Sequence + +import numpy as np + + +class Decoder(Protocol): + classes_: np.ndarray + + def fit( + self, + epochs: np.ndarray, + labels: np.ndarray, + sampling_rate: float, + ) -> "Decoder": ... + + def predict_proba( + self, + epochs: np.ndarray, + sampling_rate: float, + ) -> np.ndarray: ... + + +def _softmax(scores: np.ndarray) -> np.ndarray: + z = scores - np.max(scores, axis=1, keepdims=True) + exp = np.exp(np.clip(z, -60, 60)) + return exp / np.sum(exp, axis=1, keepdims=True) + + +class SpectralCentroidDecoder: + """Inspectable frequency-band feature decoder using nearest class centroids.""" + + bands = ((4.0, 8.0), (8.0, 13.0), (13.0, 30.0), (30.0, 45.0)) + + def __init__(self) -> None: + self.classes_: np.ndarray = np.array([]) + self.centroids_: Dict[object, np.ndarray] = {} + self.scale_: np.ndarray | None = None + + def _features(self, epochs: np.ndarray, sampling_rate: float) -> np.ndarray: + x = np.asarray(epochs, dtype=float) + if x.ndim != 3: + raise ValueError("epochs must be 3-D") + n = x.shape[-1] + freqs = np.fft.rfftfreq(n, d=1.0 / sampling_rate) + centered = x - np.mean(x, axis=-1, keepdims=True) + power = np.abs(np.fft.rfft(centered, axis=-1)) ** 2 + total_mask = (freqs >= 1.0) & ( + freqs <= min(45.0, sampling_rate / 2) + ) + total = np.sum(power[..., total_mask], axis=-1) + 1e-12 + feats: List[np.ndarray] = [] + for lo, hi in self.bands: + mask = (freqs >= lo) & (freqs < hi) + band = np.sum(power[..., mask], axis=-1) / total + feats.append(band) + return np.concatenate(feats, axis=1) + + def fit( + self, + epochs: np.ndarray, + labels: np.ndarray, + sampling_rate: float, + ) -> "SpectralCentroidDecoder": + y = np.asarray(labels) + f = self._features(epochs, sampling_rate) + self.classes_ = np.unique(y) + if len(self.classes_) < 2: + raise ValueError("at least two classes are required") + self.scale_ = np.std(f, axis=0) + 1e-6 + z = f / self.scale_ + self.centroids_ = { + cls: np.mean(z[y == cls], axis=0) for cls in self.classes_ + } + return self + + def predict_proba( + self, + epochs: np.ndarray, + sampling_rate: float, + ) -> np.ndarray: + if self.scale_ is None or not self.centroids_: + raise RuntimeError("decoder has not been fit") + f = self._features(epochs, sampling_rate) / self.scale_ + distances = np.stack( + [ + np.sum((f - self.centroids_[cls]) ** 2, axis=1) + for cls in self.classes_ + ], + axis=1, + ) + return _softmax(-distances) + + +class RiemannianCentroidDecoder: + """Log-Euclidean covariance decoder for multichannel EEG epochs. + + This is a compact research baseline, not a claim of equivalence with specialist + Riemannian BCI libraries. + """ + + def __init__(self, regularization: float = 1e-4) -> None: + self.regularization = float(regularization) + self.classes_: np.ndarray = np.array([]) + self.centroids_: Dict[object, np.ndarray] = {} + self.scale_: np.ndarray | None = None + + def _features(self, epochs: np.ndarray, sampling_rate: float) -> np.ndarray: + del sampling_rate + x = np.asarray(epochs, dtype=float) + if x.ndim != 3: + raise ValueError("epochs must be 3-D") + features = [] + for epoch in x: + centered = epoch - np.mean(epoch, axis=1, keepdims=True) + cov = centered @ centered.T / max(epoch.shape[1] - 1, 1) + trace = float(np.trace(cov)) + scale = trace / max(cov.shape[0], 1) if trace > 0 else 1.0 + cov = cov + np.eye(cov.shape[0]) * self.regularization * scale + vals, vecs = np.linalg.eigh(cov) + log_cov = ( + vecs * np.log(np.clip(vals, 1e-12, None)) + ) @ vecs.T + idx = np.triu_indices(log_cov.shape[0]) + feat = log_cov[idx].copy() + offdiag = idx[0] != idx[1] + feat[offdiag] *= np.sqrt(2.0) + features.append(feat) + return np.asarray(features) + + def fit( + self, + epochs: np.ndarray, + labels: np.ndarray, + sampling_rate: float, + ) -> "RiemannianCentroidDecoder": + y = np.asarray(labels) + f = self._features(epochs, sampling_rate) + self.classes_ = np.unique(y) + if len(self.classes_) < 2: + raise ValueError("at least two classes are required") + self.scale_ = np.std(f, axis=0) + 1e-6 + z = f / self.scale_ + self.centroids_ = { + cls: np.mean(z[y == cls], axis=0) for cls in self.classes_ + } + return self + + def predict_proba( + self, + epochs: np.ndarray, + sampling_rate: float, + ) -> np.ndarray: + if self.scale_ is None or not self.centroids_: + raise RuntimeError("decoder has not been fit") + f = self._features(epochs, sampling_rate) / self.scale_ + distances = np.stack( + [ + np.sum((f - self.centroids_[cls]) ** 2, axis=1) + for cls in self.classes_ + ], + axis=1, + ) + return _softmax(-distances) + + +class EnsembleDecoder: + """Probability-averaging ensemble for decoders sharing the same class set.""" + + def __init__(self, decoders: Sequence[Decoder]) -> None: + if not decoders: + raise ValueError("decoders must not be empty") + self.decoders = list(decoders) + self.classes_: np.ndarray = np.array([]) + + def fit( + self, + epochs: np.ndarray, + labels: np.ndarray, + sampling_rate: float, + ) -> "EnsembleDecoder": + for decoder in self.decoders: + decoder.fit(epochs, labels, sampling_rate) + self.classes_ = np.asarray(self.decoders[0].classes_) + for decoder in self.decoders[1:]: + if not np.array_equal(self.classes_, decoder.classes_): + raise ValueError("ensemble decoders disagree on class ordering") + return self + + def predict_proba( + self, + epochs: np.ndarray, + sampling_rate: float, + ) -> np.ndarray: + arrays = [ + decoder.predict_proba(epochs, sampling_rate) + for decoder in self.decoders + ] + return np.mean(np.stack(arrays, axis=0), axis=0) diff --git a/synapdrive_ai/benchmarking/epoching.py b/synapdrive_ai/benchmarking/epoching.py new file mode 100644 index 0000000..4401b79 --- /dev/null +++ b/synapdrive_ai/benchmarking/epoching.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import csv +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Sequence + +import numpy as np + +from synapdrive_ai.benchmarking.dataset import EpochDataset +from synapdrive_ai.neuro.eeg_loader import EEGRecording + + +@dataclass(frozen=True) +class LabeledEvent: + onset_s: float + label: str + + +def load_events_csv(path: str | Path) -> list[LabeledEvent]: + """Load event onsets from CSV columns `onset_s` and `label`.""" + events: list[LabeledEvent] = [] + with Path(path).open(newline="", encoding="utf-8") as handle: + reader = csv.DictReader(handle) + fields = set(reader.fieldnames or []) + if not {"onset_s", "label"}.issubset(fields): + raise ValueError("event CSV requires columns: onset_s,label") + for row in reader: + label = str(row.get("label") or "").strip() + if not label: + raise ValueError("event label must not be empty") + events.append(LabeledEvent(float(row["onset_s"]), label)) + if not events: + raise ValueError("event CSV contains no events") + return events + + +def epoch_recording( + recording: EEGRecording, + events: Iterable[LabeledEvent], + *, + tmin_s: float = 0.0, + tmax_s: float = 1.0, + channels: Sequence[str] | None = None, +) -> EpochDataset: + """Cut labeled fixed-length epochs from a loaded recording. + + Events whose requested window extends outside the recording are rejected rather than + silently padded or shortened. + """ + if tmax_s <= tmin_s: + raise ValueError("tmax_s must be greater than tmin_s") + selected_names = list(channels) if channels else list(recording.channels) + selected = np.stack([recording.channel(name) for name in selected_names]) + sr = recording.sampling_rate + n_samples = int(round((tmax_s - tmin_s) * sr)) + if n_samples < 2: + raise ValueError("epoch window is too short") + + epochs = [] + labels = [] + for event in events: + start = int(round((event.onset_s + tmin_s) * sr)) + end = start + n_samples + if start < 0 or end > recording.n_samples: + raise ValueError( + f"event {event.label!r} at {event.onset_s}s exceeds recording bounds" + ) + epochs.append(selected[:, start:end]) + labels.append(event.label) + if not epochs: + raise ValueError("no events supplied") + return EpochDataset( + epochs=np.asarray(epochs, dtype=float), + labels=np.asarray(labels), + sampling_rate=sr, + channel_names=selected_names, + source=recording.source_file, + ) diff --git a/synapdrive_ai/benchmarking/evaluation.py b/synapdrive_ai/benchmarking/evaluation.py new file mode 100644 index 0000000..dd33e94 --- /dev/null +++ b/synapdrive_ai/benchmarking/evaluation.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any, Dict + +import numpy as np + +from synapdrive_ai.benchmarking.dataset import EpochDataset +from synapdrive_ai.benchmarking.decoders import Decoder + + +@dataclass(frozen=True) +class BenchmarkReport: + decoder: str + n_samples: int + n_train: int + n_test: int + accuracy: float + balanced_accuracy: float + brier_score: float + ece: float + coverage: float + selective_accuracy: float + abstain_threshold: float + classes: list[str] + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +def _split_indices( + labels: np.ndarray, + test_fraction: float, + seed: int, +) -> tuple[np.ndarray, np.ndarray]: + if not 0.05 <= test_fraction <= 0.5: + raise ValueError("test_fraction must be between 0.05 and 0.5") + rng = np.random.default_rng(seed) + train, test = [], [] + for cls in np.unique(labels): + idx = np.flatnonzero(labels == cls) + if len(idx) < 2: + raise ValueError("each class needs at least two samples") + idx = idx.copy() + rng.shuffle(idx) + n_test = max(1, int(round(len(idx) * test_fraction))) + n_test = min(n_test, len(idx) - 1) + test.extend(idx[:n_test]) + train.extend(idx[n_test:]) + return np.array(sorted(train)), np.array(sorted(test)) + + +def expected_calibration_error( + confidence: np.ndarray, + correct: np.ndarray, + bins: int = 10, +) -> float: + edges = np.linspace(0, 1, bins + 1) + total = len(confidence) + value = 0.0 + for i in range(bins): + upper = ( + confidence < edges[i + 1] + if i < bins - 1 + else confidence <= edges[i + 1] + ) + mask = (confidence >= edges[i]) & upper + count = int(np.sum(mask)) + if count: + mean_conf = float(np.mean(confidence[mask])) + mean_correct = float(np.mean(correct[mask])) + value += count / total * abs(mean_conf - mean_correct) + return float(value) + + +def evaluate_decoder( + decoder: Decoder, + dataset: EpochDataset, + *, + test_fraction: float = 0.25, + seed: int = 7, + abstain_threshold: float = 0.55, +) -> BenchmarkReport: + y = np.asarray(dataset.labels) + train_idx, test_idx = _split_indices(y, test_fraction, seed) + decoder.fit( + dataset.epochs[train_idx], + y[train_idx], + dataset.sampling_rate, + ) + probs = decoder.predict_proba( + dataset.epochs[test_idx], + dataset.sampling_rate, + ) + classes = np.asarray(decoder.classes_) + pred = classes[np.argmax(probs, axis=1)] + truth = y[test_idx] + correct = pred == truth + confidence = np.max(probs, axis=1) + accuracy = float(np.mean(correct)) + per_class = [] + for cls in classes: + mask = truth == cls + if np.any(mask): + per_class.append(float(np.mean(correct[mask]))) + balanced = float(np.mean(per_class)) if per_class else 0.0 + onehot = np.zeros_like(probs) + class_to_idx = {cls: i for i, cls in enumerate(classes)} + for row, cls in enumerate(truth): + onehot[row, class_to_idx[cls]] = 1.0 + brier = float(np.mean(np.sum((probs - onehot) ** 2, axis=1))) + ece = expected_calibration_error(confidence, correct.astype(float)) + accepted = confidence >= abstain_threshold + coverage = float(np.mean(accepted)) + selective_accuracy = ( + float(np.mean(correct[accepted])) if np.any(accepted) else 0.0 + ) + return BenchmarkReport( + decoder=type(decoder).__name__, + n_samples=len(y), + n_train=len(train_idx), + n_test=len(test_idx), + accuracy=round(accuracy, 6), + balanced_accuracy=round(balanced, 6), + brier_score=round(brier, 6), + ece=round(ece, 6), + coverage=round(coverage, 6), + selective_accuracy=round(selective_accuracy, 6), + abstain_threshold=float(abstain_threshold), + classes=[str(c) for c in classes.tolist()], + ) + + +def run_arena( + dataset: EpochDataset, + *, + test_fraction: float = 0.25, + seed: int = 7, + abstain_threshold: float = 0.55, +) -> list[BenchmarkReport]: + """Evaluate all built-in decoders under one deterministic split configuration.""" + from synapdrive_ai.benchmarking.decoders import ( + EnsembleDecoder, + RiemannianCentroidDecoder, + SpectralCentroidDecoder, + ) + + decoders = [ + SpectralCentroidDecoder(), + RiemannianCentroidDecoder(), + EnsembleDecoder( + [SpectralCentroidDecoder(), RiemannianCentroidDecoder()] + ), + ] + reports = [ + evaluate_decoder( + decoder, + dataset, + test_fraction=test_fraction, + seed=seed, + abstain_threshold=abstain_threshold, + ) + for decoder in decoders + ] + return sorted( + reports, + key=lambda report: ( + report.balanced_accuracy, + report.selective_accuracy, + -report.brier_score, + ), + reverse=True, + ) diff --git a/synapdrive_ai/benchmarking/prepare_cli.py b/synapdrive_ai/benchmarking/prepare_cli.py new file mode 100644 index 0000000..44cc179 --- /dev/null +++ b/synapdrive_ai/benchmarking/prepare_cli.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import argparse + +import numpy as np + +from synapdrive_ai.benchmarking.epoching import epoch_recording, load_events_csv +from synapdrive_ai.neuro.eeg_loader import EEGLoader + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser( + description="Create a labeled SynapDrive benchmark NPZ from a recording and event CSV." + ) + parser.add_argument("recording") + parser.add_argument("events", help="CSV with onset_s,label columns") + parser.add_argument("--out", required=True) + parser.add_argument("--tmin", type=float, default=0.0) + parser.add_argument("--tmax", type=float, default=1.0) + parser.add_argument("--sr", type=float, default=256.0) + parser.add_argument("--channel", action="append", dest="channels") + args = parser.parse_args(argv) + + recording = EEGLoader(sampling_rate=args.sr).load(args.recording) + events = load_events_csv(args.events) + dataset = epoch_recording( + recording, + events, + tmin_s=args.tmin, + tmax_s=args.tmax, + channels=args.channels, + ) + np.savez( + args.out, + X=dataset.epochs, + y=dataset.labels, + sampling_rate=np.array([dataset.sampling_rate]), + channel_names=np.asarray(dataset.channel_names), + ) + print( + f"wrote {len(dataset.labels)} epochs x {dataset.epochs.shape[1]} channels " + f"x {dataset.epochs.shape[2]} samples to {args.out}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/synapdrive_ai/benchmarking/runtime_adapter.py b/synapdrive_ai/benchmarking/runtime_adapter.py new file mode 100644 index 0000000..46c1ce3 --- /dev/null +++ b/synapdrive_ai/benchmarking/runtime_adapter.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any, Dict, Mapping + +import numpy as np + +from synapdrive_ai.benchmarking.dataset import EpochDataset +from synapdrive_ai.benchmarking.decoders import Decoder +from synapdrive_ai.benchmarking.evaluation import BenchmarkReport, evaluate_decoder + + +@dataclass(frozen=True) +class QualificationPolicy: + """Local software gates for promoting a decoder into simulation use. + + These thresholds are engineering policy choices, not clinical validation criteria. + """ + + min_balanced_accuracy: float = 0.60 + max_ece: float = 0.30 + max_brier_score: float = 0.80 + min_coverage: float = 0.20 + abstain_threshold: float = 0.55 + + def __post_init__(self) -> None: + for name in ( + "min_balanced_accuracy", + "max_ece", + "max_brier_score", + "min_coverage", + "abstain_threshold", + ): + value = float(getattr(self, name)) + if not 0.0 <= value <= 1.0: + raise ValueError(f"{name} must be in [0, 1]") + + +@dataclass(frozen=True) +class DecoderQualification: + qualified: bool + reason: str + report: BenchmarkReport + policy: QualificationPolicy + + def to_dict(self) -> Dict[str, Any]: + return { + "qualified": self.qualified, + "reason": self.reason, + "report": self.report.to_dict(), + "policy": asdict(self.policy), + } + + +class QualifiedDecoderAdapter: + """Bridge a benchmark decoder into the runtime's explicit decoder callback contract. + + Construction performs a deterministic held-out evaluation. Only if all local software + gates pass is the decoder refit on the complete supplied labeled dataset. Runtime + decoding then requires exact channel count and sampling-rate agreement plus an explicit + label-to-action mapping. Unmapped labels and low-confidence outputs abstain. + + Passing this gate means only that the decoder met this repository's local evaluation + policy on the supplied data. It does not establish participant generalization, clinical + validity, or physical-control safety. + """ + + def __init__( + self, + decoder: Decoder, + dataset: EpochDataset, + action_map: Mapping[object, str], + *, + policy: QualificationPolicy | None = None, + test_fraction: float = 0.25, + seed: int = 7, + ) -> None: + self.decoder = decoder + self.dataset = dataset + self.action_map = dict(action_map) + self.policy = policy or QualificationPolicy() + self.report = evaluate_decoder( + decoder, + dataset, + test_fraction=test_fraction, + seed=seed, + abstain_threshold=self.policy.abstain_threshold, + ) + self.qualification = self._qualify(self.report) + if self.qualification.qualified: + self.decoder.fit(dataset.epochs, dataset.labels, dataset.sampling_rate) + + def _qualify(self, report: BenchmarkReport) -> DecoderQualification: + failures: list[str] = [] + if report.balanced_accuracy < self.policy.min_balanced_accuracy: + failures.append("balanced-accuracy-below-policy") + if report.ece > self.policy.max_ece: + failures.append("ece-above-policy") + if report.brier_score > self.policy.max_brier_score: + failures.append("brier-score-above-policy") + if report.coverage < self.policy.min_coverage: + failures.append("coverage-below-policy") + + mapped_classes = {str(key) for key in self.action_map} + missing = [str(cls) for cls in report.classes if str(cls) not in mapped_classes] + if missing: + failures.append("unmapped-decoder-classes:" + ",".join(sorted(missing))) + + qualified = not failures + reason = "local qualification policy passed" if qualified else "; ".join(failures) + return DecoderQualification(qualified, reason, report, self.policy) + + def __call__(self, data: np.ndarray, metadata: Mapping[str, Any]) -> Dict[str, Any]: + if not self.qualification.qualified: + return self._abstention("decoder did not pass local qualification policy") + + samples = np.asarray(data, dtype=float) + if samples.ndim != 2: + return self._abstention("decoder input must have shape channels x samples") + expected_channels = int(self.dataset.epochs.shape[1]) + if samples.shape[0] != expected_channels: + return self._abstention( + f"channel-count mismatch: got {samples.shape[0]}, expected {expected_channels}" + ) + + sampling_rate = float(metadata.get("sampling_rate", 0.0) or 0.0) + if not np.isclose(sampling_rate, self.dataset.sampling_rate, rtol=0.0, atol=1e-9): + return self._abstention( + "sampling-rate mismatch: " + f"got {sampling_rate}, expected {self.dataset.sampling_rate}" + ) + + probabilities = self.decoder.predict_proba(samples[None, :, :], sampling_rate)[0] + classes = np.asarray(self.decoder.classes_) + best_idx = int(np.argmax(probabilities)) + label = classes[best_idx] + confidence = float(probabilities[best_idx]) + action = self._mapped_action(label) + + action_probabilities: Dict[str, float] = {} + for cls, probability in zip(classes, probabilities, strict=True): + mapped = self._mapped_action(cls) + if mapped is not None: + action_probabilities[mapped] = action_probabilities.get(mapped, 0.0) + float( + probability + ) + + if action is None: + return self._abstention(f"decoder label {label!r} has no action mapping") + if confidence < self.policy.abstain_threshold: + packet = self._abstention( + f"decoder confidence {confidence:.6f} below abstain threshold " + f"{self.policy.abstain_threshold:.6f}" + ) + packet["decoder_label"] = str(label) + packet["raw_decoder_confidence"] = confidence + packet["probabilities"] = action_probabilities + return packet + + return { + "intent": action, + "confidence": confidence, + "probabilities": action_probabilities, + "source": f"qualified-decoder/{type(self.decoder).__name__}", + "memory_context": [], + "inference_authority": "locally-qualified-decoder", + "confidence_semantics": "model-probability-local-holdout-qualified", + "neural_decode_performed": True, + "analysis_only": False, + "decoder_label": str(label), + "decoder_qualification": self.qualification.to_dict(), + } + + def _mapped_action(self, label: object) -> str | None: + if label in self.action_map: + return str(self.action_map[label]) + label_text = str(label) + for key, value in self.action_map.items(): + if str(key) == label_text: + return str(value) + return None + + def _abstention(self, reason: str) -> Dict[str, Any]: + return { + "intent": "unknown", + "confidence": 0.0, + "probabilities": {"unknown": 1.0}, + "source": f"qualified-decoder/{type(self.decoder).__name__}", + "memory_context": [], + "inference_authority": "decoder-abstention", + "confidence_semantics": "abstained", + "neural_decode_performed": bool(self.qualification.qualified), + "analysis_only": True, + "abstention_reason": reason, + "decoder_qualification": self.qualification.to_dict(), + } diff --git a/synapdrive_ai/cloud/cloud_stub.py b/synapdrive_ai/cloud/cloud_stub.py deleted file mode 100644 index 73ff4f0..0000000 --- a/synapdrive_ai/cloud/cloud_stub.py +++ /dev/null @@ -1,55 +0,0 @@ -import json -import random - -from synapdrive_ai.core.logger import SynapLogger - - -class CloudControlStub: - """ - Simulates remote cloud communication with external systems like - Tesla, SpaceX, or Hyperloop infrastructure. - """ - - def __init__(self): - self.logger = SynapLogger() - self.transmitted_packets = [] - - def transmit_intent(self, intent_packet): - """ - Pretends to send the intent to a secure remote infrastructure control API. - - Args: - intent_packet (dict): { - 'intent': str, - 'confidence': float, - 'source': str, - 'memory_context': [] - } - """ - payload = { - "system": self.route_system(intent_packet["intent"]), - "intent": intent_packet["intent"], - "confidence": intent_packet["confidence"], - "metadata": { - "source": intent_packet["source"], - "timestamp": random.randint(1000000, 9999999), - }, - } - - self.transmitted_packets.append(payload) - self.logger.info(f"Transmitted intent to simulated cloud system: {json.dumps(payload)}") - - def route_system(self, intent): - """ - Simple mapping of intent types to infrastructure targets. - """ - if "move" in intent or "rotate" in intent: - return "TeslaAutonomySystem" - elif "launch" in intent or "sequence" in intent: - return "SpaceXCommandCore" - elif "tube" in intent or "velocity" in intent: - return "HyperloopOps" - return "GenericCloudController" - - def get_transmissions(self): - return self.transmitted_packets diff --git a/synapdrive_ai/cognition/__init__.py b/synapdrive_ai/cognition/__init__.py new file mode 100644 index 0000000..7420d8d --- /dev/null +++ b/synapdrive_ai/cognition/__init__.py @@ -0,0 +1,17 @@ +from .expectation import ExpectationReconciler, ThreeWayDelta +from .planner import CandidateAction, CounterfactualPlanner, PlanChoice +from .shared_autonomy import SharedAutonomyArbiter, SharedAutonomyProposal +from .world_model import ActionModel, Prediction, WorldModel + +__all__ = [ + "ActionModel", + "CandidateAction", + "CounterfactualPlanner", + "ExpectationReconciler", + "PlanChoice", + "Prediction", + "SharedAutonomyArbiter", + "SharedAutonomyProposal", + "ThreeWayDelta", + "WorldModel", +] diff --git a/synapdrive_ai/cognition/expectation.py b/synapdrive_ai/cognition/expectation.py new file mode 100644 index 0000000..1db16f3 --- /dev/null +++ b/synapdrive_ai/cognition/expectation.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ThreeWayDelta: + machine_reality: float + human_reality: float | None + machine_human: float | None + + +class ExpectationReconciler: + """Computes machine/human/reality deltas for scalar expected success. + + Human expectation is optional and must be supplied explicitly; it is never inferred from + neural data by this class. + """ + + def compare( + self, + machine_expected_success: float, + observed_success: bool, + human_expected_success: float | None = None, + ) -> ThreeWayDelta: + machine = max(0.0, min(1.0, float(machine_expected_success))) + reality = float(bool(observed_success)) + if human_expected_success is None: + return ThreeWayDelta(abs(machine - reality), None, None) + human = max(0.0, min(1.0, float(human_expected_success))) + return ThreeWayDelta( + abs(machine - reality), + abs(human - reality), + abs(machine - human), + ) diff --git a/synapdrive_ai/cognition/planner.py b/synapdrive_ai/cognition/planner.py new file mode 100644 index 0000000..6381683 --- /dev/null +++ b/synapdrive_ai/cognition/planner.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Dict, Iterable, Tuple + +from synapdrive_ai.cognition.world_model import Prediction, WorldModel + + +@dataclass(frozen=True) +class CandidateAction: + action: str + intent_alignment: float + confidence: float + + +@dataclass(frozen=True) +class RankedCandidate: + action: str + utility: float + goal_score: float + predicted_risk: float + reversible: bool + feasible: bool + + +@dataclass(frozen=True) +class PlanChoice: + selected: RankedCandidate | None + ranked: Tuple[RankedCandidate, ...] + + +class CounterfactualPlanner: + """Ranks candidate actions against explicit predicted state and a caller goal function.""" + + def __init__(self, world_model: WorldModel) -> None: + self.world_model = world_model + + def rank( + self, + candidates: Iterable[CandidateAction], + goal: Callable[[Dict[str, object]], float], + ) -> PlanChoice: + ranked = [] + for candidate in candidates: + prediction: Prediction = self.world_model.predict(candidate.action) + goal_score = ( + float(max(0.0, min(1.0, goal(prediction.after)))) + if prediction.feasible + else 0.0 + ) + utility = ( + 0.35 * max(0.0, min(1.0, candidate.intent_alignment)) + + 0.25 * max(0.0, min(1.0, candidate.confidence)) + + 0.30 * goal_score + + 0.05 * (1.0 if prediction.reversible else 0.0) + - 0.35 * prediction.predicted_risk + ) + ranked.append( + RankedCandidate( + candidate.action, + round(utility, 6), + round(goal_score, 6), + prediction.predicted_risk, + prediction.reversible, + prediction.feasible, + ) + ) + ranked.sort(key=lambda item: item.utility, reverse=True) + selected = next((item for item in ranked if item.feasible), None) + return PlanChoice(selected, tuple(ranked)) diff --git a/synapdrive_ai/cognition/shared_autonomy.py b/synapdrive_ai/cognition/shared_autonomy.py new file mode 100644 index 0000000..76579c9 --- /dev/null +++ b/synapdrive_ai/cognition/shared_autonomy.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Dict, Iterable, Tuple + +from synapdrive_ai.cognition.planner import CandidateAction, CounterfactualPlanner, RankedCandidate + + +@dataclass(frozen=True) +class SharedAutonomyProposal: + user_intent: str + selected_action: str | None + preserved_user_goal: bool + ranked: Tuple[RankedCandidate, ...] + + +class SharedAutonomyArbiter: + """Selects among bounded actions without silently rewriting the user's intent. + + Machine candidates must be explicitly supplied by the caller and scored against the same + goal function. The user's requested action is always represented as a candidate. + """ + + def __init__(self, planner: CounterfactualPlanner) -> None: + self.planner = planner + + def propose( + self, + *, + user_intent: str, + user_confidence: float, + machine_candidates: Iterable[CandidateAction], + goal: Callable[[Dict[str, object]], float], + ) -> SharedAutonomyProposal: + candidates = [CandidateAction(user_intent, 1.0, user_confidence)] + candidates.extend(machine_candidates) + choice = self.planner.rank(candidates, goal) + selected = choice.selected.action if choice.selected else None + # Goal preservation means selection came from the bounded candidate set under the same + # goal evaluator. It does not mean the action equals the user's low-level command. + return SharedAutonomyProposal( + user_intent=user_intent, + selected_action=selected, + preserved_user_goal=selected is not None, + ranked=choice.ranked, + ) diff --git a/synapdrive_ai/cognition/world_model.py b/synapdrive_ai/cognition/world_model.py new file mode 100644 index 0000000..8ee1f14 --- /dev/null +++ b/synapdrive_ai/cognition/world_model.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass, field +from typing import Any, Dict, Iterable, Mapping, Tuple + + +@dataclass(frozen=True) +class ActionModel: + name: str + effects: Mapping[str, Any] = field(default_factory=dict) + required: Mapping[str, Any] = field(default_factory=dict) + predicted_risk: float = 0.0 + reversible: bool = True + + def __post_init__(self) -> None: + if not 0.0 <= float(self.predicted_risk) <= 1.0: + raise ValueError("predicted_risk must be in [0, 1]") + + +@dataclass(frozen=True) +class Prediction: + action: str + feasible: bool + before: Dict[str, Any] + after: Dict[str, Any] + predicted_risk: float + reversible: bool + unmet_requirements: Tuple[str, ...] = field(default_factory=tuple) + + def to_dict(self) -> Dict[str, Any]: + return { + "action": self.action, + "feasible": self.feasible, + "before": self.before, + "after": self.after, + "predicted_risk": self.predicted_risk, + "reversible": self.reversible, + "unmet_requirements": list(self.unmet_requirements), + } + + +class WorldModel: + """Explicit symbolic action model with fail-closed behavior for unknown actions. + + Only registered action effects are predicted. An unregistered action is infeasible and + receives maximal software risk rather than silently being treated as safe. + """ + + def __init__(self, state: Mapping[str, Any] | None = None) -> None: + self.state: Dict[str, Any] = dict(state or {}) + self._actions: Dict[str, ActionModel] = {} + + def register(self, model: ActionModel) -> None: + self._actions[model.name] = model + + def register_many(self, models: Iterable[ActionModel]) -> None: + for model in models: + self.register(model) + + def registered_actions(self) -> tuple[str, ...]: + return tuple(sorted(self._actions)) + + def predict(self, action: str) -> Prediction: + before = deepcopy(self.state) + model = self._actions.get(action) + if model is None: + return Prediction( + action=action, + feasible=False, + before=before, + after=deepcopy(before), + predicted_risk=1.0, + reversible=False, + unmet_requirements=("unmodeled-action",), + ) + + unmet = tuple( + key for key, expected in model.required.items() if self.state.get(key) != expected + ) + after = deepcopy(before) + if not unmet: + after.update(dict(model.effects)) + return Prediction( + action=action, + feasible=not unmet, + before=before, + after=after, + predicted_risk=float(model.predicted_risk), + reversible=bool(model.reversible), + unmet_requirements=unmet, + ) + + def commit(self, prediction: Prediction) -> None: + if not prediction.feasible: + raise ValueError("cannot commit an infeasible prediction") + self.state = deepcopy(prediction.after) + + +def default_simulation_world_model() -> WorldModel: + """Create the finite action model used by the bundled simulation runtime. + + Risk values are software policy fixtures for exercising routing behavior. They are not + physical risk probabilities and are never presented as measured safety performance. + """ + + world = WorldModel({"mode": "manual", "last_action": "hold_position"}) + actions = [ + ActionModel("halt_all_motion", {"last_action": "halt_all_motion"}, predicted_risk=0.0), + ActionModel("hold_position", {"last_action": "hold_position"}, predicted_risk=0.0), + ActionModel("move_left", {"last_action": "move_left"}, predicted_risk=0.15), + ActionModel("move_right", {"last_action": "move_right"}, predicted_risk=0.15), + ActionModel("move_forward", {"last_action": "move_forward"}, predicted_risk=0.20), + ActionModel("move_backward", {"last_action": "move_backward"}, predicted_risk=0.20), + ActionModel("turn_left", {"last_action": "turn_left"}, predicted_risk=0.15), + ActionModel("turn_right", {"last_action": "turn_right"}, predicted_risk=0.15), + ActionModel("move_left_arm", {"last_action": "move_left_arm"}, predicted_risk=0.20), + ActionModel("move_right_arm", {"last_action": "move_right_arm"}, predicted_risk=0.20), + ActionModel("initiate_walk", {"last_action": "initiate_walk"}, predicted_risk=0.30), + ActionModel("pick_up", {"last_action": "pick_up"}, predicted_risk=0.25), + ActionModel("drop", {"last_action": "drop"}, predicted_risk=0.25), + ActionModel( + "initiate_computation", + {"last_action": "initiate_computation"}, + predicted_risk=0.0, + ), + ActionModel("retrieve_memory", {"last_action": "retrieve_memory"}, predicted_risk=0.0), + ActionModel("expand_context", {"last_action": "expand_context"}, predicted_risk=0.0), + ActionModel( + "switch_mode_manual", + {"mode": "manual", "last_action": "switch_mode_manual"}, + predicted_risk=0.05, + ), + ActionModel( + "switch_mode_assistive", + {"mode": "assistive", "last_action": "switch_mode_assistive"}, + predicted_risk=0.10, + ), + ] + world.register_many(actions) + return world diff --git a/synapdrive_ai/control/__init__.py b/synapdrive_ai/control/__init__.py new file mode 100644 index 0000000..6a8b410 --- /dev/null +++ b/synapdrive_ai/control/__init__.py @@ -0,0 +1 @@ +"""Simulated actuation primitives used by the governed runtime.""" diff --git a/synapdrive_ai/control/actuation_engine.py b/synapdrive_ai/control/actuation_engine.py index 4ae2ec1..13a7145 100644 --- a/synapdrive_ai/control/actuation_engine.py +++ b/synapdrive_ai/control/actuation_engine.py @@ -6,7 +6,7 @@ class ActuationEngine: """ - Simulated actuation engine that converts intent packets into mock physical actions. + Simulated actuation engine that records admitted intent packets as simulated actions. Determinism support: - simulate_delay=True -> sleeps to emulate actuation time diff --git a/synapdrive_ai/core/__init__.py b/synapdrive_ai/core/__init__.py new file mode 100644 index 0000000..a38b970 --- /dev/null +++ b/synapdrive_ai/core/__init__.py @@ -0,0 +1 @@ +"""Core logging and compatibility utilities.""" diff --git a/synapdrive_ai/dashboard/__init__.py b/synapdrive_ai/dashboard/__init__.py new file mode 100644 index 0000000..106a965 --- /dev/null +++ b/synapdrive_ai/dashboard/__init__.py @@ -0,0 +1 @@ +"""Console dashboard compatibility helpers.""" diff --git a/synapdrive_ai/governance/__init__.py b/synapdrive_ai/governance/__init__.py new file mode 100644 index 0000000..6aed041 --- /dev/null +++ b/synapdrive_ai/governance/__init__.py @@ -0,0 +1,4 @@ +from .permission import PermissionGate +from .policy import RuntimePolicy + +__all__ = ["PermissionGate", "RuntimePolicy"] diff --git a/synapdrive_ai/governance/permission.py b/synapdrive_ai/governance/permission.py new file mode 100644 index 0000000..aa3441a --- /dev/null +++ b/synapdrive_ai/governance/permission.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import fnmatch +from dataclasses import dataclass +from typing import Iterable, Tuple + + +@dataclass(frozen=True) +class PermissionDecision: + allowed: bool + reason: str + + +class PermissionGate: + """Capability-style permission gate for simulated actions. + + STOP/HALT actions are always permitted. By default the simulation namespace is + allowed except explicit high-risk administrative patterns. + """ + + def __init__( + self, + allow_patterns: Iterable[str] = ("*",), + deny_patterns: Iterable[str] = ( + "*override_security*", + "*disable_brakes*", + "*trigger_launch*", + "*release_payload*", + ), + ) -> None: + self.allow_patterns: Tuple[str, ...] = tuple(allow_patterns) + self.deny_patterns: Tuple[str, ...] = tuple(deny_patterns) + self._revoked = False + + def revoke_all(self) -> None: + self._revoked = True + + def restore(self) -> None: + self._revoked = False + + def evaluate(self, action: str) -> PermissionDecision: + normalized = (action or "unknown").strip().lower() + if any(token in normalized for token in ("halt", "stop", "hold_position")): + return PermissionDecision(True, "safe-state action permitted") + if self._revoked: + return PermissionDecision(False, "all non-safe capabilities revoked") + if any(fnmatch.fnmatch(normalized, pattern) for pattern in self.deny_patterns): + return PermissionDecision(False, "action matches denied capability pattern") + if any(fnmatch.fnmatch(normalized, pattern) for pattern in self.allow_patterns): + return PermissionDecision(True, "action matches allowed capability pattern") + return PermissionDecision(False, "action is outside granted capability patterns") diff --git a/synapdrive_ai/governance/policy.py b/synapdrive_ai/governance/policy.py new file mode 100644 index 0000000..621532e --- /dev/null +++ b/synapdrive_ai/governance/policy.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Dict + + +@dataclass(frozen=True) +class RuntimePolicy: + """Fail-closed limits for the simulation runtime. + + These are software policy limits, not medical or hardware safety limits. + """ + + min_confidence: float = 0.45 + max_uncertainty: float = 0.70 + min_signal_quality: float = 0.60 + max_predicted_risk: float = 0.70 + max_drift_score: float = 6.0 + errp_contradiction_threshold: float = 0.70 + safe_fallback_action: str = "hold_position" + + def __post_init__(self) -> None: + bounded = ( + ("min_confidence", self.min_confidence), + ("max_uncertainty", self.max_uncertainty), + ("min_signal_quality", self.min_signal_quality), + ("max_predicted_risk", self.max_predicted_risk), + ("errp_contradiction_threshold", self.errp_contradiction_threshold), + ) + for name, value in bounded: + if not 0.0 <= float(value) <= 1.0: + raise ValueError(f"{name} must be in [0, 1]") + if self.max_drift_score <= 0: + raise ValueError("max_drift_score must be > 0") + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + @classmethod + def from_json(cls, path: str | Path) -> "RuntimePolicy": + payload = json.loads(Path(path).read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("policy JSON must contain an object") + return cls(**payload) diff --git a/synapdrive_ai/integrations/brainflow_adapter.py b/synapdrive_ai/integrations/brainflow_adapter.py index 9b965c3..914bd5d 100644 --- a/synapdrive_ai/integrations/brainflow_adapter.py +++ b/synapdrive_ai/integrations/brainflow_adapter.py @@ -1,50 +1,76 @@ from __future__ import annotations +import time from dataclasses import dataclass -from typing import Any, Dict, Optional +from typing import Any, Callable, Dict, Mapping, Optional import numpy as np +from synapdrive_ai.neuro.signal_quality import SignalQualityAnalyzer -@dataclass +Decoder = Callable[[np.ndarray, Mapping[str, Any]], Mapping[str, Any]] + + +@dataclass(frozen=True) class BrainFlowSample: - """A minimal sample snapshot derived from BrainFlow board data.""" + """EEG acquisition snapshot from a BrainFlow board. - rms: float + ``data`` contains only the channels BrainFlow identifies as EEG channels. No intent + is inferred by this acquisition object. + """ + + data: np.ndarray + sampling_rate: float + board_id: int n_channels: int n_samples: int + rms: float + + def metadata(self) -> Dict[str, Any]: + return { + "board_id": self.board_id, + "sampling_rate": self.sampling_rate, + "n_channels": self.n_channels, + "n_samples": self.n_samples, + "rms": self.rms, + } class BrainFlowIntentSource: - """ - Optional “real-ish” input source backed by BrainFlow. + """Optional BrainFlow acquisition adapter with an explicit decoder boundary. + + The adapter acquires EEG samples and computes engineering signal-quality checks. It + does **not** convert signal energy into an intent. If no decoder callable is supplied, + ``next_intent_packet`` returns an ``unknown`` packet with zero confidence so the + governed runtime abstains. - - This does NOT claim medical validity. - - It uses BrainFlow's Synthetic board by default to keep it runnable. - - The mapping from sample→intent is a conservative placeholder heuristic. - Replace it with a real decoder when you integrate a headset/dataset. + The default board id is BrainFlow's SYNTHETIC_BOARD (-1), which keeps the optional + smoke path hardware-independent when BrainFlow is installed. """ def __init__( self, - board_id: int = 0, + board_id: int = -1, serial_port: Optional[str] = None, stream_seconds: float = 2.0, + decoder: Decoder | None = None, ) -> None: - # Import only when used (so base installs remain lightweight) try: from brainflow.board_shim import BoardShim, BrainFlowInputParams # type: ignore - except Exception as e: # pragma: no cover + except Exception as exc: # pragma: no cover - optional dependency raise RuntimeError( "BrainFlow is not installed. Run: pip install -r requirements-brainflow.txt" - ) from e + ) from exc + + if stream_seconds <= 0: + raise ValueError("stream_seconds must be > 0") self._BoardShim = BoardShim self._BrainFlowInputParams = BrainFlowInputParams - self.board_id = int(board_id) self.serial_port = serial_port self.stream_seconds = float(stream_seconds) + self.decoder = decoder def _acquire(self) -> BrainFlowSample: params = self._BrainFlowInputParams() @@ -52,66 +78,99 @@ def _acquire(self) -> BrainFlowSample: params.serial_port = self.serial_port board = self._BoardShim(self.board_id, params) - self._BoardShim.enable_dev_board_logger() - board.prepare_session() try: board.start_stream() - # BrainFlow streams continuously; we keep this short & deterministic. - self._BoardShim.sleep(int(self.stream_seconds * 1000)) - data = board.get_board_data() # shape: (channels, samples) + time.sleep(self.stream_seconds) + raw = board.get_board_data() finally: try: board.stop_stream() except Exception: + # Session cleanup must continue even if a driver reports that streaming + # already stopped. The acquisition error, if any, occurs before this path. pass try: board.release_session() except Exception: pass - # Compute RMS across all channels/samples as a minimal “signal energy” proxy - if data is None or len(data) == 0: - return BrainFlowSample(rms=0.0, n_channels=0, n_samples=0) - - arr = np.array(data, dtype=float) - rms = float(np.sqrt(np.mean(np.square(arr)))) if arr.size else 0.0 - n_channels = int(arr.shape[0]) if arr.ndim == 2 else 0 - n_samples = int(arr.shape[1]) if arr.ndim == 2 else int(arr.size) - - return BrainFlowSample(rms=rms, n_channels=n_channels, n_samples=n_samples) + arr = np.asarray(raw, dtype=float) + if arr.ndim != 2 or arr.size == 0: + eeg = np.empty((0, 0), dtype=float) + else: + eeg_indices = list(self._BoardShim.get_eeg_channels(self.board_id)) + eeg = arr[eeg_indices, :] if eeg_indices else np.empty((0, arr.shape[1]), dtype=float) + + sampling_rate = float(self._BoardShim.get_sampling_rate(self.board_id)) + rms = float(np.sqrt(np.mean(np.square(eeg)))) if eeg.size else 0.0 + return BrainFlowSample( + data=eeg, + sampling_rate=sampling_rate, + board_id=self.board_id, + n_channels=int(eeg.shape[0]) if eeg.ndim == 2 else 0, + n_samples=int(eeg.shape[1]) if eeg.ndim == 2 else 0, + rms=rms, + ) + + def next_observation(self) -> BrainFlowSample: + """Acquire and return EEG samples without interpreting them.""" + return self._acquire() def next_intent_packet(self) -> Dict[str, Any]: - """ - Return a packet compatible with SynapDrivePipeline._run_common(). - - Heuristic mapping: - - very low energy -> 'halt_all_motion' (safe default) - - moderate energy -> 'expand_context' - - high energy -> 'initiate_walk' - """ sample = self._acquire() - - # Conservative thresholds (placeholders) — tuned for “don’t do risky things by accident” - if sample.rms < 5.0: - intent = "halt_all_motion" - conf = 0.70 - elif sample.rms < 20.0: - intent = "expand_context" - conf = 0.75 - else: - intent = "initiate_walk" - conf = 0.80 - - return { - "intent": intent, - "confidence": float(conf), - "source": "brainflow", - "raw_text": "", - "params": { - "rms": f"{sample.rms:.3f}", - "n_channels": str(sample.n_channels), - "n_samples": str(sample.n_samples), - }, - "memory_context": [], - } + quality = SignalQualityAnalyzer(sample.sampling_rate).analyze(sample.data) + metadata = sample.metadata() + metadata["signal_quality"] = quality.to_dict() + + if self.decoder is None: + return { + "intent": "unknown", + "confidence": 0.0, + "source": "brainflow/acquisition-only", + "raw_text": "", + "params": metadata, + "memory_context": [], + "signal_quality": quality.score, + "signal_quality_state": quality.state, + "decoder_status": "not-configured", + "inference_authority": "acquisition-only", + "analysis_only": True, + "abstention_reason": "brainflow acquisition has no configured decoder", + } + + decoded = dict(self.decoder(sample.data.copy(), metadata)) + return self._validate_decoded_packet(decoded, quality.score, quality.state, metadata) + + @staticmethod + def _validate_decoded_packet( + decoded: Dict[str, Any], + quality_score: float, + quality_state: str, + metadata: Dict[str, Any], + ) -> Dict[str, Any]: + intent = str(decoded.get("intent") or "unknown").strip() or "unknown" + try: + confidence = float(decoded.get("confidence", 0.0)) + except (TypeError, ValueError) as exc: + raise ValueError("decoder confidence must be numeric") from exc + if not 0.0 <= confidence <= 1.0: + raise ValueError("decoder confidence must be in [0, 1]") + + packet = dict(decoded) + packet.update( + { + "intent": intent, + "confidence": confidence, + "source": str(decoded.get("source") or "brainflow/decoder"), + "memory_context": list(decoded.get("memory_context") or []), + "signal_quality": quality_score, + "signal_quality_state": quality_state, + "acquisition": metadata, + "decoder_status": "configured", + "inference_authority": str( + decoded.get("inference_authority") or "external-decoder" + ), + } + ) + return packet diff --git a/synapdrive_ai/integrations/lsl_adapter.py b/synapdrive_ai/integrations/lsl_adapter.py index a2da532..5a536d1 100644 --- a/synapdrive_ai/integrations/lsl_adapter.py +++ b/synapdrive_ai/integrations/lsl_adapter.py @@ -2,27 +2,44 @@ import time from dataclasses import dataclass -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Mapping, Optional import numpy as np +from synapdrive_ai.neuro.signal_quality import SignalQualityAnalyzer -@dataclass +Decoder = Callable[[np.ndarray, Mapping[str, Any]], Mapping[str, Any]] + + +@dataclass(frozen=True) class LSLSnapshot: - rms: float + """Acquired Lab Streaming Layer window. No intent is inferred here.""" + + data: np.ndarray + sampling_rate: float n_channels: int n_samples: int stream_name: str stream_type: str + rms: float + + def metadata(self) -> Dict[str, Any]: + return { + "sampling_rate": self.sampling_rate, + "n_channels": self.n_channels, + "n_samples": self.n_samples, + "stream_name": self.stream_name, + "stream_type": self.stream_type, + "rms": self.rms, + } class LSLIntentSource: - """ - Optional lab-stream input source backed by pylsl (Lab Streaming Layer). + """Optional LSL acquisition adapter with an explicit decoder boundary. - - This does NOT claim medical validity. - - By default, it resolves the *first* matching stream and takes a short snapshot window. - - Mapping from snapshot → intent is conservative placeholder logic. + LSL samples are acquired and quality-checked. No RMS-to-intent heuristic exists. If + a decoder is not supplied the returned packet is an explicit abstention and therefore + cannot pass the governed runtime's confidence gate. """ def __init__( @@ -32,40 +49,37 @@ def __init__( resolve_timeout_s: float = 5.0, snapshot_seconds: float = 2.0, max_chunk_samples: int = 512, + decoder: Decoder | None = None, ) -> None: try: from pylsl import StreamInlet, resolve_stream # type: ignore - except Exception as e: # pragma: no cover + except Exception as exc: # pragma: no cover - optional dependency raise RuntimeError( "pylsl is not installed. Run: pip install -r requirements-lsl.txt" - ) from e + ) from exc + + if resolve_timeout_s <= 0: + raise ValueError("resolve_timeout_s must be > 0") + if snapshot_seconds <= 0: + raise ValueError("snapshot_seconds must be > 0") + if max_chunk_samples <= 0: + raise ValueError("max_chunk_samples must be > 0") self._StreamInlet = StreamInlet self._resolve_stream = resolve_stream - self.stream_name = stream_name self.stream_type = stream_type self.resolve_timeout_s = float(resolve_timeout_s) self.snapshot_seconds = float(snapshot_seconds) self.max_chunk_samples = int(max_chunk_samples) + self.decoder = decoder def _resolve(self): - """ - Resolve an LSL stream. - If both name and type are unset, resolve any stream (first match). - """ - streams = [] - - # pylsl resolve_stream supports property queries like ("name", "X") or ("type", "EEG") if self.stream_name: streams = self._resolve_stream("name", self.stream_name, timeout=self.resolve_timeout_s) - elif self.stream_type: - streams = self._resolve_stream("type", self.stream_type, timeout=self.resolve_timeout_s) else: - # Fallback: pylsl does not support a type wildcard, so try a common type first. - # then fall back to a short "anything" attempt by name is not possible. - # Best-effort: try EEG first (common) then raise if none found. - streams = self._resolve_stream("type", "EEG", timeout=self.resolve_timeout_s) + requested_type = self.stream_type or "EEG" + streams = self._resolve_stream("type", requested_type, timeout=self.resolve_timeout_s) if not streams: raise RuntimeError( @@ -74,82 +88,98 @@ def _resolve(self): info = streams[0] inlet = self._StreamInlet(info, max_chunklen=self.max_chunk_samples) - return inlet, info def _snapshot(self) -> LSLSnapshot: inlet, info = self._resolve() + name = str(getattr(info, "name", lambda: "unknown")()) + stream_type = str(getattr(info, "type", lambda: "unknown")()) + nominal_srate = float(getattr(info, "nominal_srate", lambda: 0.0)() or 0.0) - name = getattr(info, "name", lambda: "unknown")() - stype = getattr(info, "type", lambda: "unknown")() - - # pull_chunk returns (samples, timestamps) - # samples is List[List[float]] with shape approx [n_samples][n_channels] samples: List[List[float]] = [] - start = time.time() - - while (time.time() - start) < self.snapshot_seconds: - chunk, _ts = inlet.pull_chunk(timeout=0.2, max_samples=self.max_chunk_samples) + start = time.monotonic() + while (time.monotonic() - start) < self.snapshot_seconds: + chunk, _timestamps = inlet.pull_chunk(timeout=0.2, max_samples=self.max_chunk_samples) if chunk: samples.extend(chunk) if not samples: - return LSLSnapshot( - rms=0.0, - n_channels=0, - n_samples=0, - stream_name=str(name), - stream_type=str(stype), - ) - - arr = np.array(samples, dtype=float) # shape: (n_samples, n_channels) - rms = float(np.sqrt(np.mean(np.square(arr)))) if arr.size else 0.0 - n_samples = int(arr.shape[0]) if arr.ndim == 2 else int(arr.size) - n_channels = int(arr.shape[1]) if arr.ndim == 2 else 0 + data = np.empty((0, 0), dtype=float) + else: + arr = np.asarray(samples, dtype=float) + if arr.ndim == 1: + arr = arr[:, None] + # Internal convention is channels x samples. + data = arr.T + rms = float(np.sqrt(np.mean(np.square(data)))) if data.size else 0.0 return LSLSnapshot( + data=data, + sampling_rate=nominal_srate, + n_channels=int(data.shape[0]) if data.ndim == 2 else 0, + n_samples=int(data.shape[1]) if data.ndim == 2 else 0, + stream_name=name, + stream_type=stream_type, rms=rms, - n_channels=n_channels, - n_samples=n_samples, - stream_name=str(name), - stream_type=str(stype), ) - def next_intent_packet(self) -> Dict[str, Any]: - """ - Return a packet compatible with SynapDrivePipeline._run_common(). - - Conservative placeholder mapping: - - very low energy -> halt_all_motion - - moderate -> expand_context - - high -> initiate_walk + def next_observation(self) -> LSLSnapshot: + return self._snapshot() - You can replace this with a real decoder later (bandpower features, ML classifier, etc). - """ + def next_intent_packet(self) -> Dict[str, Any]: snap = self._snapshot() + metadata = snap.metadata() - # Conservative thresholds — tuned to avoid accidental movement on noisy data. - if snap.rms < 5.0: - intent = "halt_all_motion" - conf = 0.70 - elif snap.rms < 20.0: - intent = "expand_context" - conf = 0.75 + if snap.sampling_rate <= 0: + quality_score = 0.0 + quality_state = "invalid" + quality_detail: Dict[str, Any] = {"issues": ["unknown-sampling-rate"]} else: - intent = "initiate_walk" - conf = 0.80 - - return { - "intent": intent, - "confidence": float(conf), - "source": "lsl", - "raw_text": "", - "params": { - "rms": f"{snap.rms:.3f}", - "n_channels": str(snap.n_channels), - "n_samples": str(snap.n_samples), - "stream_name": snap.stream_name, - "stream_type": snap.stream_type, - }, - "memory_context": [], - } + quality = SignalQualityAnalyzer(snap.sampling_rate).analyze(snap.data) + quality_score = quality.score + quality_state = quality.state + quality_detail = quality.to_dict() + metadata["signal_quality"] = quality_detail + + if self.decoder is None: + return { + "intent": "unknown", + "confidence": 0.0, + "source": "lsl/acquisition-only", + "raw_text": "", + "params": metadata, + "memory_context": [], + "signal_quality": quality_score, + "signal_quality_state": quality_state, + "decoder_status": "not-configured", + "inference_authority": "acquisition-only", + "analysis_only": True, + "abstention_reason": "LSL acquisition has no configured decoder", + } + + decoded = dict(self.decoder(snap.data.copy(), metadata)) + intent = str(decoded.get("intent") or "unknown").strip() or "unknown" + try: + confidence = float(decoded.get("confidence", 0.0)) + except (TypeError, ValueError) as exc: + raise ValueError("decoder confidence must be numeric") from exc + if not 0.0 <= confidence <= 1.0: + raise ValueError("decoder confidence must be in [0, 1]") + + packet = dict(decoded) + packet.update( + { + "intent": intent, + "confidence": confidence, + "source": str(decoded.get("source") or "lsl/decoder"), + "memory_context": list(decoded.get("memory_context") or []), + "signal_quality": quality_score, + "signal_quality_state": quality_state, + "acquisition": metadata, + "decoder_status": "configured", + "inference_authority": str( + decoded.get("inference_authority") or "external-decoder" + ), + } + ) + return packet diff --git a/synapdrive_ai/intent/intent_parser.py b/synapdrive_ai/intent/intent_parser.py index 6eb4aef..879ddce 100644 --- a/synapdrive_ai/intent/intent_parser.py +++ b/synapdrive_ai/intent/intent_parser.py @@ -2,12 +2,11 @@ This repo is intentionally **simulation-first**. -We support two "input" shapes: -1) decoded BCI intent text (e.g. "move left", "stop", "switch mode to manual") -2) raw brain-signal labels produced by our simulator (handled elsewhere) +We support caller-declared command text such as "move left", "stop", or +"switch mode to manual". Synthetic signal labels are handled elsewhere. This parser is a *small, deterministic* rule-based layer that translates text into -structured intents suitable for downstream safety checks and actuation. +structured simulation actions suitable for downstream governance checks. """ from __future__ import annotations diff --git a/synapdrive_ai/interface/__init__.py b/synapdrive_ai/interface/__init__.py new file mode 100644 index 0000000..2916742 --- /dev/null +++ b/synapdrive_ai/interface/__init__.py @@ -0,0 +1 @@ +"""User-facing bridge and standard-library WSGI dashboard interfaces.""" diff --git a/synapdrive_ai/interface/bridge.py b/synapdrive_ai/interface/bridge.py index dd466de..e0f45b8 100644 --- a/synapdrive_ai/interface/bridge.py +++ b/synapdrive_ai/interface/bridge.py @@ -1,48 +1,48 @@ -from synapdrive_ai.agi.core_reasoning import AGICoreReasoner +from __future__ import annotations + +from typing import Any, Dict + from synapdrive_ai.bci.signal_simulator import BrainSignalSimulator -from synapdrive_ai.control.actuation_engine import ActuationEngine +from synapdrive_ai.pipeline import SynapDrivePipeline class SynapDriveBridge: - """ - Integration layer that bridges brain signal simulator, AGI reasoning core, - and control actuation engine. + """Backward-compatible streaming bridge into the canonical governed pipeline. - Simulates a closed-loop system where brain signals drive cognition, - which in turn drives real-world control responses. + The bridge is intentionally simulation-only. It subscribes to the bundled synthetic + signal generator and forwards the *declared synthetic label* through the canonical + pipeline. There is no parallel actuator or bypass around runtime governance. """ - def __init__(self): + def __init__(self, simulate_delay: bool = False) -> None: self.simulator = BrainSignalSimulator() - self.reasoner = AGICoreReasoner() - self.actuator = ActuationEngine() + self.pipeline = SynapDrivePipeline(simulate_delay=simulate_delay) self.running = False + self._cycle_log: list[Dict[str, Any]] = [] - def _signal_handler(self, label, signal_data): - """Receive brain signal, reason about it, and pass intent to control.""" - intent_packet = self.reasoner.receive_signal(label, signal_data) - result = self.actuator.execute_intent(intent_packet) - print(f"[Bridge] Intent executed: {result}") + @property + def reasoner(self): + """Compatibility access to the pipeline's labeled-signal mapper.""" + return self.pipeline.reasoner - def start(self, interval=1.0): - """ - Start the full simulated pipeline with real-time signal streaming. - """ + def _signal_handler(self, label, _signal_data) -> None: + result = self.pipeline.run_signal_event(label=label) + self._cycle_log.append(result) + + def start(self, interval: float = 1.0) -> None: self.simulator.subscribe(self._signal_handler) self.simulator.start_real_time_stream(interval=interval) self.running = True - print("[Bridge] SynapDrive-AI system started.") - def stop(self): - """ - Stop the simulated pipeline. - """ + def stop(self) -> None: self.simulator.stop() self.running = False - print("[Bridge] SynapDrive-AI system stopped.") def get_action_log(self): - """ - Access logs from the actuation engine. - """ - return self.actuator.get_action_log() + return self.pipeline.get_action_log() + + def get_cycle_log(self) -> list[Dict[str, Any]]: + return list(self._cycle_log) + + def get_assurance_report(self) -> Dict[str, Any]: + return self.pipeline.get_assurance_report() diff --git a/synapdrive_ai/interface/dashboard.py b/synapdrive_ai/interface/dashboard.py index 1818b19..eaf069d 100644 --- a/synapdrive_ai/interface/dashboard.py +++ b/synapdrive_ai/interface/dashboard.py @@ -1,82 +1,60 @@ +from __future__ import annotations + import threading import time -from synapdrive_ai.agi.feedback_learning import FeedbackLearner -from synapdrive_ai.cloud.cloud_stub import CloudControlStub from synapdrive_ai.core.logger import SynapLogger from synapdrive_ai.interface.bridge import SynapDriveBridge class SynapDriveDashboard: - """ - Real-time console dashboard showing BCI input, AGI decision, - actuator feedback, memory, and cloud control results. + """Console monitor for the canonical governed simulation bridge. - This must never crash on schema drift — we use .get() defaults everywhere. + Historical versions contained a fabricated "cloud" router and a feedback learner that + updated weights outside the governed adaptation path. Both have been removed. This + dashboard only displays results already produced by the canonical runtime. """ - def __init__(self): - self.bridge = SynapDriveBridge() - self.learner = FeedbackLearner(self.bridge.reasoner) - self.cloud = CloudControlStub() + def __init__(self) -> None: + self.bridge = SynapDriveBridge(simulate_delay=False) self.logger = SynapLogger() self.running = False + self._seen_cycles = 0 - def _monitor(self): + def _monitor(self) -> None: while self.running: - log = self.bridge.get_action_log() - if log: - latest = log[-1] - - intent = latest.get("intent", "unknown") - conf = latest.get("confidence", 0.0) - status = latest.get("status", "unknown") - duration = latest.get("duration", 0.0) - source = latest.get("source", "unknown") - memory = latest.get("memory", latest.get("memory_context", [])) - - print("\n--- SynapDrive-AI Live Snapshot ---") - print(f"🧠 Intent: {intent} | Conf: {conf}") - print(f"🤖 Result: {status} | Duration: {duration}s") - print(f"📡 Source: {source}") - print(f"🧬 Memory: {memory}") - - # Cloud wants intent/confidence/source - if intent != "unknown": - self.cloud.transmit_intent( - { - "intent": intent, - "confidence": conf, - "source": source, - "memory_context": latest.get("memory_context", []), - } - ) - - # Feedback learner expects (intent_packet, result) - self.learner.apply_feedback( - { - "intent": intent, - "confidence": conf, - "source": source, - "memory_context": [], - }, - latest, + cycles = self.bridge.get_cycle_log() + while self._seen_cycles < len(cycles): + latest = cycles[self._seen_cycles] + self._seen_cycles += 1 + intent = latest.get("intent", {}) or {} + result = latest.get("result", {}) or {} + reality = latest.get("reality", {}) or {} + runtime = latest.get("runtime", {}) or {} + + print("\n--- SynapDrive-AI Governed Snapshot ---") + print( + f"Intent: {intent.get('intent', 'unknown')} | " + f"confidence={float(intent.get('confidence', 0.0)):.3f}" ) + print( + f"Runtime: {'allowed' if runtime.get('allowed') else 'blocked'} | " + f"reason={runtime.get('reason', 'unknown')}" + ) + print( + f"Result: {result.get('status', 'unknown')} | " + f"reality_aligned={reality.get('aligned', False)}" + ) + time.sleep(0.2) - if self.cloud.transmitted_packets: - print(f"✅ Cloud Routed → {self.cloud.transmitted_packets[-1]['system']}") - - time.sleep(2) - - def launch(self): - self.logger.info("Starting SynapDrive-AI dashboard...") + def launch(self) -> None: + self.logger.info("Starting SynapDrive-AI governed console dashboard") self.bridge.start() self.running = True - t = threading.Thread(target=self._monitor) - t.daemon = True - t.start() + thread = threading.Thread(target=self._monitor, daemon=True) + thread.start() - def shutdown(self): + def shutdown(self) -> None: self.running = False self.bridge.stop() - self.logger.info("Dashboard stopped.") + self.logger.info("Dashboard stopped") diff --git a/synapdrive_ai/interface/dashboard_stub.py b/synapdrive_ai/interface/dashboard_stub.py deleted file mode 100644 index 2a4a7e5..0000000 --- a/synapdrive_ai/interface/dashboard_stub.py +++ /dev/null @@ -1,44 +0,0 @@ -from flask import Flask, render_template_string, request - -from synapdrive_ai.main.integration_runner import SynapDriveExecutor - -app = Flask(__name__) -engine = SynapDriveExecutor() - -HTML_TEMPLATE = """ - - - - SynapDrive-AI Dashboard - - -

🧠 SynapDrive-AI AGI Interface

-
-
-

-
-

- -
- {% if result %} -

Results:

-
{{ result }}
- {% endif %} - - -""" - - -@app.route("/", methods=["GET", "POST"]) -def dashboard(): - result = None - if request.method == "POST": - intent = request.form["intent"] - visual = request.form.get("visual", "") - agi_result = engine.run_once(intent, visual or None) - result = agi_result - return render_template_string(HTML_TEMPLATE, result=result) - - -if __name__ == "__main__": - app.run(debug=True) diff --git a/synapdrive_ai/interface/templates/index.html b/synapdrive_ai/interface/templates/index.html index 66446d4..3ec3f1d 100644 --- a/synapdrive_ai/interface/templates/index.html +++ b/synapdrive_ai/interface/templates/index.html @@ -16,12 +16,13 @@

SynapDrive-AI Dashboard

- Canonical pipeline: intent → optimizer → safety → actuation → evaluation (simulation-first). + Simulation only: declared input → uncertainty/runtime gates → simulated action → + reality/evidence reconciliation.
-

Run (Text / Decoded Intent)

+

Run (Declared Text Command)



@@ -33,7 +34,7 @@

Run (Text / Decoded Intent)

Run (Signal / Simulated Label)


- +
diff --git a/synapdrive_ai/interface/web_dashboard.py b/synapdrive_ai/interface/web_dashboard.py index 41b867c..1d468e4 100644 --- a/synapdrive_ai/interface/web_dashboard.py +++ b/synapdrive_ai/interface/web_dashboard.py @@ -1,58 +1,188 @@ from __future__ import annotations -from flask import Flask, jsonify, render_template, request +import io +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Iterable +from wsgiref.simple_server import make_server +from wsgiref.types import StartResponse, WSGIEnvironment +from wsgiref.util import setup_testing_defaults from synapdrive_ai.pipeline import SynapDrivePipeline -app = Flask(__name__) - -# IMPORTANT: use no-delay mode so: -# - local dev feels snappy -# - tests don’t sleep for simulated actuation -pipeline = SynapDrivePipeline(simulate_delay=False) - - -@app.get("/") -def home(): - return render_template("index.html") - - -@app.get("/api/log") -def get_log(): - log = pipeline.get_action_log() - return jsonify({"count": len(log), "log": log[-50:]}) - - -@app.get("/api/assurance") -def get_assurance(): - return jsonify( - { - "report": pipeline.get_assurance_report(), - "receipts": pipeline.get_assurance_log()[-50:], - } - ) - - -@app.post("/api/run/text") -def run_text(): - data = request.get_json(force=True) or {} - cmd = (data.get("text") or "").strip() - image = data.get("image") - out = pipeline.run_text_command(cmd, image_label=image) - return jsonify(out) - - -@app.post("/api/run/signal") -def run_signal(): - data = request.get_json(force=True) or {} - label = data.get("label") # None or string - image = data.get("image") - out = pipeline.run_signal_event(label=label, image_label=image) - return jsonify(out) - - -def main(): - app.run(host="127.0.0.1", port=5055, debug=True) +_TEMPLATE = Path(__file__).with_name("templates") / "index.html" + + +@dataclass(frozen=True) +class DashboardResponse: + status_code: int + data: bytes + content_type: str + + def get_json(self) -> Any: + if "application/json" not in self.content_type: + return None + return json.loads(self.data.decode("utf-8")) + + +class DashboardTestClient: + """In-process WSGI client used by the repository's route tests.""" + + def __init__(self, application: "DashboardApplication") -> None: + self.application = application + + def get(self, path: str) -> DashboardResponse: + return self._request("GET", path, None) + + def post(self, path: str, json: Any = None) -> DashboardResponse: + return self._request("POST", path, json) + + def _request(self, method: str, path: str, payload: Any) -> DashboardResponse: + body = b"" if payload is None else json.dumps(payload).encode("utf-8") + environ: dict[str, Any] = {} + setup_testing_defaults(environ) + environ.update( + { + "REQUEST_METHOD": method, + "PATH_INFO": path, + "CONTENT_TYPE": "application/json" if body else "", + "CONTENT_LENGTH": str(len(body)), + "wsgi.input": io.BytesIO(body), + } + ) + captured: dict[str, Any] = {} + + def start_response( + status: str, + headers: list[tuple[str, str]], + exc_info: object = None, + ) -> Callable[[bytes], object]: + del exc_info + captured["status"] = status + captured["headers"] = dict(headers) + return lambda data: len(data) + + chunks = self.application(environ, start_response) + data = b"".join(chunks) + status_code = int(str(captured["status"]).split()[0]) + headers = captured.get("headers", {}) + return DashboardResponse( + status_code=status_code, + data=data, + content_type=str(headers.get("Content-Type", "application/octet-stream")), + ) + + +class DashboardApplication: + """Small localhost-only WSGI dashboard over the canonical governed pipeline.""" + + def __init__(self, pipeline: SynapDrivePipeline | None = None) -> None: + self.pipeline = pipeline or SynapDrivePipeline(simulate_delay=False) + + def test_client(self) -> DashboardTestClient: + return DashboardTestClient(self) + + def __call__( + self, + environ: WSGIEnvironment, + start_response: StartResponse, + ) -> Iterable[bytes]: + method = str(environ.get("REQUEST_METHOD", "GET")).upper() + path = str(environ.get("PATH_INFO", "/")) + try: + status, content_type, payload = self._dispatch(method, path, environ) + except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as exc: + status, content_type, payload = ( + "400 Bad Request", + "application/json; charset=utf-8", + self._json_bytes({"error": "invalid-request", "detail": str(exc)}), + ) + except Exception: + status, content_type, payload = ( + "500 Internal Server Error", + "application/json; charset=utf-8", + self._json_bytes({"error": "internal-server-error"}), + ) + start_response( + status, + [ + ("Content-Type", content_type), + ("Content-Length", str(len(payload))), + ("Cache-Control", "no-store"), + ("X-Content-Type-Options", "nosniff"), + ], + ) + return [payload] + + def _dispatch( + self, + method: str, + path: str, + environ: WSGIEnvironment + ) -> tuple[str, str, bytes]: + if method == "GET" and path == "/": + return "200 OK", "text/html; charset=utf-8", _TEMPLATE.read_bytes() + if method == "GET" and path == "/api/log": + log = self.pipeline.get_action_log() + return self._json_response({"count": len(log), "log": log[-50:]}) + if method == "GET" and path == "/api/assurance": + return self._json_response( + { + "report": self.pipeline.get_assurance_report(), + "receipts": self.pipeline.get_assurance_log()[-50:], + } + ) + if method == "POST" and path == "/api/run/text": + data = self._read_json(environ) + command = str(data.get("text") or "").strip() + image = data.get("image") + return self._json_response( + self.pipeline.run_text_command(command, image_label=image) + ) + if method == "POST" and path == "/api/run/signal": + data = self._read_json(environ) + label = data.get("label") + image = data.get("image") + return self._json_response( + self.pipeline.run_signal_event(label=label, image_label=image) + ) + if path.startswith("/api/"): + return self._json_response({"error": "not-found"}, status="404 Not Found") + return "404 Not Found", "text/plain; charset=utf-8", b"Not Found\n" + + @staticmethod + def _read_json(environ: dict[str, Any]) -> dict[str, Any]: + raw_length = str(environ.get("CONTENT_LENGTH") or "0") + length = int(raw_length) if raw_length.isdigit() else 0 + raw = environ["wsgi.input"].read(length) if length else b"{}" + payload = json.loads(raw.decode("utf-8")) + if not isinstance(payload, dict): + raise ValueError("JSON body must be an object") + return payload + + @staticmethod + def _json_bytes(payload: Any) -> bytes: + return json.dumps(payload, default=str, separators=(",", ":")).encode("utf-8") + + def _json_response( + self, + payload: Any, + *, + status: str = "200 OK", + ) -> tuple[str, str, bytes]: + return status, "application/json; charset=utf-8", self._json_bytes(payload) + + +app = DashboardApplication() + + +def main() -> None: + host = "127.0.0.1" + port = 5055 + print(f"SynapDrive-AI dashboard listening on http://{host}:{port}") + with make_server(host, port, app) as server: + server.serve_forever() if __name__ == "__main__": diff --git a/synapdrive_ai/main/__init__.py b/synapdrive_ai/main/__init__.py new file mode 100644 index 0000000..3f1c04f --- /dev/null +++ b/synapdrive_ai/main/__init__.py @@ -0,0 +1 @@ +"""Command-line and integration entry points.""" diff --git a/synapdrive_ai/main/cli.py b/synapdrive_ai/main/cli.py index 16397c6..6802f7e 100644 --- a/synapdrive_ai/main/cli.py +++ b/synapdrive_ai/main/cli.py @@ -19,8 +19,14 @@ def _print_summary(out: dict) -> None: print(json.dumps(out.get("result", {}), indent=2, default=str)) print("\nevaluation:") print(json.dumps(out.get("evaluation", {}), indent=2, default=str)) + print("\nruntime:") + print(json.dumps(out.get("runtime", {}), indent=2, default=str)) + print("\nreality:") + print(json.dumps(out.get("reality", {}), indent=2, default=str)) print("\nassurance:") print(json.dumps(out.get("assurance", {}), indent=2, default=str)) + print("\nevidence:") + print(json.dumps(out.get("evidence", {}), indent=2, default=str)) def build_parser() -> argparse.ArgumentParser: @@ -31,26 +37,34 @@ def build_parser() -> argparse.ArgumentParser: mode = p.add_mutually_exclusive_group(required=True) mode.add_argument( - "--text", help='Run one cycle using decoded intent text (e.g. "move left", "stop").' + "--text", + help='Run one cycle using caller-declared command text (e.g. "move left", "stop").', ) mode.add_argument( "--signal", nargs="?", const="RANDOM", - help="Run one cycle using a signal label (or RANDOM).", + help="Run one cycle using an explicit synthetic fixture label (or seeded RANDOM).", ) mode.add_argument( - "--brainflow", action="store_true", help="Run one cycle using BrainFlow input (optional)." + "--brainflow", + action="store_true", + help="Acquire one BrainFlow window. CLI has no implicit decoder, so this path abstains.", ) mode.add_argument( - "--lsl", action="store_true", help="Run one cycle using LSL input (optional)." + "--lsl", + action="store_true", + help="Acquire one LSL window. CLI has no implicit decoder, so this path abstains.", ) mode.add_argument("--replay", help="Replay JSONL records from a prior --record run.") p.add_argument( "--image", default=None, - help="Optional simulated vision label (road, hazard, person, vehicle).", + help=( + "Optional caller-declared context label (road, hazard, person, vehicle); " + "no image model runs." + ), ) p.add_argument("--count", type=int, default=1, help="How many cycles to run (default: 1).") p.add_argument( @@ -61,6 +75,22 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument( "--record", default=None, help="Write each cycle to a JSONL file (reproducible runs)." ) + p.add_argument( + "--evidence-out", + default=None, + help="Export the tamper-evident cycle ledger to JSONL after the run.", + ) + p.add_argument( + "--errp-probability", + type=float, + default=None, + help="Optional externally-derived error-potential probability for feedback testing.", + ) + p.add_argument( + "--reject-feedback", + action="store_true", + help="Explicitly mark the action feedback as rejected.", + ) p.add_argument( "--no-delay", action="store_true", @@ -68,7 +98,7 @@ def build_parser() -> argparse.ArgumentParser: ) # BrainFlow options - p.add_argument("--bf-board-id", type=int, default=0) + p.add_argument("--bf-board-id", type=int, default=-1) p.add_argument("--bf-serial-port", default=None) p.add_argument("--bf-seconds", type=float, default=2.0) @@ -88,6 +118,12 @@ def main(argv=None) -> int: pipe = SynapDrivePipeline(simulate_delay=simulate_delay) recorder = JsonlRecorder(args.record) if args.record else None + feedback = {} + if args.errp_probability is not None: + feedback["errp_probability"] = args.errp_probability + if args.reject_feedback: + feedback["accepted"] = False + feedback = feedback or None if args.replay: # Replay should be deterministic + fast @@ -95,8 +131,12 @@ def main(argv=None) -> int: for rec in iter_jsonl(args.replay): intent_packet = rec["intent_packet"] image_label = rec.get("image_label") - out = pipe.run_intent_packet(intent_packet, image_label=image_label) + out = pipe.run_intent_packet( + intent_packet, image_label=image_label, feedback=feedback + ) _print_summary(out) + if args.evidence_out: + pipe.evidence.export_jsonl(args.evidence_out) return 0 for i in range(args.count): @@ -107,7 +147,9 @@ def main(argv=None) -> int: if args.text is not None: mode = "text" raw_input = {"text": args.text} - out = pipe.run_text_command(args.text, image_label=args.image) + out = pipe.run_text_command( + args.text, image_label=args.image, feedback=feedback + ) elif args.brainflow: mode = "brainflow" @@ -120,7 +162,9 @@ def main(argv=None) -> int: ) intent_packet = src.next_intent_packet() raw_input = {"brainflow": {"board_id": args.bf_board_id, "seconds": args.bf_seconds}} - out = pipe.run_intent_packet(intent_packet, image_label=args.image) + out = pipe.run_intent_packet( + intent_packet, image_label=args.image, feedback=feedback + ) elif args.lsl: mode = "lsl" @@ -136,13 +180,17 @@ def main(argv=None) -> int: raw_input = { "lsl": {"name": args.lsl_name, "type": args.lsl_type, "seconds": args.lsl_seconds} } - out = pipe.run_intent_packet(intent_packet, image_label=args.image) + out = pipe.run_intent_packet( + intent_packet, image_label=args.image, feedback=feedback + ) else: mode = "signal" label = None if args.signal == "RANDOM" else args.signal raw_input = {"label": label or "RANDOM"} - out = pipe.run_signal_event(label=label, image_label=args.image) + out = pipe.run_signal_event( + label=label, image_label=args.image, feedback=feedback + ) _print_summary(out) @@ -163,6 +211,9 @@ def main(argv=None) -> int: if i < args.count - 1 and args.interval > 0: time.sleep(args.interval) + if args.evidence_out: + pipe.evidence.export_jsonl(args.evidence_out) + return 0 diff --git a/synapdrive_ai/main/test_simulation.py b/synapdrive_ai/main/test_simulation.py index d8238d9..e3b0b3f 100644 --- a/synapdrive_ai/main/test_simulation.py +++ b/synapdrive_ai/main/test_simulation.py @@ -1,36 +1,33 @@ from synapdrive_ai.main.integration_runner import SynapDriveExecutor -def run_simulation(): - engine = SynapDriveExecutor() +def run_simulation() -> None: + engine = SynapDriveExecutor(simulate_delay=False) - print("\n🔬 SynapDrive-AI AGI Simulation\n") - simulated_bci_input = input( - "🧠 Enter simulated intent (e.g. 'navigate forward', 'initiate docking'): " - ) - simulated_image_input = input( - "👁️ Enter simulated visual label (e.g. 'road', 'hazard', or leave blank): " + print("\nSynapDrive-AI governed simulation\n") + command = input("Enter a declared text intent (for example 'move left' or 'stop'): ") + visual = input( + "Optional declared visual context label (road, hazard, person, vehicle; blank for none): " ) - result = engine.run_once(simulated_bci_input, simulated_image_input or None) + result = engine.run_once(command, visual or None) - print("\n🧠 AGI Decision Summary:") - print("------------------------") - for k, v in result["intent"].items(): - print(f"{k}: {v}") + print("\nGoverned decision summary") + print("-------------------------") + for key, value in result["intent"].items(): + print(f"{key}: {value}") - print("\n✅ Execution Result:") - print("--------------------") - for k, v in result["result"].items(): - print(f"{k}: {v}") + print("\nSimulation result") + print("-----------------") + for key, value in result["result"].items(): + print(f"{key}: {value}") - print("\n📊 Evaluation Metrics:") - print("----------------------") - for k, v in result["evaluation"].items(): - print(f"{k}: {v}") + print("\nAssurance") + print("---------") + print(result.get("assurance", {})) if result["status"] == "blocked": - print(f"\n🚫 Safety Blocked Execution:\nReason: {result['reason']}") + print(f"\nBlocked: {result['reason']}") if __name__ == "__main__": diff --git a/synapdrive_ai/memory/__init__.py b/synapdrive_ai/memory/__init__.py new file mode 100644 index 0000000..add1d98 --- /dev/null +++ b/synapdrive_ai/memory/__init__.py @@ -0,0 +1 @@ +"""Episodic and evidence-gated memory stores.""" diff --git a/synapdrive_ai/memory/episodic_memory.py b/synapdrive_ai/memory/episodic_memory.py index dd92cdc..25527b3 100644 --- a/synapdrive_ai/memory/episodic_memory.py +++ b/synapdrive_ai/memory/episodic_memory.py @@ -2,18 +2,12 @@ class EpisodicMemory: - """ - Stores sequences of past AGI decisions and outcomes. - Supports context-aware recall and adaptation. - """ + """Backward-compatible episodic store with outcome-aware recall.""" def __init__(self): self.episodes = [] def record_episode(self, intent_packet, result_packet): - """ - Stores a full cognitive interaction and result as a retrievable memory. - """ episode = { "timestamp": time.time(), "intent": intent_packet["intent"], @@ -30,5 +24,12 @@ def retrieve_recent(self, count=5): def find_by_intent(self, intent_keyword): return [ep for ep in self.episodes if intent_keyword in ep["intent"]] + def find_successful_by_intent(self, intent_keyword): + return [ + ep + for ep in self.episodes + if intent_keyword in ep["intent"] and ep.get("result") in {"success", "executed"} + ] + def clear_memory(self): self.episodes.clear() diff --git a/synapdrive_ai/memory/evidence_memory.py b/synapdrive_ai/memory/evidence_memory.py new file mode 100644 index 0000000..cb7e813 --- /dev/null +++ b/synapdrive_ai/memory/evidence_memory.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import time +from dataclasses import asdict, dataclass +from typing import Any, Dict, List, Literal + +MemoryState = Literal["validated", "quarantined"] + + +@dataclass(frozen=True) +class EvidenceMemoryRecord: + timestamp: float + cycle_id: str + intent: str + confidence: float + outcome: str + state: MemoryState + reason: str + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +class EvidenceMemory: + """Outcome-bound memory that refuses to reinforce contradicted cycles.""" + + def __init__(self) -> None: + self._records: List[EvidenceMemoryRecord] = [] + + def record( + self, + *, + cycle_id: str, + intent: str, + confidence: float, + outcome: str, + aligned: bool, + reason: str, + ) -> EvidenceMemoryRecord: + record = EvidenceMemoryRecord( + timestamp=time.time(), + cycle_id=cycle_id, + intent=intent, + confidence=max(0.0, min(1.0, float(confidence))), + outcome=outcome, + state="validated" if aligned else "quarantined", + reason=reason, + ) + self._records.append(record) + return record + + def validated_for_intent(self, intent: str) -> List[EvidenceMemoryRecord]: + return [r for r in self._records if r.intent == intent and r.state == "validated"] + + def quarantined(self) -> List[EvidenceMemoryRecord]: + return [r for r in self._records if r.state == "quarantined"] + + def all(self) -> List[EvidenceMemoryRecord]: + return list(self._records) diff --git a/synapdrive_ai/neuro/__init__.py b/synapdrive_ai/neuro/__init__.py index 3861dbc..2a7757d 100644 --- a/synapdrive_ai/neuro/__init__.py +++ b/synapdrive_ai/neuro/__init__.py @@ -1,13 +1,9 @@ from synapdrive_ai.neuro.band_analyzer import BANDS, BandPowerAnalyzer, BandPowerResult from synapdrive_ai.neuro.eeg_loader import EEGLoader, EEGRecording -from synapdrive_ai.neuro.session_analyzer import EpochResult, SessionAnalyzer, SessionReport -from synapdrive_ai.neuro.task_planner import ( - ExecutorBridge, - PlanTrace, - StepTrace, - TaskPlan, - TaskStep, -) +from synapdrive_ai.neuro.errp import ErrPFeatureExtractor, ErrPFeatures, ErrPLDAClassifier +from synapdrive_ai.neuro.fusion import FusionResult, ModalityEvidence, WeightedEvidenceFusion +from synapdrive_ai.neuro.signal_quality import SignalQualityAnalyzer, SignalQualityReport +from synapdrive_ai.neuro.uncertainty import ReliabilityCalibrator, UncertaintyEstimate __all__ = [ "BANDS", @@ -15,12 +11,14 @@ "BandPowerResult", "EEGLoader", "EEGRecording", - "EpochResult", - "SessionAnalyzer", - "SessionReport", - "ExecutorBridge", - "PlanTrace", - "StepTrace", - "TaskPlan", - "TaskStep", + "ErrPFeatureExtractor", + "ErrPFeatures", + "ErrPLDAClassifier", + "FusionResult", + "ModalityEvidence", + "ReliabilityCalibrator", + "SignalQualityAnalyzer", + "SignalQualityReport", + "UncertaintyEstimate", + "WeightedEvidenceFusion", ] diff --git a/synapdrive_ai/neuro/band_analyzer.py b/synapdrive_ai/neuro/band_analyzer.py index 086cda2..1d18e6d 100644 --- a/synapdrive_ai/neuro/band_analyzer.py +++ b/synapdrive_ai/neuro/band_analyzer.py @@ -16,7 +16,11 @@ @dataclass class BandPowerResult: - """Per-band absolute and relative power, plus derived confidence.""" + """Per-band spectral features plus an uncalibrated heuristic separation score. + + The historical ``confidence`` field name is retained for API compatibility. It is not + a calibrated probability and must not be used as neural-command authority. + """ absolute: Dict[str, float] relative: Dict[str, float] diff --git a/synapdrive_ai/neuro/cli.py b/synapdrive_ai/neuro/cli.py index 2d2fcb4..6c33a70 100644 --- a/synapdrive_ai/neuro/cli.py +++ b/synapdrive_ai/neuro/cli.py @@ -33,7 +33,7 @@ def cmd_demo(args) -> int: + 1.2 * np.sin(2 * np.pi * 20 * t[burst]) + 0.8 * np.sin(2 * np.pi * 40 * t[burst]) ) - signal += np.random.normal(0, 0.05, n_samples) + signal += np.random.default_rng(0).normal(0, 0.05, n_samples) loader = EEGLoader(sampling_rate=sr) recording = loader.load_array( @@ -41,7 +41,7 @@ def cmd_demo(args) -> int: ) print(f"Synthetic recording: {recording.summary()}") - print("Motor intent burst injected at t=4s–7s\n") + print("Synthetic beta/gamma spectral burst injected at t=4s-7s; no neural intent is claimed.\n") analyzer = BandPowerAnalyzer(sampling_rate=sr) result = analyzer.analyze(signal) @@ -50,15 +50,15 @@ def cmd_demo(args) -> int: bar = "█" * int(power * 40) print(f" {band:6s} {power:.3f} {bar}") print(f" engagement ratio: {result.engagement_ratio:.3f}") - print(f" intent class: {result.intent_class}") - print(f" confidence: {result.confidence:.3f}\n") + print(f" heuristic class: {result.intent_class}") + print(f" heuristic score: {result.confidence:.3f} (uncalibrated)\n") session_analyzer = SessionAnalyzer(channel="C3", window_s=1.0, step_s=0.5) report = session_analyzer.run(recording) print(report.summary()) print("\nEpoch detail:") - print(f" {'t_start':>7} {'class':>10} {'conf':>6} {'status':>8}") + print(f" {'t_start':>7} {'class':>10} {'score':>6} {'status':>12}") for ep in report.epochs: print( f" {ep.time_start_s:>7.1f}s {ep.intent_class:>10} " @@ -126,17 +126,21 @@ def cmd_threshold(args) -> int: channel = args.channel or recording.channels[0] thresholds = [float(t) for t in args.min_conf] - print(f"Threshold sweep — {path.name} channel={channel}\n") - print(f" {'threshold':>10} {'blocked%':>10} {'mean_conf':>10} {'n_success':>10}") + analyzer = SessionAnalyzer(channel=channel, window_s=args.window, step_s=args.step) + report = analyzer.run(recording) + scores = np.asarray([epoch.signal_confidence for epoch in report.epochs], dtype=float) + print(f"Heuristic spectral-score sweep: {path.name} channel={channel}\n") + print( + "This is descriptive analysis only. The score is uncalibrated and is not decoder " + "confidence, accuracy, or actuation authority.\n" + ) + print(f" {'threshold':>10} {'epochs>=thr':>12} {'fraction':>10} {'mean_score':>10}") for thresh in thresholds: - analyzer = SessionAnalyzer(channel=channel, window_s=args.window, step_s=args.step) - analyzer._pipe.guard.min_confidence_threshold = thresh - report = analyzer.run(recording) - print( - f" {thresh:>10.3f} {report.block_rate * 100:>9.1f}% " - f"{report.mean_confidence:>10.3f} {report.n_success:>10}" - ) + count = int(np.sum(scores >= thresh)) + fraction = count / len(scores) if len(scores) else 0.0 + mean_score = float(np.mean(scores)) if len(scores) else 0.0 + print(f" {thresh:>10.3f} {count:>12} {fraction:>10.3f} {mean_score:>10.3f}") return 0 @@ -175,7 +179,7 @@ def cmd_plan(args) -> int: if args.list: print("Available plans:") for name, plan in plans.items(): - print(f" {name}: {len(plan)} steps — {plan.name}") + print(f" {name}: {len(plan)} steps: {plan.name}") return 0 task_name = args.task or "reach_grasp" @@ -208,7 +212,7 @@ def build_parser() -> argparse.ArgumentParser: a.add_argument("--sr", type=float, default=256.0, help="Sampling rate Hz for CSV/NPY") a.add_argument("--out", default=None, help="Output directory for JSONL + CSV results") - t = sub.add_parser("threshold", help="Test multiple confidence thresholds") + t = sub.add_parser("threshold", help="Sweep uncalibrated spectral-score thresholds") t.add_argument("file") t.add_argument("--min-conf", nargs="+", default=["0.4", "0.5", "0.6", "0.7"]) t.add_argument("--channel", default=None) diff --git a/synapdrive_ai/neuro/drift.py b/synapdrive_ai/neuro/drift.py new file mode 100644 index 0000000..278f520 --- /dev/null +++ b/synapdrive_ai/neuro/drift.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + +import numpy as np + + +@dataclass(frozen=True) +class DriftResult: + score: float + drifted: bool + threshold: float + + +class FeatureDriftMonitor: + """Mahalanobis-distance drift monitor for decoder feature vectors.""" + + def __init__(self, quantile: float = 0.99, regularization: float = 1e-6) -> None: + if not 0.5 < quantile < 1.0: + raise ValueError("quantile must be between 0.5 and 1.0") + self.quantile = quantile + self.regularization = regularization + self.mean_: np.ndarray | None = None + self.inv_cov_: np.ndarray | None = None + self.threshold_: float | None = None + + def fit(self, features: Iterable[Iterable[float]]) -> "FeatureDriftMonitor": + x = np.asarray(list(features), dtype=float) + if x.ndim != 2 or len(x) < 3: + raise ValueError("need at least three feature vectors") + self.mean_ = np.mean(x, axis=0) + cov = np.cov(x, rowvar=False) + if np.ndim(cov) == 0: + cov = np.array([[float(cov)]]) + cov = np.atleast_2d(cov) + np.eye(x.shape[1]) * self.regularization + self.inv_cov_ = np.linalg.pinv(cov) + scores = np.array([self._score(row) for row in x]) + self.threshold_ = max(float(np.quantile(scores, self.quantile)), 1e-9) + return self + + def evaluate(self, feature: Iterable[float]) -> DriftResult: + if self.mean_ is None or self.inv_cov_ is None or self.threshold_ is None: + raise RuntimeError("drift monitor has not been fit") + score = self._score(np.asarray(list(feature), dtype=float)) + return DriftResult(round(score, 6), score > self.threshold_, round(self.threshold_, 6)) + + def _score(self, row: np.ndarray) -> float: + assert self.mean_ is not None and self.inv_cov_ is not None + delta = row - self.mean_ + return float(np.sqrt(max(0.0, delta @ self.inv_cov_ @ delta))) diff --git a/synapdrive_ai/neuro/eeg_loader.py b/synapdrive_ai/neuro/eeg_loader.py index b99fee2..9954f73 100644 --- a/synapdrive_ai/neuro/eeg_loader.py +++ b/synapdrive_ai/neuro/eeg_loader.py @@ -51,7 +51,7 @@ def n_samples(self) -> int: def summary(self) -> str: return ( f"EEGRecording: {self.n_channels} channels × {self.n_samples} samples " - f"@ {self.sampling_rate} Hz ({self.duration_s:.1f}s) — {self.source_file}" + f"@ {self.sampling_rate} Hz ({self.duration_s:.1f}s): {self.source_file}" ) @@ -143,7 +143,8 @@ def _field(offset: int, width: int, sig_idx: int) -> str: data_raw = raw[data_start:] total_per_record = sum(n_samples_per_record) - records_available = len(data_raw) // (total_per_record * 2) + sample_width = 3 if path.suffix.lower() == ".bdf" else 2 + records_available = len(data_raw) // (total_per_record * sample_width) if n_records < 0: n_records = records_available n_records = min(n_records, records_available) @@ -153,10 +154,21 @@ def _field(offset: int, width: int, sig_idx: int) -> str: for _ in range(n_records): for sig_idx in range(n_signals): ns = n_samples_per_record[sig_idx] - chunk = data_raw[offset_bytes : offset_bytes + ns * 2] - samples = struct.unpack(f"<{ns}h", chunk) + width = ns * sample_width + chunk = data_raw[offset_bytes : offset_bytes + width] + if sample_width == 2: + samples = struct.unpack(f"<{ns}h", chunk) + else: + raw24 = np.frombuffer(chunk, dtype=np.uint8).reshape(-1, 3) + values = ( + raw24[:, 0].astype(np.int32) + | (raw24[:, 1].astype(np.int32) << 8) + | (raw24[:, 2].astype(np.int32) << 16) + ) + values[values >= 0x800000] -= 0x1000000 + samples = values.tolist() channels_raw[sig_idx].extend(samples) - offset_bytes += ns * 2 + offset_bytes += width data = np.array( [ diff --git a/synapdrive_ai/neuro/errp.py b/synapdrive_ai/neuro/errp.py new file mode 100644 index 0000000..7b98799 --- /dev/null +++ b/synapdrive_ai/neuro/errp.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, Sequence + +import numpy as np + + +@dataclass(frozen=True) +class ErrPFeatures: + early_mean: float + negative_peak: float + late_mean: float + positive_peak: float + + def as_array(self) -> np.ndarray: + return np.array( + [ + self.early_mean, + self.negative_peak, + self.late_mean, + self.positive_peak, + ], + dtype=float, + ) + + +class ErrPFeatureExtractor: + """Extracts event-related potential features from an event-locked epoch. + + Windows are relative to event onset. This is a generic feature extractor, not a + validated ErrP detector. Classification requires user-supplied labeled calibration. + """ + + def __init__(self, sampling_rate: float, event_index: int = 0) -> None: + self.sampling_rate = float(sampling_rate) + self.event_index = int(event_index) + + def extract(self, epoch: Sequence[float] | np.ndarray) -> ErrPFeatures: + x = np.asarray(epoch, dtype=float) + if x.ndim != 1: + raise ValueError("epoch must be one-dimensional") + if len(x) < max(4, self.event_index + int(0.60 * self.sampling_rate)): + raise ValueError("epoch is too short for configured ErrP windows") + early = self._window(x, 0.10, 0.25) + negative = self._window(x, 0.20, 0.35) + late = self._window(x, 0.35, 0.60) + return ErrPFeatures( + early_mean=float(np.mean(early)), + negative_peak=float(np.min(negative)), + late_mean=float(np.mean(late)), + positive_peak=float(np.max(late)), + ) + + def _window(self, x: np.ndarray, start_s: float, end_s: float) -> np.ndarray: + start = self.event_index + int(start_s * self.sampling_rate) + end = self.event_index + int(end_s * self.sampling_rate) + return x[start:max(start + 1, end)] + + +class ErrPLDAClassifier: + """Small, inspectable LDA classifier trained only on supplied labeled epochs.""" + + def __init__(self, regularization: float = 1e-5) -> None: + self.regularization = float(regularization) + self.w_: np.ndarray | None = None + self.b_: float | None = None + + @property + def fitted(self) -> bool: + return self.w_ is not None and self.b_ is not None + + def fit( + self, + features: Iterable[Iterable[float]], + labels: Iterable[int], + ) -> "ErrPLDAClassifier": + x = np.asarray(list(features), dtype=float) + y = np.asarray(list(labels), dtype=int) + if x.ndim != 2 or len(x) != len(y) or len(x) < 4: + raise ValueError("features and labels must contain at least four aligned samples") + if set(np.unique(y)) != {0, 1}: + raise ValueError("labels must contain both classes 0 and 1") + x0, x1 = x[y == 0], x[y == 1] + m0, m1 = np.mean(x0, axis=0), np.mean(x1, axis=0) + c0 = np.cov(x0, rowvar=False) if len(x0) > 1 else np.eye(x.shape[1]) + c1 = np.cov(x1, rowvar=False) if len(x1) > 1 else np.eye(x.shape[1]) + numerator = ( + (len(x0) - 1) * np.atleast_2d(c0) + + (len(x1) - 1) * np.atleast_2d(c1) + ) + cov = numerator / max(len(x) - 2, 1) + cov = cov + np.eye(x.shape[1]) * self.regularization + inv = np.linalg.pinv(cov) + self.w_ = inv @ (m1 - m0) + p0, p1 = len(x0) / len(x), len(x1) / len(x) + self.b_ = float(-0.5 * (m1 + m0) @ self.w_ + np.log(max(p1, 1e-12) / max(p0, 1e-12))) + return self + + def predict_proba(self, feature: Iterable[float]) -> float: + if not self.fitted: + raise RuntimeError("classifier has not been fit") + x = np.asarray(list(feature), dtype=float) + assert self.w_ is not None and self.b_ is not None + z = float(np.clip(x @ self.w_ + self.b_, -60, 60)) + return float(1.0 / (1.0 + np.exp(-z))) + + def predict(self, feature: Iterable[float], threshold: float = 0.5) -> int: + return int(self.predict_proba(feature) >= threshold) diff --git a/synapdrive_ai/neuro/fusion.py b/synapdrive_ai/neuro/fusion.py new file mode 100644 index 0000000..3c799aa --- /dev/null +++ b/synapdrive_ai/neuro/fusion.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Iterable, Mapping + +from synapdrive_ai.neuro.uncertainty import estimate_uncertainty, normalize_distribution + + +@dataclass(frozen=True) +class ModalityEvidence: + modality: str + probabilities: Mapping[str, float] + reliability: float + + +@dataclass(frozen=True) +class FusionResult: + probabilities: Dict[str, float] + intent: str + confidence: float + uncertainty: float + weights: Dict[str, float] + + +class WeightedEvidenceFusion: + """Reliability-weighted late fusion for independent modality decoders.""" + + def fuse(self, evidence: Iterable[ModalityEvidence]) -> FusionResult: + items = list(evidence) + if not items: + raise ValueError("at least one modality is required") + labels = sorted({label for item in items for label in item.probabilities}) + accum = {label: 0.0 for label in labels} + weights: Dict[str, float] = {} + total_weight = 0.0 + members = [] + for item in items: + probs = normalize_distribution(item.probabilities) + members.append(probs) + w = max(0.0, min(1.0, float(item.reliability))) + weights[item.modality] = w + total_weight += w + for label in labels: + accum[label] += w * probs.get(label, 0.0) + if total_weight <= 0: + raise ValueError("at least one modality must have positive reliability") + fused = normalize_distribution({k: v / total_weight for k, v in accum.items()}) + uncertainty = estimate_uncertainty(fused, members) + return FusionResult( + probabilities=fused, + intent=uncertainty.top_label, + confidence=uncertainty.top_probability, + uncertainty=uncertainty.combined, + weights=weights, + ) diff --git a/synapdrive_ai/neuro/session_analyzer.py b/synapdrive_ai/neuro/session_analyzer.py index dfbd0a0..2338294 100644 --- a/synapdrive_ai/neuro/session_analyzer.py +++ b/synapdrive_ai/neuro/session_analyzer.py @@ -4,15 +4,17 @@ import time from dataclasses import asdict, dataclass, field from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Mapping, Optional import numpy as np -from synapdrive_ai.bci.intent_generator import generate_intent from synapdrive_ai.neuro.band_analyzer import BandPowerAnalyzer, BandPowerResult from synapdrive_ai.neuro.eeg_loader import EEGRecording +from synapdrive_ai.neuro.signal_quality import SignalQualityAnalyzer from synapdrive_ai.pipeline import SynapDrivePipeline +EpochDecoder = Callable[[np.ndarray, Mapping[str, Any]], Mapping[str, Any]] + @dataclass class EpochResult: @@ -50,7 +52,7 @@ class SessionReport: def summary(self) -> str: lines = [ - f"Session Analysis — {self.source_file}", + f"Session Analysis: {self.source_file}", f" Channel: {self.channel}", f" Window: {self.window_s}s step: {self.step_s}s", f" Epochs: {self.n_epochs}", @@ -103,7 +105,11 @@ def save_csv(self, path: str | Path) -> None: class SessionAnalyzer: """ - Runs a sliding-window analysis of an EEGRecording through the pipeline. + Sliding-window EEG feature analysis with an explicit decoder boundary. + + Without ``decoder``, spectral features are analysis-only and are deliberately blocked + from actuation. A supplied decoder must return an intent packet containing at least + ``intent`` and ``confidence``; its outputs still pass through runtime governance. """ def __init__( @@ -114,12 +120,14 @@ def __init__( image_label: Optional[str] = None, simulate_delay: bool = False, pipeline: Optional[SynapDrivePipeline] = None, + decoder: Optional[EpochDecoder] = None, ) -> None: self.channel = channel self.window_s = float(window_s) self.step_s = float(step_s) self.image_label = image_label self._pipe = pipeline or SynapDrivePipeline(simulate_delay=simulate_delay) + self.decoder = decoder self._band_analyzer: Optional[BandPowerAnalyzer] = None def run(self, recording: EEGRecording) -> SessionReport: @@ -146,8 +154,11 @@ def run(self, recording: EEGRecording) -> SessionReport: t_start = start / sr t_end = (start + window_samples) / sr + quality_result = SignalQualityAnalyzer(sr).analyze(epoch_signal) band_result = self._band_analyzer.analyze(epoch_signal) - pipeline_out = self._run_epoch_through_pipeline(band_result) + pipeline_out = self._run_epoch_through_pipeline( + epoch_signal, sr, band_result, quality_result.score, quality_result.state + ) intent_out = pipeline_out.get("intent", {}) or {} eval_out = pipeline_out.get("evaluation", {}) or {} @@ -175,26 +186,54 @@ def run(self, recording: EEGRecording) -> SessionReport: return self._build_report(recording, ch_name, epochs) - def _run_epoch_through_pipeline(self, band_result: BandPowerResult) -> Dict[str, Any]: - if band_result.intent_class == "motor": - if band_result.engagement_ratio > 2.0: - text = "move forward" - elif band_result.engagement_ratio > 1.2: - text = "move left" - else: - text = "move right" - elif band_result.intent_class == "cognitive": - text = "calculate" if band_result.cognitive_ratio > 2.0 else "recall" + def _run_epoch_through_pipeline( + self, + epoch_signal: np.ndarray, + sampling_rate: float, + band_result: BandPowerResult, + signal_quality: float = 1.0, + quality_state: str = "good", + ) -> Dict[str, Any]: + if self.decoder is None: + packet: Dict[str, Any] = { + "intent": "unknown", + "confidence": 0.0, + "source": f"eeg_band_features/{band_result.intent_class}", + "memory_context": [], + "band_power": band_result.relative, + "engagement_ratio": band_result.engagement_ratio, + "cognitive_ratio": band_result.cognitive_ratio, + "heuristic_score": band_result.confidence, + "confidence_semantics": "no-decoder", + "inference_authority": "analysis-only-band-features", + "analysis_only": True, + "neural_decode_performed": False, + "signal_quality": float(signal_quality), + "signal_quality_state": quality_state, + } else: - text = "unknown" - - base_packet = dict(generate_intent(text)) - base_packet["confidence"] = band_result.confidence - base_packet["source"] = f"eeg_band/{band_result.intent_class}" - base_packet["band_power"] = band_result.relative - base_packet["engagement_ratio"] = band_result.engagement_ratio + epoch_data = np.asarray(epoch_signal, dtype=float).reshape(1, -1) + decoder_metadata = { + "sampling_rate": float(sampling_rate), + "n_channels": 1, + "n_samples": int(epoch_data.shape[1]), + "signal_quality": float(signal_quality), + "signal_quality_state": quality_state, + } + packet = dict(self.decoder(epoch_data.copy(), decoder_metadata)) + packet.setdefault("intent", "unknown") + packet.setdefault("confidence", 0.0) + packet.setdefault("source", "external-epoch-decoder") + packet.setdefault("memory_context", []) + packet.setdefault("inference_authority", "external-decoder") + packet.setdefault("neural_decode_performed", True) + packet["signal_quality"] = float(signal_quality) + packet["signal_quality_state"] = quality_state + packet["band_power"] = band_result.relative + packet["spectral_feature_class"] = band_result.intent_class + packet["spectral_heuristic_score"] = band_result.confidence - return self._pipe.run_intent_packet(base_packet, image_label=self.image_label) + return self._pipe.run_intent_packet(packet, image_label=self.image_label) def _build_report( self, diff --git a/synapdrive_ai/neuro/signal_quality.py b/synapdrive_ai/neuro/signal_quality.py new file mode 100644 index 0000000..f2365a0 --- /dev/null +++ b/synapdrive_ai/neuro/signal_quality.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Tuple + +import numpy as np + + +@dataclass(frozen=True) +class SignalQualityReport: + score: float + state: str + finite_fraction: float + flatline_fraction: float + clipping_fraction: float + dropout_fraction: float + line_noise_ratio: float + issues: Tuple[str, ...] = field(default_factory=tuple) + + def to_dict(self) -> Dict[str, float | str | list[str]]: + return { + "score": self.score, + "state": self.state, + "finite_fraction": self.finite_fraction, + "flatline_fraction": self.flatline_fraction, + "clipping_fraction": self.clipping_fraction, + "dropout_fraction": self.dropout_fraction, + "line_noise_ratio": self.line_noise_ratio, + "issues": list(self.issues), + } + + +class SignalQualityAnalyzer: + """Deterministic quality checks for one or more EEG-style channels. + + The score is an engineering heuristic for gating analysis. It is not a clinical + signal-quality index and is deliberately exposed as separate component metrics. + """ + + def __init__(self, sampling_rate: float, line_frequency: float = 60.0) -> None: + if sampling_rate <= 0: + raise ValueError("sampling_rate must be positive") + self.sampling_rate = float(sampling_rate) + self.line_frequency = float(line_frequency) + + def analyze(self, signal: np.ndarray) -> SignalQualityReport: + x = np.asarray(signal, dtype=float) + if x.ndim == 1: + x = x[None, :] + if x.size == 0 or x.shape[1] < 4: + return SignalQualityReport( + 0.0, + "invalid", + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + ("insufficient-samples",), + ) + + finite = np.isfinite(x) + finite_fraction = float(np.mean(finite)) + safe = np.where(finite, x, 0.0) + centered = safe - np.mean(safe, axis=1, keepdims=True) + scale = np.std(centered, axis=1, keepdims=True) + + # Flatline is assessed from near-zero first differences relative to channel scale. + diffs = np.abs(np.diff(safe, axis=1)) + eps = np.maximum(scale[:, :1] * 1e-5, 1e-12) + flatline_fraction = float(np.mean(diffs <= eps)) + + # Dropout catches exact-zero runs, a common stream failure representation. + dropout_fraction = float(np.mean(np.isclose(safe, 0.0, atol=1e-12))) + + # Clipping catches excessive samples pinned at the observed extrema. + clipping_parts = [] + for ch in safe: + lo, hi = float(np.min(ch)), float(np.max(ch)) + if np.isclose(lo, hi): + clipping_parts.append(1.0) + else: + clipping_parts.append(float(np.mean(np.isclose(ch, lo) | np.isclose(ch, hi)))) + clipping_fraction = float(np.mean(clipping_parts)) + + line_noise_ratio = self._line_noise_ratio(centered) + + issues: list[str] = [] + if finite_fraction < 0.999: + issues.append("non-finite-samples") + if flatline_fraction > 0.20: + issues.append("flatline") + if dropout_fraction > 0.20: + issues.append("dropout") + if clipping_fraction > 0.08: + issues.append("clipping") + if line_noise_ratio > 0.35: + issues.append("line-noise") + + penalty = ( + 0.45 * (1.0 - finite_fraction) + + 0.25 * min(1.0, flatline_fraction / 0.30) + + 0.15 * min(1.0, dropout_fraction / 0.30) + + 0.10 * min(1.0, clipping_fraction / 0.12) + + 0.05 * min(1.0, line_noise_ratio / 0.50) + ) + score = float(np.clip(1.0 - penalty, 0.0, 1.0)) + if score >= 0.80: + state = "good" + elif score >= 0.55: + state = "degraded" + elif score >= 0.35: + state = "unreliable" + else: + state = "invalid" + return SignalQualityReport( + round(score, 6), + state, + round(finite_fraction, 6), + round(flatline_fraction, 6), + round(clipping_fraction, 6), + round(dropout_fraction, 6), + round(line_noise_ratio, 6), + tuple(issues), + ) + + def _line_noise_ratio(self, centered: np.ndarray) -> float: + n = centered.shape[1] + if n < 8: + return 0.0 + freqs = np.fft.rfftfreq(n, d=1.0 / self.sampling_rate) + power = np.mean(np.abs(np.fft.rfft(centered, axis=1)) ** 2, axis=0) + total = float(np.sum(power[(freqs >= 1.0) & (freqs <= min(100.0, self.sampling_rate / 2))])) + if total <= 1e-18: + return 0.0 + width = max(0.75, self.sampling_rate / n * 2.0) + mask = np.abs(freqs - self.line_frequency) <= width + return float(np.clip(np.sum(power[mask]) / total, 0.0, 1.0)) diff --git a/synapdrive_ai/neuro/task_planner.py b/synapdrive_ai/neuro/task_planner.py index 5d14b2c..98dc9d0 100644 --- a/synapdrive_ai/neuro/task_planner.py +++ b/synapdrive_ai/neuro/task_planner.py @@ -140,6 +140,9 @@ def execute(self, plan: TaskPlan) -> PlanTrace: def _execute_step(self, idx: int, step: TaskStep) -> StepTrace: base_packet = generate_intent(step.intent_text) + # A task-specific threshold is a stricter pre-action requirement. The canonical + # runtime consumes this field before the router is reachable. + base_packet["required_confidence"] = step.min_confidence out = self._pipe.run_intent_packet(base_packet, image_label=step.image_label) intent_out = out.get("intent", {}) or {} @@ -150,7 +153,7 @@ def _execute_step(self, idx: int, step: TaskStep) -> StepTrace: eval_score = float(eval_out.get("score", 0.0)) fallback_applied: Optional[FallbackPolicy] = None - if pipeline_status == "success" and confidence < step.min_confidence: + if pipeline_status == "blocked" and "confidence-below-policy" in str(block_reason): fallback_applied = step.fallback if step.fallback == "freeze": pipeline_status = "deferred" diff --git a/synapdrive_ai/neuro/uncertainty.py b/synapdrive_ai/neuro/uncertainty.py new file mode 100644 index 0000000..2968a18 --- /dev/null +++ b/synapdrive_ai/neuro/uncertainty.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Dict, Iterable, Mapping, Sequence + +import numpy as np + + +@dataclass(frozen=True) +class UncertaintyEstimate: + entropy: float + margin_uncertainty: float + disagreement: float + combined: float + top_label: str + top_probability: float + + def to_dict(self) -> Dict[str, float | str]: + return { + "entropy": self.entropy, + "margin_uncertainty": self.margin_uncertainty, + "disagreement": self.disagreement, + "combined": self.combined, + "top_label": self.top_label, + "top_probability": self.top_probability, + } + + +def _numeric_value(value: object, *, field: str) -> float: + if isinstance(value, (int, float, str)): + return float(value) + raise TypeError(f"{field} must be numeric") + + +def normalize_distribution(values: Mapping[str, float]) -> Dict[str, float]: + clean = {str(k): max(0.0, float(v)) for k, v in values.items()} + total = sum(clean.values()) + if total <= 0: + if not clean: + return {"unknown": 1.0} + uniform = 1.0 / len(clean) + return {k: uniform for k in clean} + return {k: v / total for k, v in clean.items()} + + +def packet_distribution(packet: Mapping[str, object]) -> Dict[str, float]: + provided = packet.get("probabilities") + if isinstance(provided, Mapping) and provided: + parsed = { + str(k): _numeric_value(v, field=f"probabilities[{k!r}]") + for k, v in provided.items() + } + return normalize_distribution(parsed) + + intent = str(packet.get("intent") or "unknown") + confidence = max( + 0.0, + min( + 1.0, + _numeric_value( + packet.get("confidence", 0.0), + field="confidence", + ), + ), + ) + + if intent == "unknown": + return { + "unknown": max(confidence, 0.5), + "other": 1.0 - max(confidence, 0.5), + } + + return normalize_distribution( + { + intent: confidence, + "unknown": 1.0 - confidence, + } + ) + + +def estimate_uncertainty( + probabilities: Mapping[str, float], + ensemble_probabilities: Sequence[Mapping[str, float]] | None = None, +) -> UncertaintyEstimate: + probs = normalize_distribution(probabilities) + labels = sorted(probs) + arr = np.array([probs[k] for k in labels], dtype=float) + + if len(arr) <= 1: + entropy = 0.0 + margin_u = 0.0 + else: + raw_entropy = -float( + np.sum(arr * np.log(np.clip(arr, 1e-12, 1.0))) + ) + entropy = raw_entropy / math.log(len(arr)) + sorted_probs = np.sort(arr)[::-1] + margin_u = 1.0 - float( + sorted_probs[0] - sorted_probs[1] + ) + + disagreement = 0.0 + if ensemble_probabilities and len(ensemble_probabilities) > 1: + union = sorted( + { + k + for member in ensemble_probabilities + for k in member + } + ) + matrix = np.array( + [ + [ + normalize_distribution(member).get(k, 0.0) + for k in union + ] + for member in ensemble_probabilities + ], + dtype=float, + ) + disagreement = float( + np.clip( + np.mean(np.std(matrix, axis=0)) * 2.0, + 0.0, + 1.0, + ) + ) + + combined = float( + np.clip( + 0.55 * entropy + + 0.30 * margin_u + + 0.15 * disagreement, + 0.0, + 1.0, + ) + ) + + top_label = max( + probs.items(), + key=lambda item: item[1], + )[0] + + return UncertaintyEstimate( + entropy=round(entropy, 6), + margin_uncertainty=round(margin_u, 6), + disagreement=round(disagreement, 6), + combined=round(combined, 6), + top_label=top_label, + top_probability=round(probs[top_label], 6), + ) + + +class ReliabilityCalibrator: + """Histogram reliability calibrator with deterministic binning. + + Fit using model confidence and binary correctness. Transform maps raw + confidence to observed empirical accuracy in its bin. No model claims + are made before fit(). + """ + + def __init__(self, n_bins: int = 10) -> None: + if n_bins < 2: + raise ValueError("n_bins must be >= 2") + self.n_bins = int(n_bins) + self._rates: np.ndarray | None = None + self._counts: np.ndarray | None = None + + @property + def fitted(self) -> bool: + return self._rates is not None + + def fit( + self, + confidence: Iterable[float], + correct: Iterable[bool], + ) -> "ReliabilityCalibrator": + conf = np.clip( + np.asarray(list(confidence), dtype=float), + 0.0, + 1.0, + ) + ok = np.asarray(list(correct), dtype=float) + + if conf.size == 0 or conf.size != ok.size: + raise ValueError( + "confidence and correct must be non-empty and equal length" + ) + + indices = np.minimum( + (conf * self.n_bins).astype(int), + self.n_bins - 1, + ) + + rates = np.zeros(self.n_bins, dtype=float) + counts = np.zeros(self.n_bins, dtype=int) + global_rate = float(np.mean(ok)) + + for idx in range(self.n_bins): + mask = indices == idx + counts[idx] = int(np.sum(mask)) + rates[idx] = ( + float(np.mean(ok[mask])) + if counts[idx] + else global_rate + ) + + self._rates = rates + self._counts = counts + return self + + def transform(self, confidence: float) -> float: + if self._rates is None: + raise RuntimeError("calibrator has not been fit") + + value = float( + np.clip( + confidence, + 0.0, + 1.0, + ) + ) + idx = min( + int(value * self.n_bins), + self.n_bins - 1, + ) + + return float( + np.clip( + self._rates[idx], + 0.0, + 1.0, + ) + ) \ No newline at end of file diff --git a/synapdrive_ai/pipeline.py b/synapdrive_ai/pipeline.py index 233a1c7..1e6efc6 100644 --- a/synapdrive_ai/pipeline.py +++ b/synapdrive_ai/pipeline.py @@ -1,78 +1,101 @@ from __future__ import annotations -import random -from typing import Any, Dict, Optional +from typing import Any, Dict, Mapping, Optional from synapdrive_ai.action.decision_router import DecisionRouter from synapdrive_ai.agi.cognitive_optimizer import CognitiveOptimizer -from synapdrive_ai.agi.core_reasoning import AGICoreReasoner +from synapdrive_ai.agi.core_reasoning import LabeledSignalMapper from synapdrive_ai.agi.meta_evaluator import MetaEvaluator from synapdrive_ai.assurance import AssuranceMonitor +from synapdrive_ai.assurance.evidence_ledger import SessionEvidenceLedger from synapdrive_ai.bci.intent_generator import generate_intent from synapdrive_ai.bci.signal_simulator import BrainSignalSimulator +from synapdrive_ai.cognition.planner import CandidateAction, CounterfactualPlanner +from synapdrive_ai.cognition.shared_autonomy import SharedAutonomyArbiter from synapdrive_ai.memory.episodic_memory import EpisodicMemory +from synapdrive_ai.memory.evidence_memory import EvidenceMemory +from synapdrive_ai.neuro.uncertainty import estimate_uncertainty, packet_distribution +from synapdrive_ai.runtime.governed_runtime import GovernedRuntime +from synapdrive_ai.runtime.reality import RealityReconciler from synapdrive_ai.safety.safety_guard import SafetyGuard from synapdrive_ai.vision.visual_inference import VisualInferenceEngine class SynapDrivePipeline: - """ - Canonical end-to-end simulation pipeline. + """Canonical governed simulation pipeline. - Wiring: input → intent → optimizer (memory + vision) → safety → actuation → evaluation + Flow: + input -> intent -> context -> uncertainty -> policy/permission/world assessment + -> legacy safety checks -> simulated actuation or fail-closed abstention + -> reality reconciliation -> evidence-gated memory -> assurance + hash-chain evidence - Args: - simulate_delay: If True, actuation sleeps to emulate real timing. - Set False for tests, replay, and the Flask dashboard. + No element of this class constitutes medical-device or physical-actuation validation. """ - def __init__(self, simulate_delay: bool = True) -> None: + def __init__(self, simulate_delay: bool = True, runtime: GovernedRuntime | None = None) -> None: self.simulator = BrainSignalSimulator() - self.reasoner = AGICoreReasoner() - + self.reasoner = LabeledSignalMapper() self.memory = EpisodicMemory() + self.evidence_memory = EvidenceMemory() self.visual = VisualInferenceEngine() - self.optimizer = CognitiveOptimizer() - # Share the same memory/vision instances so the optimizer sees live state self.optimizer.memory = self.memory self.optimizer.visual = self.visual - self.guard = SafetyGuard() self.router = DecisionRouter(simulate_delay=simulate_delay) self.evaluator = MetaEvaluator() self.assurance = AssuranceMonitor() - - # ------------------------------------------------------------------ - # Public entrypoints - # ------------------------------------------------------------------ + self.runtime = runtime or GovernedRuntime() + self.reconciler = RealityReconciler() + self.evidence = SessionEvidenceLedger() def run_text_command( - self, command_text: str, image_label: Optional[str] = None + self, + command_text: str, + image_label: Optional[str] = None, + feedback: Mapping[str, Any] | None = None, ) -> Dict[str, Any]: - """Text path: decoded intent or operator input (e.g. 'move left', 'stop').""" intent_packet = generate_intent(command_text) - return self.run_intent_packet(intent_packet, image_label=image_label) + return self.run_intent_packet(intent_packet, image_label=image_label, feedback=feedback) def run_signal_event( - self, label: Optional[str] = None, image_label: Optional[str] = None + self, + label: Optional[str] = None, + image_label: Optional[str] = None, + feedback: Mapping[str, Any] | None = None, ) -> Dict[str, Any]: - """Signal path: simulated EEG-like waveform event.""" - label = label or random.choice( - ["left_arm", "right_arm", "walk", "stop", "calculate", "recall", "explore"] - ) + label = label or self.simulator.random_label() signal = self._generate_signal_for_label(label) intent_packet = self.reasoner.receive_signal(label, signal) - return self.run_intent_packet(intent_packet, image_label=image_label) + return self.run_intent_packet(intent_packet, image_label=image_label, feedback=feedback) def run_intent_packet( - self, intent_packet: Dict[str, Any], image_label: Optional[str] = None + self, + intent_packet: Dict[str, Any], + image_label: Optional[str] = None, + feedback: Mapping[str, Any] | None = None, ) -> Dict[str, Any]: + return self._run_common(intent_packet, image_label=image_label, feedback=feedback) + + def plan_shared_action( + self, + *, + user_intent: str, + user_confidence: float, + machine_candidates: list[CandidateAction], + goal, + ): + """Rank bounded machine alternatives against the runtime world model. + + This method proposes only. It does not actuate the selected action. """ - Primary entrypoint for integrations and replay. - Accepts a pre-built intent packet and runs it through the full pipeline. - """ - return self._run_common(intent_packet, image_label=image_label) + arbiter = SharedAutonomyArbiter(CounterfactualPlanner(self.runtime.world_model)) + return arbiter.propose( + user_intent=user_intent, + user_confidence=user_confidence, + machine_candidates=machine_candidates, + goal=goal, + ) def get_action_log(self): return self.router.get_action_log() @@ -81,11 +104,14 @@ def get_assurance_log(self): return [receipt.to_dict() for receipt in self.assurance.history()] def get_assurance_report(self) -> Dict[str, Any]: - return self.assurance.health_report() - - # ------------------------------------------------------------------ - # Internals - # ------------------------------------------------------------------ + report = dict(self.assurance.health_report()) + report["evidence_chain_entries"] = len(self.evidence.chain.entries()) + report["evidence_chain_valid"] = self.evidence.verify(self.evidence.chain.entries()) + report["validated_memories"] = sum( + 1 for record in self.evidence_memory.all() if record.state == "validated" + ) + report["quarantined_memories"] = len(self.evidence_memory.quarantined()) + return report def _generate_signal_for_label(self, label: str): patterns = { @@ -102,63 +128,155 @@ def _generate_signal_for_label(self, label: str): return self.simulator.generate_waveform(patterns[label]) def _run_common( - self, intent_packet: Dict[str, Any], image_label: Optional[str] + self, + intent_packet: Dict[str, Any], + image_label: Optional[str], + feedback: Mapping[str, Any] | None, ) -> Dict[str, Any]: optimized = self.optimizer.optimize(intent_packet, image_label=image_label) + distribution = packet_distribution(optimized) + uncertainty = estimate_uncertainty(distribution) + optimized["probabilities"] = distribution + optimized["uncertainty"] = float(optimized.get("uncertainty", uncertainty.combined)) + optimized["uncertainty_detail"] = uncertainty.to_dict() + + runtime_decision = self.runtime.assess(optimized) + legacy_safe, legacy_reason = self.guard.evaluate_safety(optimized) + allowed = runtime_decision.allowed and legacy_safe + block_reason = runtime_decision.reason if not runtime_decision.allowed else legacy_reason - is_safe, reason = self.guard.evaluate_safety(optimized) - if not is_safe: + if not allowed: result = { "status": "blocked", "intent": optimized.get("intent", "unknown"), "confidence": optimized.get("confidence", 0.0), "duration": 0.0, + "fallback_action": runtime_decision.fallback_action, } evaluation = { "score": 0.0, - "total_actions": 0, + "total_actions": len(self.evaluator.scores), "avg_score": 0.0, } + reality = self.reconciler.reconcile( + predicted_success=0.0, + result_packet=result, + feedback=feedback, + errp_threshold=self.runtime.policy.errp_contradiction_threshold, + ) receipt = self.assurance.record_cycle( intent_packet=optimized, image_label=image_label, safety_allowed=False, - safety_reason=reason, + safety_reason=block_reason, result_packet=result, evaluation=evaluation, executed=False, ) + evidence_entry = self._record_evidence( + optimized, runtime_decision.to_dict(), result, reality.to_dict(), receipt.to_dict() + ) return { "status": "blocked", - "reason": reason, + "reason": block_reason, "intent": optimized, "result": result, "evaluation": evaluation, + "runtime": runtime_decision.to_dict(), + "reality": reality.to_dict(), + "memory": {"state": "not-recorded", "reason": "blocked-before-actuation"}, "assurance": receipt.to_dict(), + "evidence": evidence_entry.to_dict(), } result = self.router.route(optimized) + world_prediction = self.runtime.world_model.predict( + str(optimized.get("intent", "unknown")) + ) + if result.get("status") == "success" and world_prediction.feasible: + self.runtime.world_model.commit(world_prediction) + evaluation = self.evaluator.evaluate(optimized, result) + predicted_success = max( + 0.0, + min(1.0, 1.0 - runtime_decision.assessment.predicted_risk), + ) + reality = self.reconciler.reconcile( + predicted_success=predicted_success, + result_packet=result, + feedback=feedback, + errp_threshold=self.runtime.policy.errp_contradiction_threshold, + ) - try: + # Legacy memory remains for compatibility but only aligned successful outcomes reinforce it. + if reality.aligned and result.get("status") == "success": self.memory.record_episode(optimized, result) - except Exception: - pass - evaluation = self.evaluator.evaluate(optimized, result) receipt = self.assurance.record_cycle( intent_packet=optimized, image_label=image_label, safety_allowed=True, - safety_reason=reason, + safety_reason=legacy_reason, result_packet=result, evaluation=evaluation, executed=True, ) - + cycle_id = receipt.receipt_id + memory_record = self.evidence_memory.record( + cycle_id=cycle_id, + intent=str(optimized.get("intent", "unknown")), + confidence=float(optimized.get("confidence", 0.0)), + outcome=str(result.get("status", "unknown")), + aligned=reality.aligned and result.get("status") == "success", + reason=reality.reason, + ) + evidence_entry = self._record_evidence( + optimized, runtime_decision.to_dict(), result, reality.to_dict(), receipt.to_dict() + ) return { "status": result["status"], "intent": optimized, "result": result, "evaluation": evaluation, + "runtime": runtime_decision.to_dict(), + "reality": reality.to_dict(), + "memory": memory_record.to_dict(), "assurance": receipt.to_dict(), + "evidence": evidence_entry.to_dict(), + "world_state": dict(self.runtime.world_model.state), + } + + def _record_evidence( + self, + intent: Dict[str, Any], + runtime: Dict[str, Any], + result: Dict[str, Any], + reality: Dict[str, Any], + assurance: Dict[str, Any], + ): + # Deliberately exclude wall-clock timestamps so cycle content remains comparable. + payload = { + "schema": "synapdrive.cycle-evidence.v2", + "intent": { + "intent": intent.get("intent"), + "confidence": intent.get("confidence"), + "source": intent.get("source"), + "inference_authority": intent.get("inference_authority", "unspecified"), + "confidence_semantics": intent.get("confidence_semantics", "unspecified"), + "neural_decode_performed": bool(intent.get("neural_decode_performed", False)), + "analysis_only": bool(intent.get("analysis_only", False)), + "signal_quality": intent.get("signal_quality", 1.0), + "uncertainty": intent.get("uncertainty"), + "required_confidence": intent.get("required_confidence"), + }, + "runtime": runtime, + "result": { + "status": result.get("status"), + "intent": result.get("intent"), + "confidence": result.get("confidence"), + "fallback_action": result.get("fallback_action"), + }, + "reality": reality, + "assurance_receipt_id": assurance.get("receipt_id"), + "assurance_passed": assurance.get("passed"), } + return self.evidence.record(payload) diff --git a/synapdrive_ai/runtime/__init__.py b/synapdrive_ai/runtime/__init__.py new file mode 100644 index 0000000..a099b09 --- /dev/null +++ b/synapdrive_ai/runtime/__init__.py @@ -0,0 +1,4 @@ +from .contracts import PreActionDecision, RealityVerdict, RuntimeAssessment +from .governed_runtime import GovernedRuntime + +__all__ = ["GovernedRuntime", "PreActionDecision", "RealityVerdict", "RuntimeAssessment"] diff --git a/synapdrive_ai/runtime/contracts.py b/synapdrive_ai/runtime/contracts.py new file mode 100644 index 0000000..effa691 --- /dev/null +++ b/synapdrive_ai/runtime/contracts.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, Tuple + + +@dataclass(frozen=True) +class RuntimeAssessment: + confidence: float + required_confidence: float + uncertainty: float + signal_quality: float + predicted_risk: float + permission_allowed: bool + drift_score: float = 0.0 + issues: Tuple[str, ...] = field(default_factory=tuple) + + @property + def admissible(self) -> bool: + return not self.issues + + def to_dict(self) -> Dict[str, Any]: + return { + "confidence": self.confidence, + "required_confidence": self.required_confidence, + "uncertainty": self.uncertainty, + "signal_quality": self.signal_quality, + "predicted_risk": self.predicted_risk, + "permission_allowed": self.permission_allowed, + "drift_score": self.drift_score, + "admissible": self.admissible, + "issues": list(self.issues), + } + + +@dataclass(frozen=True) +class PreActionDecision: + action: str + allowed: bool + reason: str + fallback_action: str + assessment: RuntimeAssessment + + def to_dict(self) -> Dict[str, Any]: + return { + "action": self.action, + "allowed": self.allowed, + "reason": self.reason, + "fallback_action": self.fallback_action, + "assessment": self.assessment.to_dict(), + } + + +@dataclass(frozen=True) +class RealityVerdict: + aligned: bool + outcome: str + reason: str + prediction_error: float + user_error_probability: float + + def to_dict(self) -> Dict[str, Any]: + return { + "aligned": self.aligned, + "outcome": self.outcome, + "reason": self.reason, + "prediction_error": self.prediction_error, + "user_error_probability": self.user_error_probability, + } diff --git a/synapdrive_ai/runtime/governed_runtime.py b/synapdrive_ai/runtime/governed_runtime.py new file mode 100644 index 0000000..eec0715 --- /dev/null +++ b/synapdrive_ai/runtime/governed_runtime.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from typing import Any, Mapping + +from synapdrive_ai.cognition.world_model import WorldModel, default_simulation_world_model +from synapdrive_ai.governance.permission import PermissionGate +from synapdrive_ai.governance.policy import RuntimePolicy +from synapdrive_ai.neuro.uncertainty import estimate_uncertainty, packet_distribution +from synapdrive_ai.runtime.contracts import PreActionDecision, RuntimeAssessment + + +class GovernedRuntime: + """Fail-closed pre-action arbiter for provenance, uncertainty, quality and risk.""" + + def __init__( + self, + policy: RuntimePolicy | None = None, + permission_gate: PermissionGate | None = None, + world_model: WorldModel | None = None, + ) -> None: + self.policy = policy or RuntimePolicy() + self.permissions = permission_gate or PermissionGate() + self.world_model = world_model or default_simulation_world_model() + + def assess(self, packet: Mapping[str, Any]) -> PreActionDecision: + action = str(packet.get("intent") or "unknown") + confidence = max(0.0, min(1.0, float(packet.get("confidence", 0.0)))) + requested_min_confidence = max( + self.policy.min_confidence, + max(0.0, float(packet.get("required_confidence", 0.0))), + ) + uncertainty = packet.get("uncertainty") + if uncertainty is None: + uncertainty = estimate_uncertainty(packet_distribution(packet)).combined + uncertainty = max(0.0, min(1.0, float(uncertainty))) + quality = max(0.0, min(1.0, float(packet.get("signal_quality", 1.0)))) + drift_score = max(0.0, float(packet.get("drift_score", 0.0))) + permission = self.permissions.evaluate(action) + prediction = self.world_model.predict(action) + predicted_risk = max(float(packet.get("predicted_risk", 0.0)), prediction.predicted_risk) + + issues: list[str] = [] + if bool(packet.get("analysis_only", False)): + issues.append("analysis-only-inference") + if action == "unknown": + issues.append("unknown-intent") + if confidence < requested_min_confidence: + issues.append("confidence-below-policy") + if uncertainty > self.policy.max_uncertainty: + issues.append("uncertainty-above-policy") + if quality < self.policy.min_signal_quality: + issues.append("signal-quality-below-policy") + if predicted_risk > self.policy.max_predicted_risk: + issues.append("predicted-risk-above-policy") + if drift_score > self.policy.max_drift_score: + issues.append("decoder-drift-above-policy") + if not permission.allowed: + issues.append("permission-denied") + if not prediction.feasible: + issues.append("world-model-precondition-failed") + + # Stable ordering with no duplicate issue labels keeps replay evidence deterministic. + issues = list(dict.fromkeys(issues)) + assessment = RuntimeAssessment( + round(confidence, 6), + round(requested_min_confidence, 6), + round(uncertainty, 6), + round(quality, 6), + round(predicted_risk, 6), + permission.allowed, + round(drift_score, 6), + tuple(issues), + ) + allowed = not issues + reason = "runtime policy passed" if allowed else "; ".join(issues) + return PreActionDecision( + action, + allowed, + reason, + self.policy.safe_fallback_action, + assessment, + ) diff --git a/synapdrive_ai/runtime/reality.py b/synapdrive_ai/runtime/reality.py new file mode 100644 index 0000000..59c9deb --- /dev/null +++ b/synapdrive_ai/runtime/reality.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from typing import Any, Mapping + +from synapdrive_ai.runtime.contracts import RealityVerdict + + +class RealityReconciler: + """Compare predicted software outcome with result and user-error feedback.""" + + def reconcile( + self, + *, + predicted_success: float, + result_packet: Mapping[str, Any], + feedback: Mapping[str, Any] | None = None, + errp_threshold: float = 0.70, + ) -> RealityVerdict: + observed_success = str(result_packet.get("status", "")) in { + "success", + "executed", + } + predicted_success = max(0.0, min(1.0, float(predicted_success))) + error_probability = 0.0 + if feedback: + raw_probability = float(feedback.get("errp_probability", 0.0)) + error_probability = max(0.0, min(1.0, raw_probability)) + explicit = feedback.get("accepted") + if explicit is False: + error_probability = 1.0 + prediction_error = abs(predicted_success - float(observed_success)) + if error_probability >= errp_threshold: + return RealityVerdict( + False, + "contradicted", + "user/error-potential feedback contradicts action", + round(prediction_error, 6), + error_probability, + ) + if predicted_success >= 0.5 and not observed_success: + return RealityVerdict( + False, + "failed", + "predicted success but execution did not succeed", + round(prediction_error, 6), + error_probability, + ) + return RealityVerdict( + True, + "aligned", + "observed outcome is consistent with current prediction", + round(prediction_error, 6), + error_probability, + ) diff --git a/synapdrive_ai/runtime/shadow.py b/synapdrive_ai/runtime/shadow.py new file mode 100644 index 0000000..40a6e2f --- /dev/null +++ b/synapdrive_ai/runtime/shadow.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any, Callable, Dict, List + + +@dataclass(frozen=True) +class ShadowComparison: + trusted_action: str + shadow_action: str + agreement: bool + shadow_metadata: Dict[str, Any] + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +class ShadowController: + """Evaluates an experimental policy without granting it actuation authority.""" + + def __init__(self, policy: Callable[[Dict[str, Any]], tuple[str, Dict[str, Any]]]) -> None: + self.policy = policy + self.history: List[ShadowComparison] = [] + + def evaluate(self, trusted_action: str, context: Dict[str, Any]) -> ShadowComparison: + action, metadata = self.policy(dict(context)) + comparison = ShadowComparison( + trusted_action=trusted_action, + shadow_action=str(action), + agreement=str(action) == str(trusted_action), + shadow_metadata=dict(metadata), + ) + self.history.append(comparison) + return comparison diff --git a/synapdrive_ai/runtime/simplex.py b/synapdrive_ai/runtime/simplex.py new file mode 100644 index 0000000..0f8ebda --- /dev/null +++ b/synapdrive_ai/runtime/simplex.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, Dict + +from synapdrive_ai.runtime.contracts import PreActionDecision + + +@dataclass(frozen=True) +class SimplexExecution: + controller: str + result: Dict[str, Any] + + +class SimplexController: + """Routes to advanced control only after the runtime monitor admits the action.""" + + def __init__( + self, + advanced_controller: Callable[[Dict[str, Any]], Dict[str, Any]], + safe_controller: Callable[[str], Dict[str, Any]], + ) -> None: + self.advanced_controller = advanced_controller + self.safe_controller = safe_controller + + def execute(self, packet: Dict[str, Any], decision: PreActionDecision) -> SimplexExecution: + if decision.allowed: + return SimplexExecution("advanced", self.advanced_controller(packet)) + return SimplexExecution("reversionary", self.safe_controller(decision.fallback_action)) diff --git a/synapdrive_ai/safety/__init__.py b/synapdrive_ai/safety/__init__.py new file mode 100644 index 0000000..503252a --- /dev/null +++ b/synapdrive_ai/safety/__init__.py @@ -0,0 +1 @@ +"""Compatibility safety helpers; governed policy lives in the runtime layer.""" diff --git a/synapdrive_ai/stress/__init__.py b/synapdrive_ai/stress/__init__.py new file mode 100644 index 0000000..9496584 --- /dev/null +++ b/synapdrive_ai/stress/__init__.py @@ -0,0 +1,4 @@ +from .campaign import CampaignReport, StressCampaign +from .faults import FaultInjector + +__all__ = ["CampaignReport", "FaultInjector", "StressCampaign"] diff --git a/synapdrive_ai/stress/campaign.py b/synapdrive_ai/stress/campaign.py new file mode 100644 index 0000000..b4f7e42 --- /dev/null +++ b/synapdrive_ai/stress/campaign.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Callable, Iterable, List + +import numpy as np + +from synapdrive_ai.neuro.signal_quality import SignalQualityAnalyzer + + +@dataclass(frozen=True) +class CampaignTrial: + trial: int + fault: str + quality_score: float + quality_state: str + expected_block: bool + blocked: bool + invariant_passed: bool + + +@dataclass(frozen=True) +class CampaignReport: + total_trials: int + invariant_passes: int + invariant_failures: int + trials: List[CampaignTrial] + + def to_dict(self): + return { + "total_trials": self.total_trials, + "invariant_passes": self.invariant_passes, + "invariant_failures": self.invariant_failures, + "trials": [asdict(t) for t in self.trials], + } + + +class StressCampaign: + """Runs faulted signals through a supplied analysis callback and checks fail-closed behavior. + + callback(signal, quality_score) must return True when the action path is blocked. + """ + + def __init__(self, sampling_rate: float, min_quality: float = 0.35) -> None: + self.sampling_rate = float(sampling_rate) + self.min_quality = float(min_quality) + self.quality = SignalQualityAnalyzer(sampling_rate) + + def run( + self, + trials: Iterable[tuple[str, np.ndarray]], + callback: Callable[[np.ndarray, float], bool], + ) -> CampaignReport: + results: List[CampaignTrial] = [] + for idx, (fault_name, signal) in enumerate(trials, 1): + quality = self.quality.analyze(signal) + expected_block = quality.score < self.min_quality + blocked = bool(callback(signal, quality.score)) + invariant = (not expected_block) or blocked + results.append( + CampaignTrial( + idx, + fault_name, + quality.score, + quality.state, + expected_block, + blocked, + invariant, + ) + ) + passes = sum(1 for t in results if t.invariant_passed) + return CampaignReport(len(results), passes, len(results) - passes, results) diff --git a/synapdrive_ai/stress/cli.py b/synapdrive_ai/stress/cli.py new file mode 100644 index 0000000..7e08b18 --- /dev/null +++ b/synapdrive_ai/stress/cli.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import argparse +import json + +import numpy as np + +from synapdrive_ai.bci.intent_generator import generate_intent +from synapdrive_ai.pipeline import SynapDrivePipeline +from synapdrive_ai.stress.campaign import StressCampaign +from synapdrive_ai.stress.faults import FaultInjector + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser( + description="Run deterministic SynapDrive signal fault-injection campaign." + ) + parser.add_argument("--runs", type=int, default=25) + parser.add_argument("--seed", type=int, default=7) + parser.add_argument("--sampling-rate", type=float, default=256.0) + args = parser.parse_args(argv) + rng = np.random.default_rng(args.seed) + n = int(args.sampling_rate * 2) + t = np.arange(n) / args.sampling_rate + base = np.sin(2 * np.pi * 20 * t) + 0.05 * rng.normal(size=n) + injector = FaultInjector(args.seed) + trials = [] + for i in range(args.runs): + mode = i % 4 + if mode == 0: + res = injector.dropout(base, min(0.95, 0.25 + 0.03 * i)) + elif mode == 1: + res = injector.line_noise(base, args.sampling_rate, amplitude=1.5) + elif mode == 2: + res = injector.clip(base, quantile=0.6) + else: + res = injector.gaussian_noise(base, sigma=1.0) + trials.append((str(res.metadata["fault"]), res.signal)) + + pipe = SynapDrivePipeline(simulate_delay=False) + campaign = StressCampaign( + args.sampling_rate, + min_quality=pipe.runtime.policy.min_signal_quality, + ) + + def callback(_signal, quality_score: float) -> bool: + packet = generate_intent("move left") + packet["signal_quality"] = quality_score + packet["source"] = "stress-campaign" + return pipe.run_intent_packet(packet)["status"] == "blocked" + + report = campaign.run(trials, callback) + print(json.dumps(report.to_dict(), indent=2)) + return 1 if report.invariant_failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/synapdrive_ai/stress/faults.py b/synapdrive_ai/stress/faults.py new file mode 100644 index 0000000..379a71f --- /dev/null +++ b/synapdrive_ai/stress/faults.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict + +import numpy as np + + +@dataclass(frozen=True) +class FaultResult: + signal: np.ndarray + metadata: Dict[str, float | int | str] + + +class FaultInjector: + """Deterministic scientific fault injection for EEG-style arrays.""" + + def __init__(self, seed: int = 0) -> None: + self.rng = np.random.default_rng(seed) + + def gaussian_noise(self, signal: np.ndarray, sigma: float) -> FaultResult: + x = np.asarray(signal, dtype=float) + out = x + self.rng.normal(0.0, float(sigma), size=x.shape) + return FaultResult(out, {"fault": "gaussian_noise", "sigma": float(sigma)}) + + def dropout(self, signal: np.ndarray, fraction: float) -> FaultResult: + x = np.asarray(signal, dtype=float).copy() + fraction = max(0.0, min(1.0, float(fraction))) + n = x.shape[-1] + width = int(round(n * fraction)) + if width > 0: + start = int(self.rng.integers(0, max(1, n - width + 1))) + x[..., start : start + width] = 0.0 + else: + start = 0 + return FaultResult( + x, + { + "fault": "dropout", + "fraction": fraction, + "start": start, + "width": width, + }, + ) + + def clip(self, signal: np.ndarray, quantile: float = 0.90) -> FaultResult: + x = np.asarray(signal, dtype=float).copy() + q = max(0.5, min(0.999, float(quantile))) + limit = float(np.quantile(np.abs(x), q)) + if limit > 0: + x = np.clip(x, -limit, limit) + return FaultResult(x, {"fault": "clip", "quantile": q, "limit": limit}) + + def line_noise( + self, + signal: np.ndarray, + sampling_rate: float, + frequency: float = 60.0, + amplitude: float = 1.0, + ) -> FaultResult: + x = np.asarray(signal, dtype=float) + t = np.arange(x.shape[-1], dtype=float) / float(sampling_rate) + noise = float(amplitude) * np.sin(2 * np.pi * float(frequency) * t) + out = x + noise + return FaultResult( + out, + { + "fault": "line_noise", + "frequency": float(frequency), + "amplitude": float(amplitude), + }, + ) + + def channel_swap(self, signal: np.ndarray, a: int, b: int) -> FaultResult: + x = np.asarray(signal, dtype=float).copy() + if x.ndim != 2: + raise ValueError("channel_swap requires shape (channels, samples)") + if a == b or not (0 <= a < x.shape[0]) or not (0 <= b < x.shape[0]): + raise ValueError("invalid channel indices") + x[[a, b]] = x[[b, a]] + return FaultResult(x, {"fault": "channel_swap", "a": a, "b": b}) diff --git a/synapdrive_ai/tests/test_final_hardening.py b/synapdrive_ai/tests/test_final_hardening.py new file mode 100644 index 0000000..456b60f --- /dev/null +++ b/synapdrive_ai/tests/test_final_hardening.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import sys +import types +from pathlib import Path + +import numpy as np +import pytest + +from synapdrive_ai.agi.cognitive_optimizer import CognitiveOptimizer +from synapdrive_ai.bci.intent_generator import generate_intent +from synapdrive_ai.benchmarking.dataset import EpochDataset +from synapdrive_ai.benchmarking.decoders import SpectralCentroidDecoder +from synapdrive_ai.benchmarking.runtime_adapter import QualifiedDecoderAdapter +from synapdrive_ai.cognition.world_model import WorldModel +from synapdrive_ai.neuro.eeg_loader import EEGLoader, EEGRecording +from synapdrive_ai.neuro.session_analyzer import SessionAnalyzer +from synapdrive_ai.pipeline import SynapDrivePipeline +from synapdrive_ai.vision.visual_inference import VisualInferenceEngine + + +def test_declared_visual_context_has_no_random_certainty() -> None: + engine = VisualInferenceEngine() + first = engine.infer("road") + second = engine.infer("road") + assert first == second + assert first["certainty"] == 1.0 + assert first["evidence_kind"] == "declared-label" + assert first["model_used"] is False + + +def test_visual_context_does_not_boost_command_confidence() -> None: + optimizer = CognitiveOptimizer() + packet = generate_intent("move left") + without_visual = optimizer.optimize(packet) + with_visual = optimizer.optimize(packet, image_label="road") + assert with_visual["confidence"] == without_visual["confidence"] + + +def test_unmodeled_world_action_fails_closed() -> None: + prediction = WorldModel().predict("invented_action") + assert prediction.feasible is False + assert prediction.predicted_risk == 1.0 + assert prediction.after == prediction.before + + +def test_analysis_only_packet_cannot_reach_actuation() -> None: + pipe = SynapDrivePipeline(simulate_delay=False) + packet = { + "intent": "move_left", + "confidence": 1.0, + "source": "analysis", + "analysis_only": True, + "signal_quality": 1.0, + } + out = pipe.run_intent_packet(packet) + assert out["status"] == "blocked" + assert "analysis-only-inference" in out["reason"] + assert pipe.get_action_log() == [] + + +def test_session_analyzer_without_decoder_is_analysis_only() -> None: + sr = 128.0 + t = np.arange(256) / sr + signal = np.sin(2 * np.pi * 10 * t) + recording = EEGLoader(sr).load_array(signal, channel_names=["C3"]) + report = SessionAnalyzer(channel="C3", window_s=1.0, step_s=1.0).run(recording) + assert report.n_epochs == 2 + assert all(epoch.pipeline_status == "blocked" for epoch in report.epochs) + assert all(epoch.pipeline_confidence == 0.0 for epoch in report.epochs) + + +def _install_fake_brainflow(monkeypatch: pytest.MonkeyPatch) -> None: + board_mod = types.ModuleType("brainflow.board_shim") + + class Params: + serial_port = "" + + class Board: + def __init__(self, board_id, params): + self.board_id = board_id + self.params = params + + def prepare_session(self): + return None + + def start_stream(self): + return None + + def get_board_data(self): + t = np.arange(256) / 256.0 + return np.stack([np.sin(2 * np.pi * 10 * t), np.sin(2 * np.pi * 12 * t)]) + + def stop_stream(self): + return None + + def release_session(self): + return None + + @staticmethod + def get_eeg_channels(_board_id): + return [0, 1] + + @staticmethod + def get_sampling_rate(_board_id): + return 256 + + setattr(board_mod, "BoardShim", Board) + setattr(board_mod, "BrainFlowInputParams", Params) + pkg = types.ModuleType("brainflow") + monkeypatch.setitem(sys.modules, "brainflow", pkg) + monkeypatch.setitem(sys.modules, "brainflow.board_shim", board_mod) + + +def test_brainflow_without_decoder_abstains(monkeypatch: pytest.MonkeyPatch) -> None: + _install_fake_brainflow(monkeypatch) + import synapdrive_ai.integrations.brainflow_adapter as module + + monkeypatch.setattr(module.time, "sleep", lambda _seconds: None) + source = module.BrainFlowIntentSource(stream_seconds=0.001) + packet = source.next_intent_packet() + assert packet["intent"] == "unknown" + assert packet["confidence"] == 0.0 + assert packet["decoder_status"] == "not-configured" + assert packet["analysis_only"] is True + + +def test_brainflow_explicit_decoder_is_only_decoder_authority( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _install_fake_brainflow(monkeypatch) + import synapdrive_ai.integrations.brainflow_adapter as module + + monkeypatch.setattr(module.time, "sleep", lambda _seconds: None) + + def decoder(data, metadata): + assert data.shape == (2, 256) + assert metadata["sampling_rate"] == 256.0 + return { + "intent": "move_left", + "confidence": 0.8, + "probabilities": {"move_left": 0.8, "unknown": 0.2}, + "source": "test-decoder", + } + + source = module.BrainFlowIntentSource(stream_seconds=0.001, decoder=decoder) + packet = source.next_intent_packet() + assert packet["intent"] == "move_left" + assert packet["confidence"] == 0.8 + assert packet["decoder_status"] == "configured" + assert packet["source"] == "test-decoder" + + +def _install_fake_lsl(monkeypatch: pytest.MonkeyPatch, sampling_rate: float = 256.0) -> None: + mod = types.ModuleType("pylsl") + + class Info: + def name(self): + return "unit-test-stream" + + def type(self): + return "EEG" + + def nominal_srate(self): + return sampling_rate + + class Inlet: + def __init__(self, info, max_chunklen=512): + self.info = info + self.max_chunklen = max_chunklen + self.calls = 0 + + def pull_chunk(self, timeout=0.2, max_samples=512): + self.calls += 1 + if self.calls == 1: + t = np.arange(64) / 256.0 + samples = np.stack( + [np.sin(2 * np.pi * 10 * t), np.sin(2 * np.pi * 12 * t)], axis=1 + ) + return samples.tolist(), t.tolist() + return [], [] + + def resolve_stream(_prop, _value, timeout=5.0): + del timeout + return [Info()] + + setattr(mod, "StreamInlet", Inlet) + setattr(mod, "resolve_stream", resolve_stream) + monkeypatch.setitem(sys.modules, "pylsl", mod) + + +def test_lsl_without_decoder_abstains(monkeypatch: pytest.MonkeyPatch) -> None: + _install_fake_lsl(monkeypatch) + import synapdrive_ai.integrations.lsl_adapter as module + + ticks = iter([0.0, 0.0, 1.0]) + monkeypatch.setattr(module.time, "monotonic", lambda: next(ticks, 1.0)) + source = module.LSLIntentSource(snapshot_seconds=0.1) + packet = source.next_intent_packet() + assert packet["intent"] == "unknown" + assert packet["confidence"] == 0.0 + assert packet["decoder_status"] == "not-configured" + assert packet["analysis_only"] is True + + +def test_repository_contains_no_retired_fabricated_routes() -> None: + root = Path(__file__).resolve().parents[2] + retired = ("Tesla" + "AutonomySystem", "SpaceX" + "CommandCore", "Hyperloop" + "Ops") + for path in root.rglob("*.py"): + if "__pycache__" in path.parts: + continue + text = path.read_text(encoding="utf-8") + for marker in retired: + assert marker not in text, f"retired fabricated route {marker!r} remains in {path}" + + +def test_qualified_decoder_adapter_connects_to_session_analyzer() -> None: + sr = 128.0 + n = 128 + times = np.arange(n, dtype=float) / sr + rng = np.random.default_rng(123) + epochs = [] + labels = [] + for label, frequency in (("left", 10.0), ("right", 20.0)): + for _ in range(12): + signal = np.sin(2.0 * np.pi * frequency * times) + signal += rng.normal(0.0, 0.01, n) + epochs.append(signal[None, :]) + labels.append(label) + dataset = EpochDataset( + epochs=np.asarray(epochs, dtype=float), + labels=np.asarray(labels), + sampling_rate=sr, + channel_names=["C3"], + source="deterministic-synthetic-verification", + ) + adapter = QualifiedDecoderAdapter( + SpectralCentroidDecoder(), + dataset, + {"left": "move_left", "right": "move_right"}, + ) + assert adapter.qualification.qualified is True + + recording = EEGRecording( + channels=["C3"], + data=np.sin(2.0 * np.pi * 10.0 * times)[None, :], + sampling_rate=sr, + duration_s=1.0, + source_file="synthetic-verification", + ) + report = SessionAnalyzer( + channel="C3", + window_s=1.0, + step_s=1.0, + decoder=adapter, + ).run(recording) + assert report.n_epochs == 1 + assert report.n_success == 1 + assert report.epochs[0].pipeline_status == "success" + assert report.epochs[0].pipeline_confidence >= adapter.policy.abstain_threshold diff --git a/synapdrive_ai/tests/test_legacy_core.py b/synapdrive_ai/tests/test_legacy_core.py index 19a7957..731c182 100644 --- a/synapdrive_ai/tests/test_legacy_core.py +++ b/synapdrive_ai/tests/test_legacy_core.py @@ -13,3 +13,11 @@ def test_legacy_core_router_example_remains_importable() -> None: result = router.route(parsed) assert result == "Executed simulated move: forward" + + +def test_legacy_core_package_exports_router() -> None: + from core.intent_router import IntentParser as ExportedParser + from core.intent_router import IntentRouter as ExportedRouter + + assert ExportedParser is IntentParser + assert ExportedRouter is IntentRouter diff --git a/synapdrive_ai/tests/test_neuro.py b/synapdrive_ai/tests/test_neuro.py index baadab1..ab6d82a 100644 --- a/synapdrive_ai/tests/test_neuro.py +++ b/synapdrive_ai/tests/test_neuro.py @@ -226,6 +226,7 @@ def test_impossible_confidence_defers(self): bridge = ExecutorBridge(simulate_delay=False) trace = bridge.execute(plan) assert any(s.pipeline_status == "deferred" for s in trace.steps) + assert bridge._pipe.get_action_log() == [] def test_abort_fallback_stops_plan(self): plan = TaskPlan( @@ -239,6 +240,7 @@ def test_abort_fallback_stops_plan(self): trace = bridge.execute(plan) assert trace.outcome == "aborted" assert len(trace.steps) == 1 + assert bridge._pipe.get_action_log() == [] def test_complete_fallback_proceeds(self): plan = TaskPlan( @@ -261,3 +263,46 @@ def test_empty_plan(self): trace = bridge.execute(plan) assert trace.n_steps == 0 assert trace.outcome == "completed" + + +def test_bdf_24bit_single_channel_loading(tmp_path): + def field(value, width): + return str(value).ljust(width)[:width].encode("ascii") + + n_signals = 1 + ns = 4 + header_bytes = 256 + 256 * n_signals + fixed = bytearray(b" " * 256) + fixed[0:8] = field("255", 8) + fixed[184:192] = field(header_bytes, 8) + fixed[236:244] = field(1, 8) + fixed[244:252] = field(1, 8) + fixed[252:256] = field(n_signals, 4) + + signal_header = bytearray() + signal_header += field("C3", 16) + signal_header += field("", 80) + signal_header += field("uV", 8) + signal_header += field(-8388608, 8) + signal_header += field(8388607, 8) + signal_header += field(-8388608, 8) + signal_header += field(8388607, 8) + signal_header += field("", 80) + signal_header += field(ns, 8) + signal_header += field("", 32) + + samples = [-8388608, -1, 0, 8388607] + encoded = bytearray() + for sample in samples: + unsigned = sample if sample >= 0 else sample + (1 << 24) + encoded.extend( + [unsigned & 0xFF, (unsigned >> 8) & 0xFF, (unsigned >> 16) & 0xFF] + ) + + path = tmp_path / "tiny.bdf" + path.write_bytes(bytes(fixed) + bytes(signal_header) + bytes(encoded)) + recording = EEGLoader().load(path) + assert recording.n_samples == ns + assert recording.sampling_rate == 4.0 + assert recording.channel("C3")[0] < 0 + assert recording.channel("C3")[-1] > 0 diff --git a/synapdrive_ai/tests/test_v1_benchmark_stress.py b/synapdrive_ai/tests/test_v1_benchmark_stress.py new file mode 100644 index 0000000..741b4e2 --- /dev/null +++ b/synapdrive_ai/tests/test_v1_benchmark_stress.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import numpy as np + +from synapdrive_ai.adaptation.guarded import GuardedThresholdAdapter +from synapdrive_ai.benchmarking.dataset import EpochDataset, load_npz_dataset +from synapdrive_ai.benchmarking.decoders import ( + EnsembleDecoder, + RiemannianCentroidDecoder, + SpectralCentroidDecoder, +) +from synapdrive_ai.benchmarking.evaluation import evaluate_decoder +from synapdrive_ai.stress.campaign import StressCampaign +from synapdrive_ai.stress.faults import FaultInjector + + +def _dataset(n_per_class=24, sr=128.0): + rng = np.random.default_rng(11) + n = int(sr) + t = np.arange(n) / sr + epochs, labels = [], [] + for label, freq in [("left", 10.0), ("right", 20.0)]: + for _ in range(n_per_class): + ch1 = np.sin(2 * np.pi * freq * t) + rng.normal(0, 0.12, n) + ch2 = 0.7 * np.sin(2 * np.pi * freq * t + 0.3) + rng.normal(0, 0.12, n) + epochs.append(np.stack([ch1, ch2])) + labels.append(label) + return EpochDataset(np.asarray(epochs), np.asarray(labels), sr, ["C3", "C4"], "test-generated") + + +def test_npz_dataset_contract_roundtrip(tmp_path): + ds = _dataset(4) + p = tmp_path / "d.npz" + np.savez( + p, + X=ds.epochs, + y=ds.labels, + sampling_rate=np.array([ds.sampling_rate]), + channel_names=np.array(ds.channel_names), + ) + loaded = load_npz_dataset(p) + assert loaded.epochs.shape == ds.epochs.shape + assert loaded.channel_names == ["C3", "C4"] + + +def test_spectral_decoder_learns_frequency_classes(): + ds = _dataset() + report = evaluate_decoder(SpectralCentroidDecoder(), ds, seed=3) + assert report.accuracy > 0.9 + assert report.n_test > 0 + + +def test_riemannian_decoder_outputs_probabilities(): + ds = _dataset() + decoder = RiemannianCentroidDecoder().fit(ds.epochs, ds.labels, ds.sampling_rate) + p = decoder.predict_proba(ds.epochs[:3], ds.sampling_rate) + assert p.shape == (3, 2) + assert np.allclose(np.sum(p, axis=1), 1.0) + + +def test_ensemble_benchmark_metrics_are_bounded(): + ds = _dataset() + decoder = EnsembleDecoder([SpectralCentroidDecoder(), RiemannianCentroidDecoder()]) + report = evaluate_decoder(decoder, ds) + assert 0 <= report.accuracy <= 1 + assert 0 <= report.balanced_accuracy <= 1 + assert 0 <= report.ece <= 1 + assert 0 <= report.coverage <= 1 + + +def test_guarded_adapter_promotes_safer_better_threshold(): + rows = [ + {"confidence": 0.9, "safe": True}, + {"confidence": 0.8, "safe": True}, + {"confidence": 0.6, "safe": False}, + {"confidence": 0.55, "safe": False}, + ] + adapter = GuardedThresholdAdapter(0.45) + decision = adapter.evaluate_candidate(0.7, rows) + assert decision.promoted is True + assert adapter.threshold == 0.7 + + +def test_guarded_adapter_rejects_more_unsafe_accepts(): + rows = [ + {"confidence": 0.9, "safe": True}, + {"confidence": 0.6, "safe": False}, + ] + adapter = GuardedThresholdAdapter(0.7) + decision = adapter.evaluate_candidate(0.5, rows) + assert decision.promoted is False + assert adapter.threshold == 0.7 + + +def test_fault_injector_is_seed_deterministic(): + x = np.ones(100) + a = FaultInjector(5).dropout(x, 0.2) + b = FaultInjector(5).dropout(x, 0.2) + assert np.array_equal(a.signal, b.signal) + assert a.metadata == b.metadata + + +def test_fault_dropout_writes_zeros(): + out = FaultInjector(1).dropout(np.ones(100), 0.5) + assert np.sum(out.signal == 0) >= 50 + + +def test_fault_channel_swap(): + x = np.vstack([np.zeros(8), np.ones(8)]) + out = FaultInjector().channel_swap(x, 0, 1).signal + assert np.all(out[0] == 1) + assert np.all(out[1] == 0) + + +def test_stress_campaign_enforces_low_quality_block(): + clean = np.sin(2 * np.pi * 10 * np.arange(512) / 256) + bad = np.zeros(512) + campaign = StressCampaign(256, min_quality=0.5) + report = campaign.run([("clean", clean), ("flatline", bad)], lambda _sig, q: q < 0.5) + assert report.invariant_failures == 0 + assert report.total_trials == 2 + + +def test_decoder_arena_ranks_all_builtins(): + from synapdrive_ai.benchmarking.evaluation import run_arena + + reports = run_arena(_dataset()) + assert len(reports) == 3 + assert {report.decoder for report in reports} == { + "SpectralCentroidDecoder", + "RiemannianCentroidDecoder", + "EnsembleDecoder", + } + + +def test_epoch_recording_uses_explicit_events(): + from synapdrive_ai.benchmarking.epoching import LabeledEvent, epoch_recording + from synapdrive_ai.neuro.eeg_loader import EEGLoader + + sr = 100.0 + data = np.vstack([np.arange(500), np.arange(500) * 2.0]) + recording = EEGLoader(sr).load_array(data, channel_names=["C3", "C4"]) + dataset = epoch_recording( + recording, + [LabeledEvent(1.0, "left"), LabeledEvent(3.0, "right")], + tmin_s=0.0, + tmax_s=0.5, + channels=["C3"], + ) + assert dataset.epochs.shape == (2, 1, 50) + assert dataset.labels.tolist() == ["left", "right"] + + +def test_qualified_decoder_adapter_bridges_labeled_data_to_action_packet(): + from synapdrive_ai.benchmarking.runtime_adapter import QualifiedDecoderAdapter + + ds = _dataset() + adapter = QualifiedDecoderAdapter( + SpectralCentroidDecoder(), + ds, + {"left": "move_left", "right": "move_right"}, + seed=3, + ) + assert adapter.qualification.qualified is True + packet = adapter(ds.epochs[0], {"sampling_rate": ds.sampling_rate}) + assert packet["intent"] == "move_left" + assert packet["analysis_only"] is False + assert packet["neural_decode_performed"] is True + assert 0.0 <= packet["confidence"] <= 1.0 + + +def test_qualified_decoder_adapter_abstains_on_sampling_rate_mismatch(): + from synapdrive_ai.benchmarking.runtime_adapter import QualifiedDecoderAdapter + + ds = _dataset() + adapter = QualifiedDecoderAdapter( + SpectralCentroidDecoder(), + ds, + {"left": "move_left", "right": "move_right"}, + seed=3, + ) + packet = adapter(ds.epochs[0], {"sampling_rate": ds.sampling_rate + 1}) + assert packet["intent"] == "unknown" + assert packet["analysis_only"] is True + assert "sampling-rate mismatch" in packet["abstention_reason"] + + +def test_qualified_decoder_requires_action_mapping_for_all_classes(): + from synapdrive_ai.benchmarking.runtime_adapter import QualifiedDecoderAdapter + + ds = _dataset() + adapter = QualifiedDecoderAdapter( + SpectralCentroidDecoder(), + ds, + {"left": "move_left"}, + seed=3, + ) + assert adapter.qualification.qualified is False + assert "unmapped-decoder-classes:right" in adapter.qualification.reason diff --git a/synapdrive_ai/tests/test_v1_cognition_evidence.py b/synapdrive_ai/tests/test_v1_cognition_evidence.py new file mode 100644 index 0000000..d3ca10f --- /dev/null +++ b/synapdrive_ai/tests/test_v1_cognition_evidence.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from typing import cast + +from synapdrive_ai.assurance.hashchain import EvidenceHashChain +from synapdrive_ai.cognition.planner import CandidateAction, CounterfactualPlanner +from synapdrive_ai.cognition.world_model import ActionModel, WorldModel +from synapdrive_ai.memory.evidence_memory import EvidenceMemory +from synapdrive_ai.runtime.reality import RealityReconciler + + +def test_world_model_known_action_predicts_effects(): + world = WorldModel({"x": 0}) + world.register(ActionModel("right", effects={"x": 1}, predicted_risk=0.1)) + p = world.predict("right") + assert p.after["x"] == 1 + assert world.state["x"] == 0 + + +def test_world_model_commit_changes_state(): + world = WorldModel({"x": 0}) + world.register(ActionModel("right", effects={"x": 1})) + world.commit(world.predict("right")) + assert world.state["x"] == 1 + + +def test_world_model_unknown_does_not_invent_effects(): + world = WorldModel({"x": 3}) + p = world.predict("unmodeled") + assert p.after == {"x": 3} + assert "unmodeled-action" in p.unmet_requirements + + +def test_counterfactual_planner_penalizes_risk(): + world = WorldModel({"goal": 0}) + world.register_many([ + ActionModel("safe", effects={"goal": 1}, predicted_risk=0.1), + ActionModel("risky", effects={"goal": 1}, predicted_risk=0.9), + ]) + planner = CounterfactualPlanner(world) + choice = planner.rank([ + CandidateAction("safe", 1.0, 0.9), + CandidateAction("risky", 1.0, 0.9), + ], lambda s: float(cast(int | float, s.get("goal", 0)))) + assert choice.selected is not None + assert choice.selected.action == "safe" + + +def test_evidence_hash_chain_verifies(): + chain = EvidenceHashChain() + chain.append({"a": 1}) + chain.append({"b": 2}) + assert EvidenceHashChain.verify(chain.entries()) is True + + +def test_evidence_hash_chain_detects_tamper(): + chain = EvidenceHashChain() + chain.append({"a": 1}) + rows = [e.to_dict() for e in chain.entries()] + rows[0]["payload"]["a"] = 999 + assert EvidenceHashChain.verify(rows) is False + + +def test_evidence_memory_validates_aligned(): + memory = EvidenceMemory() + row = memory.record( + cycle_id="1", + intent="left", + confidence=0.8, + outcome="success", + aligned=True, + reason="ok", + ) + assert row.state == "validated" + assert len(memory.validated_for_intent("left")) == 1 + + +def test_evidence_memory_quarantines_contradiction(): + memory = EvidenceMemory() + memory.record( + cycle_id="1", + intent="left", + confidence=0.8, + outcome="success", + aligned=False, + reason="ErrP", + ) + assert len(memory.quarantined()) == 1 + assert memory.validated_for_intent("left") == [] + + +def test_reality_reconciler_errp_contradicts_success(): + verdict = RealityReconciler().reconcile( + predicted_success=0.9, + result_packet={"status": "success"}, + feedback={"errp_probability": 0.95}, + ) + assert verdict.aligned is False + assert verdict.outcome == "contradicted" + + +def test_reality_reconciler_explicit_rejection_wins(): + verdict = RealityReconciler().reconcile( + predicted_success=0.9, + result_packet={"status": "success"}, + feedback={"accepted": False}, + ) + assert verdict.aligned is False \ No newline at end of file diff --git a/synapdrive_ai/tests/test_v1_neuro.py b/synapdrive_ai/tests/test_v1_neuro.py new file mode 100644 index 0000000..7f54def --- /dev/null +++ b/synapdrive_ai/tests/test_v1_neuro.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from synapdrive_ai.neuro.drift import FeatureDriftMonitor +from synapdrive_ai.neuro.errp import ErrPFeatureExtractor, ErrPLDAClassifier +from synapdrive_ai.neuro.fusion import ModalityEvidence, WeightedEvidenceFusion +from synapdrive_ai.neuro.signal_quality import SignalQualityAnalyzer +from synapdrive_ai.neuro.uncertainty import ( + ReliabilityCalibrator, + estimate_uncertainty, + normalize_distribution, + packet_distribution, +) + + +def test_distribution_normalizes(): + p = normalize_distribution({"a": 2, "b": 1}) + assert abs(sum(p.values()) - 1.0) < 1e-12 + + +def test_packet_distribution_has_unknown_mass(): + p = packet_distribution({"intent": "left", "confidence": 0.8}) + assert p["left"] == pytest.approx(0.8) + assert p["unknown"] == pytest.approx(0.2) + + +def test_uncertainty_lower_for_decisive_distribution(): + decisive = estimate_uncertainty({"a": 0.99, "b": 0.01}).combined + ambiguous = estimate_uncertainty({"a": 0.51, "b": 0.49}).combined + assert decisive < ambiguous + + +def test_ensemble_disagreement_contributes_uncertainty(): + same = estimate_uncertainty({"a": 0.8, "b": 0.2}, [{"a": 0.8, "b": 0.2}, {"a": 0.8, "b": 0.2}]) + split = estimate_uncertainty( + {"a": 0.5, "b": 0.5}, + [{"a": 0.99, "b": 0.01}, {"a": 0.01, "b": 0.99}], + ) + assert split.disagreement > same.disagreement + + +def test_calibrator_requires_fit(): + with pytest.raises(RuntimeError): + ReliabilityCalibrator().transform(0.8) + + +def test_calibrator_maps_to_empirical_accuracy(): + cal = ReliabilityCalibrator(n_bins=2).fit([0.1, 0.2, 0.8, 0.9], [False, False, True, True]) + assert cal.transform(0.85) == pytest.approx(1.0) + assert cal.transform(0.15) == pytest.approx(0.0) + + +def test_signal_quality_good_clean_signal(): + sr = 256 + t = np.arange(sr * 2) / sr + x = np.sin(2 * np.pi * 10 * t) + 0.1 * np.sin(2 * np.pi * 20 * t) + report = SignalQualityAnalyzer(sr).analyze(x) + assert report.score > 0.8 + assert report.state == "good" + + +def test_signal_quality_detects_nan(): + x = np.ones(512) + x[::2] = np.nan + report = SignalQualityAnalyzer(256).analyze(x) + assert "non-finite-samples" in report.issues + assert report.score < 0.8 + + +def test_signal_quality_detects_flatline(): + report = SignalQualityAnalyzer(256).analyze(np.ones(512)) + assert "flatline" in report.issues + assert report.state in {"unreliable", "invalid", "degraded"} + + +def test_signal_quality_detects_dropout(): + sr = 256 + t = np.arange(512) / sr + x = np.sin(2 * np.pi * 10 * t) + x[:300] = 0 + report = SignalQualityAnalyzer(sr).analyze(x) + assert "dropout" in report.issues + + +def test_fusion_prefers_reliable_modality(): + result = WeightedEvidenceFusion().fuse([ + ModalityEvidence("eeg", {"left": 0.9, "right": 0.1}, 0.2), + ModalityEvidence("gaze", {"left": 0.1, "right": 0.9}, 1.0), + ]) + assert result.intent == "right" + assert result.weights["gaze"] > result.weights["eeg"] + + +def test_fusion_rejects_zero_reliability(): + with pytest.raises(ValueError): + WeightedEvidenceFusion().fuse([ModalityEvidence("eeg", {"left": 1.0}, 0.0)]) + + +def test_drift_monitor_detects_far_outlier(): + rng = np.random.default_rng(2) + baseline = rng.normal(0, 0.2, (100, 3)) + monitor = FeatureDriftMonitor().fit(baseline) + assert monitor.evaluate([8, 8, 8]).drifted is True + + +def test_errp_feature_extractor_windows(): + sr = 100 + x = np.zeros(sr) + x[20:35] = -2.0 + x[35:60] = 1.0 + f = ErrPFeatureExtractor(sr).extract(x) + assert f.negative_peak == pytest.approx(-2.0) + assert f.positive_peak == pytest.approx(1.0) + + +def test_errp_lda_learns_supplied_calibration(): + rng = np.random.default_rng(4) + x0 = rng.normal(0, 0.1, (20, 4)) + x1 = rng.normal(2, 0.1, (20, 4)) + x = np.vstack([x0, x1]) + y = [0] * 20 + [1] * 20 + clf = ErrPLDAClassifier().fit(x, y) + assert clf.predict([2, 2, 2, 2]) == 1 + assert clf.predict([0, 0, 0, 0]) == 0 + + +def test_errp_classifier_requires_both_classes(): + with pytest.raises(ValueError): + ErrPLDAClassifier().fit(np.zeros((4, 4)), [0, 0, 0, 0]) diff --git a/synapdrive_ai/tests/test_v1_pipeline_integration.py b/synapdrive_ai/tests/test_v1_pipeline_integration.py new file mode 100644 index 0000000..01e75ef --- /dev/null +++ b/synapdrive_ai/tests/test_v1_pipeline_integration.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from synapdrive_ai.bci.intent_generator import generate_intent +from synapdrive_ai.pipeline import SynapDrivePipeline + + +def test_pipeline_exposes_runtime_reality_memory_and_evidence(): + pipe = SynapDrivePipeline(simulate_delay=False) + out = pipe.run_text_command("move left") + assert out["status"] == "success" + assert out["runtime"]["allowed"] is True + assert out["reality"]["aligned"] is True + assert out["memory"]["state"] == "validated" + assert len(out["evidence"]["event_hash"]) == 64 + + +def test_pipeline_quarantines_errp_contradicted_success(): + pipe = SynapDrivePipeline(simulate_delay=False) + out = pipe.run_text_command("move left", feedback={"errp_probability": 0.99}) + assert out["status"] == "success" + assert out["reality"]["aligned"] is False + assert out["memory"]["state"] == "quarantined" + + +def test_quarantined_memory_does_not_boost_legacy_optimizer(): + pipe = SynapDrivePipeline(simulate_delay=False) + first = pipe.run_text_command("move left", feedback={"errp_probability": 0.99}) + second = pipe.run_text_command("move left") + assert first["intent"]["confidence"] == second["intent"]["confidence"] + + +def test_validated_memory_is_context_not_confidence_inflation(): + pipe = SynapDrivePipeline(simulate_delay=False) + first = pipe.run_text_command("move left") + second = pipe.run_text_command("move left") + assert second["intent"]["confidence"] == first["intent"]["confidence"] + assert first["intent"]["history_support_count"] == 0 + assert second["intent"]["history_support_count"] == 1 + + +def test_bad_signal_quality_blocks_before_action(): + pipe = SynapDrivePipeline(simulate_delay=False) + packet = generate_intent("move left") + packet["signal_quality"] = 0.0 + out = pipe.run_intent_packet(packet) + assert out["status"] == "blocked" + assert "signal-quality-below-policy" in out["reason"] + assert pipe.get_action_log() == [] + + +def test_permission_revoke_blocks_but_stop_stays_available(): + pipe = SynapDrivePipeline(simulate_delay=False) + pipe.runtime.permissions.revoke_all() + blocked = pipe.run_text_command("move left") + stop = pipe.run_text_command("stop") + assert blocked["status"] == "blocked" + assert stop["status"] == "success" + + +def test_successful_action_commits_explicit_world_model_state(): + pipe = SynapDrivePipeline(simulate_delay=False) + out = pipe.run_text_command("switch mode to assistive") + assert out["status"] == "success" + assert out["world_state"]["mode"] == "assistive" + assert pipe.runtime.world_model.state["mode"] == "assistive" + + +def test_evidence_chain_detects_no_tamper_after_multiple_cycles(): + pipe = SynapDrivePipeline(simulate_delay=False) + pipe.run_text_command("move left") + pipe.run_text_command("stop") + report = pipe.get_assurance_report() + assert report["evidence_chain_entries"] == 2 + assert report["evidence_chain_valid"] is True + + +def test_unknown_text_abstains_with_runtime_evidence(): + pipe = SynapDrivePipeline(simulate_delay=False) + out = pipe.run_text_command("not a known command") + assert out["status"] == "blocked" + assert out["runtime"]["allowed"] is False + assert out["memory"]["state"] == "not-recorded" diff --git a/synapdrive_ai/tests/test_v1_runtime.py b/synapdrive_ai/tests/test_v1_runtime.py new file mode 100644 index 0000000..80bcaa1 --- /dev/null +++ b/synapdrive_ai/tests/test_v1_runtime.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import json + +import pytest + +from synapdrive_ai.cognition.world_model import ActionModel, WorldModel +from synapdrive_ai.governance.permission import PermissionGate +from synapdrive_ai.governance.policy import RuntimePolicy +from synapdrive_ai.runtime.governed_runtime import GovernedRuntime + + +def test_runtime_policy_rejects_bad_probability(): + with pytest.raises(ValueError): + RuntimePolicy(min_confidence=1.2) + + +def test_runtime_policy_roundtrip_json(tmp_path): + path = tmp_path / "policy.json" + policy = RuntimePolicy(min_confidence=0.6) + path.write_text(json.dumps(policy.to_dict())) + assert RuntimePolicy.from_json(path).min_confidence == 0.6 + + +def test_permission_safe_state_survives_revoke(): + gate = PermissionGate() + gate.revoke_all() + assert gate.evaluate("halt_all_motion").allowed is True + assert gate.evaluate("move_left").allowed is False + + +def test_permission_deny_pattern(): + gate = PermissionGate() + assert gate.evaluate("override_security_now").allowed is False + + +def test_runtime_blocks_low_confidence(): + rt = GovernedRuntime(policy=RuntimePolicy(min_confidence=0.7)) + out = rt.assess({"intent": "move_left", "confidence": 0.6, "uncertainty": 0.1}) + assert out.allowed is False + assert "confidence-below-policy" in out.assessment.issues + + +def test_runtime_blocks_uncertainty(): + rt = GovernedRuntime(policy=RuntimePolicy(max_uncertainty=0.2)) + out = rt.assess({"intent": "move_left", "confidence": 0.9, "uncertainty": 0.5}) + assert out.allowed is False + assert "uncertainty-above-policy" in out.assessment.issues + + +def test_runtime_blocks_signal_quality(): + rt = GovernedRuntime(policy=RuntimePolicy(min_signal_quality=0.8)) + out = rt.assess( + { + "intent": "move_left", + "confidence": 0.9, + "uncertainty": 0.1, + "signal_quality": 0.2, + } + ) + assert out.allowed is False + assert "signal-quality-below-policy" in out.assessment.issues + + +def test_runtime_world_precondition_failure(): + world = WorldModel({"armed": False}) + world.register(ActionModel("move_left", required={"armed": True})) + rt = GovernedRuntime(world_model=world) + out = rt.assess({"intent": "move_left", "confidence": 0.9, "uncertainty": 0.1}) + assert out.allowed is False + assert "world-model-precondition-failed" in out.assessment.issues + + +def test_runtime_blocks_predicted_risk(): + world = WorldModel() + world.register(ActionModel("move_left", predicted_risk=0.9)) + rt = GovernedRuntime(world_model=world) + out = rt.assess({"intent": "move_left", "confidence": 0.9, "uncertainty": 0.1}) + assert out.allowed is False + assert "predicted-risk-above-policy" in out.assessment.issues diff --git a/synapdrive_ai/tests/test_v1_shared_autonomy.py b/synapdrive_ai/tests/test_v1_shared_autonomy.py new file mode 100644 index 0000000..45ce7b4 --- /dev/null +++ b/synapdrive_ai/tests/test_v1_shared_autonomy.py @@ -0,0 +1,114 @@ +from typing import cast + +import pytest + +from synapdrive_ai.cognition.expectation import ExpectationReconciler +from synapdrive_ai.cognition.planner import CandidateAction, CounterfactualPlanner +from synapdrive_ai.cognition.shared_autonomy import SharedAutonomyArbiter +from synapdrive_ai.cognition.world_model import ActionModel, WorldModel +from synapdrive_ai.runtime.governed_runtime import GovernedRuntime +from synapdrive_ai.runtime.shadow import ShadowController +from synapdrive_ai.runtime.simplex import SimplexController + + +def test_shared_autonomy_can_choose_lower_risk_machine_action(): + world = WorldModel({"goal": 0}) + world.register(ActionModel("direct", effects={"goal": 1}, predicted_risk=0.8)) + world.register(ActionModel("detour", effects={"goal": 1}, predicted_risk=0.1)) + arbiter = SharedAutonomyArbiter(CounterfactualPlanner(world)) + + proposal = arbiter.propose( + user_intent="direct", + user_confidence=0.8, + machine_candidates=[CandidateAction("detour", 0.95, 0.9)], + goal=lambda state: float( + cast(int | float, state.get("goal", 0)) + ), + ) + + assert proposal.selected_action == "detour" + assert proposal.preserved_user_goal is True + + +def test_expectation_reconciler_requires_explicit_human_expectation(): + delta = ExpectationReconciler().compare(0.8, True) + + assert delta.machine_reality == pytest.approx(0.2) + assert delta.human_reality is None + + +def test_expectation_three_way_delta(): + delta = ExpectationReconciler().compare( + 0.2, + True, + human_expected_success=0.9, + ) + + assert delta.machine_reality == 0.8 + assert delta.human_reality is not None + assert delta.human_reality < delta.machine_reality + assert delta.machine_human == pytest.approx(0.7) + + +def test_simplex_uses_reversionary_controller_when_blocked(): + runtime = GovernedRuntime() + packet = { + "intent": "move_left", + "confidence": 0.1, + "uncertainty": 0.1, + } + decision = runtime.assess(packet) + + simplex = SimplexController( + lambda p: { + "status": "success", + "action": p["intent"], + }, + lambda action: { + "status": "safe-state", + "action": action, + }, + ) + + out = simplex.execute(packet, decision) + + assert out.controller == "reversionary" + assert out.result["action"] == "hold_position" + + +def test_simplex_uses_advanced_controller_when_allowed(): + runtime = GovernedRuntime() + packet = { + "intent": "move_left", + "confidence": 0.95, + "uncertainty": 0.1, + } + decision = runtime.assess(packet) + + simplex = SimplexController( + lambda p: { + "status": "success", + "action": p["intent"], + }, + lambda action: { + "status": "safe-state", + "action": action, + }, + ) + + assert simplex.execute(packet, decision).controller == "advanced" + + +def test_shadow_controller_never_executes_trusted_path(): + calls = [] + + def policy(context): + calls.append(context) + return "experimental", {"score": 0.9} + + shadow = ShadowController(policy) + result = shadow.evaluate("trusted", {"x": 1}) + + assert result.shadow_action == "experimental" + assert result.agreement is False + assert len(calls) == 1 \ No newline at end of file diff --git a/synapdrive_ai/tests/test_v1_signing.py b/synapdrive_ai/tests/test_v1_signing.py new file mode 100644 index 0000000..b032dbc --- /dev/null +++ b/synapdrive_ai/tests/test_v1_signing.py @@ -0,0 +1,29 @@ +from synapdrive_ai.assurance.signing import Ed25519EvidenceSigner, verify_ed25519 + + +def test_ed25519_sign_and_verify(tmp_path): + signer = Ed25519EvidenceSigner.generate() + public = tmp_path / "public.pem" + signer.save_public_pem(public) + payload = b"evidence" + signature = signer.sign(payload) + assert verify_ed25519(public.read_bytes(), payload, signature) is True + + +def test_ed25519_detects_tamper(tmp_path): + signer = Ed25519EvidenceSigner.generate() + public = tmp_path / "public.pem" + signer.save_public_pem(public) + signature = signer.sign(b"evidence") + assert verify_ed25519(public.read_bytes(), b"tampered", signature) is False + + +def test_private_key_roundtrip(tmp_path): + signer = Ed25519EvidenceSigner.generate() + private = tmp_path / "private.pem" + public = tmp_path / "public.pem" + signer.save_private_pem(private) + signer.save_public_pem(public) + loaded = Ed25519EvidenceSigner.load_private_pem(private) + signature = loaded.sign(b"abc") + assert verify_ed25519(public.read_bytes(), b"abc", signature) is True diff --git a/synapdrive_ai/tests/test_web_dashboard.py b/synapdrive_ai/tests/test_web_dashboard.py index f862a3f..266efa5 100644 --- a/synapdrive_ai/tests/test_web_dashboard.py +++ b/synapdrive_ai/tests/test_web_dashboard.py @@ -1,44 +1,57 @@ from synapdrive_ai.interface import web_dashboard -def test_dashboard_home_route(): +def test_dashboard_home_route() -> None: client = web_dashboard.app.test_client() - r = client.get("/") - assert r.status_code == 200 - assert b"SynapDrive-AI Dashboard" in r.data + response = client.get("/") + assert response.status_code == 200 + assert b"SynapDrive-AI Dashboard" in response.data -def test_dashboard_text_api(): +def test_dashboard_text_api() -> None: client = web_dashboard.app.test_client() - r = client.post("/api/run/text", json={"text": "stop", "image": "hazard"}) - assert r.status_code == 200 - payload = r.get_json() + response = client.post("/api/run/text", json={"text": "stop", "image": "hazard"}) + assert response.status_code == 200 + payload = response.get_json() assert "status" in payload assert "result" in payload assert payload["result"]["status"] in {"success", "blocked"} -def test_dashboard_log_api(): +def test_dashboard_log_api() -> None: client = web_dashboard.app.test_client() - # generate one action so log is non-empty client.post("/api/run/text", json={"text": "move left", "image": "road"}) - r = client.get("/api/log") - assert r.status_code == 200 - payload = r.get_json() + response = client.get("/api/log") + assert response.status_code == 200 + payload = response.get_json() assert "count" in payload assert "log" in payload assert isinstance(payload["log"], list) -def test_dashboard_assurance_api(): +def test_dashboard_assurance_api() -> None: client = web_dashboard.app.test_client() client.post("/api/run/text", json={"text": "move left", "image": "road"}) - r = client.get("/api/assurance") - assert r.status_code == 200 - payload = r.get_json() + response = client.get("/api/assurance") + assert response.status_code == 200 + payload = response.get_json() assert "report" in payload assert "receipts" in payload assert payload["report"]["total_cycles"] >= 1 assert isinstance(payload["receipts"], list) + + +def test_dashboard_rejects_non_object_json() -> None: + client = web_dashboard.app.test_client() + response = client.post("/api/run/text", json=["move left"]) + assert response.status_code == 400 + assert response.get_json()["error"] == "invalid-request" + + +def test_dashboard_unknown_api_route_is_404() -> None: + client = web_dashboard.app.test_client() + response = client.get("/api/does-not-exist") + assert response.status_code == 404 + assert response.get_json()["error"] == "not-found" diff --git a/synapdrive_ai/vision/__init__.py b/synapdrive_ai/vision/__init__.py new file mode 100644 index 0000000..bcca85f --- /dev/null +++ b/synapdrive_ai/vision/__init__.py @@ -0,0 +1 @@ +"""Declared visual-context helpers with explicit provenance boundaries.""" diff --git a/synapdrive_ai/vision/visual_inference.py b/synapdrive_ai/vision/visual_inference.py index 2456936..457fd94 100644 --- a/synapdrive_ai/vision/visual_inference.py +++ b/synapdrive_ai/vision/visual_inference.py @@ -1,13 +1,46 @@ -import random +from __future__ import annotations +from dataclasses import dataclass +from typing import Dict -class VisualInferenceEngine: + +@dataclass(frozen=True) +class VisualContext: + """Declared visual context, not computer-vision inference. + + The current repository does not ship an image model. A caller may provide a label + such as ``road`` or ``hazard`` to exercise context-aware control logic. The returned + certainty therefore describes whether the declaration mapped to a known vocabulary, + not confidence produced by a perception model. """ - Simulates basic vision processing for real-world awareness integration. - Can be expanded with real models for robotics vision or camera inputs. + + visual_tag: str + recognized: bool + evidence_kind: str = "declared-label" + model_used: bool = False + + @property + def certainty(self) -> float: + return 1.0 if self.recognized else 0.0 + + def to_dict(self) -> Dict[str, object]: + return { + "visual_tag": self.visual_tag, + "certainty": self.certainty, + "recognized": self.recognized, + "evidence_kind": self.evidence_kind, + "model_used": self.model_used, + } + + +class VisualInferenceEngine: + """Backward-compatible mapper for caller-declared visual labels. + + The historical class name is retained so existing imports keep working. This class + performs no image inference and intentionally contains no pseudo-random confidence. """ - def __init__(self): + def __init__(self) -> None: self.categories = { "road": "navigation_path", "person": "human_detected", @@ -16,20 +49,8 @@ def __init__(self): "none": "no_visual_target", } - def infer(self, image_label): - """ - Simulates classification of a visual frame by label input. - - Args: - image_label (str): Simplified label like "road", "person", etc. - - Returns: - dict: { - 'visual_tag': str, - 'certainty': float - } - """ - tag = self.categories.get(image_label, "unknown_visual") - certainty = round(random.uniform(0.7, 0.99), 2) if tag != "unknown_visual" else 0.4 - - return {"visual_tag": tag, "certainty": certainty} + def infer(self, image_label: str | None) -> Dict[str, object]: + normalized = (image_label or "").strip().lower() + if normalized in self.categories: + return VisualContext(self.categories[normalized], True).to_dict() + return VisualContext("unknown_visual", False).to_dict()