mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 377413e6a8 | |||
| b9e36a8be0 | |||
| 9e860c3a7a |
@@ -1 +0,0 @@
|
||||
{"intelligence":7,"timestamp":1774922079152}
|
||||
@@ -1,55 +1,50 @@
|
||||
{
|
||||
"running": true,
|
||||
"startedAt": "2026-05-24T22:26:25.030Z",
|
||||
"startedAt": "2026-03-09T15:26:00.921Z",
|
||||
"workers": {
|
||||
"map": {
|
||||
"runCount": 64,
|
||||
"successCount": 64,
|
||||
"runCount": 49,
|
||||
"successCount": 49,
|
||||
"failureCount": 0,
|
||||
"averageDurationMs": 136.171875,
|
||||
"lastRun": "2026-05-25T06:07:33.387Z",
|
||||
"lastStartedAt": "2026-05-25T06:07:33.381Z",
|
||||
"nextRun": "2026-05-25T06:26:25.410Z",
|
||||
"averageDurationMs": 1.2857142857142858,
|
||||
"lastRun": "2026-02-28T16:13:19.194Z",
|
||||
"nextRun": "2026-03-09T15:56:00.928Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"audit": {
|
||||
"runCount": 72,
|
||||
"successCount": 27,
|
||||
"runCount": 45,
|
||||
"successCount": 0,
|
||||
"failureCount": 45,
|
||||
"averageDurationMs": 26260.11111111111,
|
||||
"lastRun": "2026-05-25T06:08:29.594Z",
|
||||
"lastStartedAt": "2026-05-25T06:07:33.416Z",
|
||||
"nextRun": "2026-05-25T06:18:32.928Z",
|
||||
"averageDurationMs": 0,
|
||||
"lastRun": "2026-03-09T15:43:00.933Z",
|
||||
"nextRun": "2026-03-09T15:38:00.914Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"optimize": {
|
||||
"runCount": 54,
|
||||
"successCount": 9,
|
||||
"failureCount": 45,
|
||||
"averageDurationMs": 40303.377578766485,
|
||||
"lastRun": "2026-05-25T05:59:05.330Z",
|
||||
"lastStartedAt": "2026-05-25T05:54:05.318Z",
|
||||
"nextRun": "2026-05-25T06:20:15.145Z",
|
||||
"runCount": 34,
|
||||
"successCount": 0,
|
||||
"failureCount": 34,
|
||||
"averageDurationMs": 0,
|
||||
"lastRun": "2026-02-28T16:23:19.387Z",
|
||||
"nextRun": "2026-03-09T15:45:00.915Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"consolidate": {
|
||||
"runCount": 32,
|
||||
"successCount": 32,
|
||||
"runCount": 23,
|
||||
"successCount": 23,
|
||||
"failureCount": 0,
|
||||
"averageDurationMs": 4.71875,
|
||||
"lastRun": "2026-05-25T05:38:20.449Z",
|
||||
"lastStartedAt": "2026-05-25T05:38:20.443Z",
|
||||
"nextRun": "2026-05-25T06:32:25.248Z",
|
||||
"averageDurationMs": 0.6521739130434783,
|
||||
"lastRun": "2026-02-28T16:05:19.091Z",
|
||||
"nextRun": "2026-03-09T16:02:00.918Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"testgaps": {
|
||||
"runCount": 100,
|
||||
"successCount": 63,
|
||||
"failureCount": 37,
|
||||
"averageDurationMs": 108604.0537328991,
|
||||
"lastRun": "2026-05-25T06:11:52.529Z",
|
||||
"lastStartedAt": "2026-05-25T06:07:33.390Z",
|
||||
"nextRun": "2026-05-25T06:14:25.296Z",
|
||||
"runCount": 27,
|
||||
"successCount": 0,
|
||||
"failureCount": 27,
|
||||
"averageDurationMs": 0,
|
||||
"lastRun": "2026-02-28T16:08:19.369Z",
|
||||
"nextRun": "2026-03-09T15:54:00.920Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"predict": {
|
||||
@@ -69,8 +64,8 @@
|
||||
},
|
||||
"config": {
|
||||
"autoStart": false,
|
||||
"logDir": "C:\\Users\\ruv\\Projects\\wifi-densepose\\.claude-flow\\logs",
|
||||
"stateFile": "C:\\Users\\ruv\\Projects\\wifi-densepose\\.claude-flow\\daemon-state.json",
|
||||
"logDir": "/Users/cohen/GitHub/ruvnet/RuView/.claude-flow/logs",
|
||||
"stateFile": "/Users/cohen/GitHub/ruvnet/RuView/.claude-flow/daemon-state.json",
|
||||
"maxConcurrent": 2,
|
||||
"workerTimeoutMs": 300000,
|
||||
"resourceThresholds": {
|
||||
@@ -136,5 +131,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"savedAt": "2026-05-25T06:11:52.530Z"
|
||||
"savedAt": "2026-03-09T15:43:00.933Z"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
31273
|
||||
@@ -1,119 +0,0 @@
|
||||
{
|
||||
"id": "aether-arena-aa",
|
||||
"name": "AetherArena (AA) — Official Spatial-Intelligence Benchmark",
|
||||
"adr": "ADR-149",
|
||||
"adrPath": "docs/adr/ADR-149-public-community-leaderboard-huggingface.md",
|
||||
"status": "Accepted",
|
||||
"initializedDate": "2026-05-30",
|
||||
"targetDate": "2026-08-31",
|
||||
"exitCriteria": "Benchmark INFRASTRUCTURE done, tested, CI-gated, deploy-ready: aa_score_runner.rs passes deterministic fixture test; CI harness-gate green on every PR; aether-arena repo scaffold committed (README four-part framing + aa-submission.toml schema + VERIFY.md); public smoke split committed; HF Space lifecycle skeleton deployed; signed Parquet ledger functional; RuView baseline PCK@20 ~2.5% entered; ADR-149 §7 acceptance test (five-step stranger test) passes. NOTE: ML SOTA (MM-Fi PCK@20 ~72%) is a separate long-running stretch goal blocked on ADR-079 camera-ground-truth — it is NOT an infra exit criterion.",
|
||||
"baselineState": {
|
||||
"adrStatus": "Accepted, committed 2026-05-30",
|
||||
"scorerCode": "ruview_metrics.rs + ablation.rs + proof.rs exist in wifi-densepose-train; aa_score_runner.rs not yet created",
|
||||
"aetherArenaRepo": "does not exist yet — needs user authorization to create ruvnet/aether-arena public repo",
|
||||
"hfSpace": "does not exist yet — needs HF_TOKEN and user authorization to deploy ruvnet/aether-arena HF Space",
|
||||
"smokeDataset": "not committed",
|
||||
"resultsLedger": "not created",
|
||||
"ruviewBaseline": "PCK@20 ~2.5% self-reported, not formally entered",
|
||||
"ciGate": "not added to workflow"
|
||||
},
|
||||
"milestones": {
|
||||
"m1": {
|
||||
"name": "ADR-149 Accepted + committed",
|
||||
"status": "DONE",
|
||||
"completedDate": "2026-05-30",
|
||||
"completionCriteria": "ADR-149 file committed to docs/adr/ with status Accepted",
|
||||
"notes": "Done this session. File at docs/adr/ADR-149-public-community-leaderboard-huggingface.md"
|
||||
},
|
||||
"m2": {
|
||||
"name": "Deterministic scorer runner bin (aa_score_runner.rs)",
|
||||
"status": "NOT_STARTED",
|
||||
"completionCriteria": "aa_score_runner.rs compiles, runs ruview_metrics on a committed fixture, emits RuViewTier + SHA-256 proof hash, mirrors existing *_proof_runner.rs pattern; cargo test passes",
|
||||
"estimatedEffort": "3-5 days",
|
||||
"owner": "wifi-densepose-train crate or new aa-scorer crate"
|
||||
},
|
||||
"m3": {
|
||||
"name": "CI harness-gate: GitHub Actions workflow",
|
||||
"status": "NOT_STARTED",
|
||||
"completionCriteria": "A GitHub Actions workflow runs aa_score_runner on every PR as a build gate; PR fails if scorer fails determinism check; workflow committed and green",
|
||||
"estimatedEffort": "2-3 days",
|
||||
"dependency": "M2 must be done first"
|
||||
},
|
||||
"m4": {
|
||||
"name": "aether-arena repo scaffold",
|
||||
"status": "NOT_STARTED",
|
||||
"completionCriteria": "ruvnet/aether-arena repo created with: README (four-part framing: Public leaderboard / Private eval split / Open scorer / Signed results); aa-submission.toml manifest schema; VERIFY.md (ADR-149 §7 stranger acceptance test); neutrality/governance section (§2.8); contribution guide",
|
||||
"estimatedEffort": "3-5 days",
|
||||
"blockers": ["Needs user authorization to create public ruvnet/aether-arena repo on GitHub"]
|
||||
},
|
||||
"m5": {
|
||||
"name": "Public smoke split committed + private MM-Fi held-out split prep",
|
||||
"status": "NOT_STARTED",
|
||||
"completionCriteria": "Public smoke split committed to aether-arena repo (stranger can score locally); private MM-Fi held-out split prepared under non-public path with CC BY-NC 4.0 attribution; Wi-Pose explicitly excluded from v0",
|
||||
"estimatedEffort": "5-7 days",
|
||||
"riskNotes": "MM-Fi CC BY-NC 4.0: AA must remain non-commercial and carry MM-Fi attribution; raw frames stay in private split; only derived CSI features + scores may be exposed"
|
||||
},
|
||||
"m6": {
|
||||
"name": "HF Space (Gradio) skeleton",
|
||||
"status": "BLOCKED",
|
||||
"completionCriteria": "HF Space deployed at ruvnet/aether-arena with submission lifecycle (submitted->validated->quarantined->smoke_scored->full_scored->published/rejected); sandboxed scorer container wired; basic leaderboard table rendered",
|
||||
"estimatedEffort": "7-10 days",
|
||||
"blockers": [
|
||||
"Needs HF_TOKEN — check .env for HF_TOKEN or HUGGINGFACE_TOKEN",
|
||||
"Needs user authorization to create/deploy ruvnet/aether-arena HF Space (outward-facing public deployment)"
|
||||
]
|
||||
},
|
||||
"m7": {
|
||||
"name": "Signed append-only Parquet results ledger",
|
||||
"status": "NOT_STARTED",
|
||||
"completionCriteria": "HF dataset ruvnet/aether-arena-results created; append-only Parquet ledger with signed rows; determinism_gate enforced; no row can be silently edited",
|
||||
"estimatedEffort": "3-5 days",
|
||||
"ledgerSchema": "submitter, model_ref, category, feature_set, tier, pck20, oks, mota, vitals_bpm_err, latency_p50, latency_p95, privacy_leakage, cross_room_deg, proof_sha256, scored_at, harness_version",
|
||||
"dependency": "M6 must be scaffolded first"
|
||||
},
|
||||
"m8": {
|
||||
"name": "RuView baseline entry + public launch",
|
||||
"status": "NOT_STARTED",
|
||||
"completionCriteria": "RuView wifi-densepose-pretrained baseline entered (honest PCK@20 ~2.5%); ADR-149 §7 five-step stranger acceptance test passes; v0 live with Presence + Pose + Edge-latency + Determinism categories active; Privacy and Cross-room shown as gated/coming-soon",
|
||||
"estimatedEffort": "3-5 days",
|
||||
"dependency": "M4+M5+M6+M7 complete",
|
||||
"notes": "ML SOTA improvement (PCK@20 ~72%) is a SEPARATE stretch goal blocked on ADR-079 P7-P9 camera ground truth. NOT a blocker for infra launch."
|
||||
}
|
||||
},
|
||||
"activeMilestone": "m2",
|
||||
"completedMilestones": ["m1"],
|
||||
"knownRisks": [
|
||||
"HF_TOKEN not confirmed present in .env — check before M6 work begins",
|
||||
"ruvnet/aether-arena public repo creation is outward-facing — needs explicit user authorization",
|
||||
"MM-Fi CC BY-NC 4.0: AA must stay legally non-commercial and brand-distinct from commercial RuView product; or seek MM-Fi commercial grant before any paid tier",
|
||||
"Wi-Pose has research-use-only terms (no redistribution grant) — excluded from v0; revisit only if terms are clarified with authors",
|
||||
"HF Space free CPU tier may be too slow for Candle/tch inference pipeline — may need ZeroGPU or self-hosted scorer on cognitum-20260110 GCloud A100/L4",
|
||||
"ADR-079 camera-ground-truth (PCK@20 SOTA) is P7-P9 pending — NOT an infra blocker; must not be conflated with AA infra completion",
|
||||
"Neutrality/governance risk: RuView seeded the scorer — must be demonstrably scored through the same public pipeline as any other entrant (§2.8 controls)"
|
||||
],
|
||||
"driftSignals": {
|
||||
"timeline": "GREEN — just initialized, no timeline pressure yet",
|
||||
"scope": "GREEN — scope locked at four-part structure per ADR-149 §2 decision",
|
||||
"approach": "GREEN — reuse pattern (existing ruview_metrics + proof.rs) confirmed in ADR-149",
|
||||
"dependency": "YELLOW — HF_TOKEN and ruvnet/aether-arena repo authorization are external blockers with unknown ETA",
|
||||
"priority": "GREEN — active feature branch feat/adr-136-146-streaming-engine in progress; AA infra can proceed in parallel on its own branch"
|
||||
},
|
||||
"stretchGoals": {
|
||||
"sotaML": "MM-Fi PCK@20 SOTA ~72% — separate ML effort blocked on ADR-079 P7-P9 camera-ground-truth data collection; NOT an infra exit criterion",
|
||||
"privacyAxis": "ADR-145 §10 membership-inference attacker — activate Privacy leaderboard axis once attacker is implemented and published",
|
||||
"crossRoom": "Multi-room held-out split — activate Cross-room generalization axis",
|
||||
"multiOrgSteering": "Invite co-maintainers from other projects once >=N external entries land"
|
||||
},
|
||||
"sessionHistory": [
|
||||
{
|
||||
"date": "2026-05-30",
|
||||
"type": "initialization",
|
||||
"accomplished": [
|
||||
"ADR-149 Accepted and committed to docs/adr/",
|
||||
"Horizon record initialized in .claude-flow/horizons/aether-arena-aa.json",
|
||||
"Memory stored in horizons namespace under key horizon-aether-arena-aa",
|
||||
"Session check-in record stored in horizon-sessions namespace"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"timestamp": "2026-05-25T06:07:33.385Z",
|
||||
"projectRoot": "C:\\Users\\ruv\\Projects\\wifi-densepose",
|
||||
"timestamp": "2026-02-28T16:13:19.193Z",
|
||||
"projectRoot": "/home/user/wifi-densepose",
|
||||
"structure": {
|
||||
"hasPackageJson": false,
|
||||
"hasTsConfig": false,
|
||||
"hasClaudeConfig": true,
|
||||
"hasClaudeFlow": true
|
||||
},
|
||||
"scannedAt": 1779689253386
|
||||
"scannedAt": 1772295199193
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"timestamp": "2026-05-25T05:38:20.448Z",
|
||||
"timestamp": "2026-02-28T16:05:19.091Z",
|
||||
"patternsConsolidated": 0,
|
||||
"memoryCleaned": 0,
|
||||
"duplicatesRemoved": 0
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"timestamp": "2026-05-25T05:59:05.405Z",
|
||||
"mode": "local",
|
||||
"memoryUsage": {
|
||||
"rss": 9891840,
|
||||
"heapTotal": 35598336,
|
||||
"heapUsed": 26516560,
|
||||
"external": 3952418,
|
||||
"arrayBuffers": 55689
|
||||
},
|
||||
"uptime": 27163.5846658,
|
||||
"optimizations": {
|
||||
"cacheHitRate": 0.78,
|
||||
"avgResponseTime": 45
|
||||
},
|
||||
"note": "Install Claude Code CLI for AI-powered optimization suggestions"
|
||||
}
|
||||
@@ -1,84 +1,12 @@
|
||||
{
|
||||
"timestamp": "2026-05-25T06:08:29.589Z",
|
||||
"mode": "headless",
|
||||
"workerType": "audit",
|
||||
"model": "haiku",
|
||||
"durationMs": 56168,
|
||||
"executionId": "audit_1779689253421_dfflmb",
|
||||
"success": true,
|
||||
"findings": {
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"severity": "high",
|
||||
"file": ".claude/helpers/github-safe.js",
|
||||
"line": 50,
|
||||
"description": "Command injection vulnerability in execSync call. User-controlled arguments in `newArgs` are joined without shell escaping. An attacker can inject shell metacharacters (e.g., `; rm -rf /`) via the body content or through command/subcommand parameters. The temp file approach is safe, but the command construction `gh ${command} ${subcommand} ${newArgs.join(' ')}` allows shell injection.",
|
||||
"example": "gh issue comment 123 'test`whoami`' would execute whoami"
|
||||
},
|
||||
{
|
||||
"severity": "high",
|
||||
"file": "scripts/csi-spectrogram.js",
|
||||
"line": 45,
|
||||
"description": "Sensitive credential exposure via command-line arguments. The `--seed-token` parameter is passed as a CLI argument, which is visible in process listings (ps aux output). This violates secure credential handling practices. Tokens should be read from environment variables or secure config files, not command-line args.",
|
||||
"example": "node scripts/csi-spectrogram.js --seed-token secret_abc_123 exposes token in process list"
|
||||
},
|
||||
{
|
||||
"severity": "medium",
|
||||
"file": "scripts/apnea-detector.js",
|
||||
"line": 71,
|
||||
"description": "Unsafe buffer reading without comprehensive length validation. The code checks `buf.length` at 32 bytes (line 70) but then reads at fixed offsets (lines 72-76) without validating that each read stays within bounds. If a malformed packet is received, `readInt8/readUInt16LE/readUInt32LE` may read unintended data or zeros.",
|
||||
"example": "A 33-byte buffer would pass the check but reading UInt32LE at offset 8 would go out of bounds"
|
||||
},
|
||||
{
|
||||
"severity": "medium",
|
||||
"file": "scripts/benchmark-rf-scan.js",
|
||||
"line": 110,
|
||||
"description": "Potential out-of-bounds buffer access in parseCSIFrame. While the bounds check at line 107 is present, the `nSubcarriers` value from the packet is used to calculate required buffer size without validation of the value itself. A maliciously crafted packet with extremely large nSubcarriers could cause memory issues.",
|
||||
"example": "Packet with nSubcarriers=999999 would request excessive buffer allocation"
|
||||
},
|
||||
{
|
||||
"severity": "medium",
|
||||
"file": "scripts/csi-spectrogram.js",
|
||||
"line": 39,
|
||||
"description": "Unsafe URL construction with untrusted `seed-url` parameter. The `--seed-url` argument is used directly for HTTPS requests without validation. This could allow SSRF (Server-Side Request Forgery) or DNS rebinding attacks if an attacker controls the seed URL.",
|
||||
"example": "node scripts/csi-spectrogram.js --seed-url http://internal.local:9000 could access internal services"
|
||||
},
|
||||
{
|
||||
"severity": "low",
|
||||
"file": ".claude/helpers/statusline.js",
|
||||
"line": 140,
|
||||
"description": "Shell command injection risk in execSync calls. Commands like `ps aux 2>/dev/null | grep -c agentic-flow` use grep patterns that could be vulnerable if any variables are interpolated (though currently hardcoded). The `execSync` with shell=true is generally risky.",
|
||||
"example": "If any pattern becomes user-controlled: `grep -c ${pattern}` could inject shell metacharacters"
|
||||
},
|
||||
{
|
||||
"severity": "low",
|
||||
"file": ".claude/helpers/memory.js",
|
||||
"line": 10,
|
||||
"description": "Unvalidated JSON parsing. The code parses JSON from MEMORY_FILE without try-catch in the loadMemory function (catches error but doesn't validate structure). Malformed JSON or corrupted memory file could cause issues.",
|
||||
"example": "Memory file with circular JSON structure could cause issues when stringifying"
|
||||
},
|
||||
{
|
||||
"severity": "low",
|
||||
"file": "scripts/device-fingerprint.js",
|
||||
"line": 72,
|
||||
"description": "Hardcoded device fingerprints and network configuration. While not a traditional 'hardcoded secret', the KNOWN_DEVICES array contains identifiable SSIDs and MAC addresses that could be used to correlate network infrastructure. This data should be externalized or sanitized.",
|
||||
"example": "SSID 'ruv.net' and 'Cohen-Guest' could identify specific installations"
|
||||
}
|
||||
],
|
||||
"riskScore": 42,
|
||||
"recommendations": [
|
||||
"**CRITICAL**: Replace `execSync` command construction in github-safe.js with proper shell escaping using `child_process.execFile()` instead of `execSync()`, or use the `shell: false` option with array arguments to avoid shell parsing entirely.",
|
||||
"**CRITICAL**: Move `--seed-token` from CLI arguments to environment variable `SEED_TOKEN` in csi-spectrogram.js. Update documentation to instruct users: `export SEED_TOKEN=...` instead of passing via CLI.",
|
||||
"**HIGH**: Add comprehensive buffer bounds validation in all UDP packet parsing functions (apnea-detector.js, benchmark-rf-scan.js, etc.). Validate both the buffer length AND the parsed header values before using them in calculations.",
|
||||
"**HIGH**: Validate and sanitize the `--seed-url` parameter in csi-spectrogram.js. Whitelist allowed domains or restrict to localhost/internal IPs only. Add URL scheme validation (https only).",
|
||||
"**MEDIUM**: Replace hardcoded device fingerprints (KNOWN_DEVICES) with externalized configuration or environment variables. Document that this data contains identifiable network information.",
|
||||
"**MEDIUM**: Add input validation to `parseArgs()` results in all scripts. Validate numeric ranges, file paths, and enum values before use.",
|
||||
"**LOW**: Wrap JSON.parse() calls in try-catch blocks throughout (memory.js, session.js) with explicit error handling and recovery.",
|
||||
"**LOW**: Audit all uses of `require()` with dynamic paths. Ensure paths are always derived from fixed `__dirname` and not user-controlled.",
|
||||
"**LOW**: Remove or sandbox the ability to pass arbitrary URLs via CLI. Consider using a configuration file (YAML/JSON) for endpoint URLs instead.",
|
||||
"**INFO**: Add a pre-commit hook to detect hardcoded credentials using tools like `detect-secrets` or `truffleHog`."
|
||||
]
|
||||
"timestamp": "2026-03-06T13:17:27.368Z",
|
||||
"mode": "local",
|
||||
"checks": {
|
||||
"envFilesProtected": true,
|
||||
"gitIgnoreExists": true,
|
||||
"noHardcodedSecrets": true
|
||||
},
|
||||
"rawOutputPreview": "# Security Audit Report — wifi-densepose\n\n```json\n{\n \"vulnerabilities\": [\n {\n \"severity\": \"high\",\n \"file\": \".claude/helpers/github-safe.js\",\n \"line\": 50,\n \"description\": \"Command injection vulnerability in execSync call. User-controlled arguments in `newArgs` are joined without shell escaping. An attacker can inject shell metacharacters (e.g., `; rm -rf /`) via the body content or through command/subcommand parameters. The temp file approach is safe, but the command construction `gh ${command} ${subcommand} ${newArgs.join(' ')}` allows shell injection.\",\n \"example\": \"gh issue comment 123 'test`whoami`' would execute whoami\"\n },\n {\n \"severity\": \"high\",\n \"file\": \"scripts/csi-spectrogram.js\",\n \"line\": 45,\n \"description\": \"Sensitive credential exposure via command-line arguments. The `--seed-token` parameter is passed as a CLI argument, which is visible in process listings (ps aux output). This violates secure credential handling practices. Tokens should be read from environment variables or secure config files, not command-line args.\",\n \"example\": \"node scripts/csi-spectrogram.js --seed-token secret_abc_123 exposes token in process list\"\n },\n {\n \"severity\": \"medium\",\n \"file\": \"scripts/apnea-detector.js\",\n \"line\": 71,\n \"description\": \"Unsafe buffer reading without comprehensive length validation. The code checks `buf.length` at 32 bytes (line 70) but then reads at fixed offsets (lines 72-76) without validating that each read stays within bounds. If a malformed packet is received, `readInt8/readUInt16LE/readUInt32LE` may read unintended data or zeros.\",\n \"example\": \"A 33-byte buffer would pass the check but reading UInt32LE at offset 8 would go out of bounds\"\n },\n {\n \"severity\": \"medium\",\n \"file\": \"scripts/benchmark-rf-scan.js\",\n \"line\": 110,\n \"description\": \"Potential out-of-bounds buffer access in parseCSIFrame. While the bounds check at line 107 is pres",
|
||||
"rawOutputLength": 7077
|
||||
"riskLevel": "low",
|
||||
"recommendations": [],
|
||||
"note": "Install Claude Code CLI for AI-powered security analysis"
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
{
|
||||
"timestamp": "2026-05-25T06:11:52.519Z",
|
||||
"mode": "headless",
|
||||
"workerType": "testgaps",
|
||||
"model": "sonnet",
|
||||
"durationMs": 259124,
|
||||
"executionId": "testgaps_1779689253395_srltd5",
|
||||
"success": true,
|
||||
"findings": {
|
||||
"sections": [
|
||||
{
|
||||
"title": "Test Coverage Gap Analysis — wifi-densepose",
|
||||
"content": "\n",
|
||||
"level": 2
|
||||
},
|
||||
{
|
||||
"title": "Coverage Summary by Crate",
|
||||
"content": "\n| Crate | Tests Found | Status | Priority |\n|-------|-------------|--------|----------|\n| `wifi-densepose-core` | 26 inline | Good | Low |\n| `wifi-densepose-signal` | ~60 (validation only) | Moderate | **High** |\n| `wifi-densepose-nn` | **0** | Critical | **P1** |\n| `wifi-densepose-train` | ~60 (config/dataset) | Moderate | High |\n| `wifi-densepose-mat` | 1 integration test | Critical | **P1** |\n| `wifi-densepose-ruvector` | **0** | Critical | **P1** |\n| `wifi-densepose-sensing-server` | 4 integration tests | Moderate | High |\n| `wifi-densepose-wasm` | 3 compliance tests | Low | Low |\n\n---\n\n",
|
||||
"level": 3
|
||||
},
|
||||
{
|
||||
"title": "Tier 1: Critical Gaps",
|
||||
"content": "\n",
|
||||
"level": 2
|
||||
},
|
||||
{
|
||||
"title": "1. `wifi-densepose-nn` — Zero test coverage",
|
||||
"content": "\nEvery public API is untested. Place these at `v2/crates/wifi-densepose-nn/tests/inference_tests.rs`:\n\n```rust\n// v2/crates/wifi-densepose-nn/tests/inference_tests.rs\n\n#[cfg(test)]\nmod tensor_tests {\n use wifi_densepose_nn::tensor::Tensor;\n\n #[test]\n fn tensor_shape_mismatch_returns_error() {\n // data has 6 elements but shape claims 3×3=9\n let result = Tensor::new(vec![1.0f32; 6], &[3, 3]);\n assert!(result.is_err(), \"shape mismatch must be rejected\");\n }\n\n #[test]\n fn tensor_empty_data_returns_error() {\n let result = Tensor::new(vec![], &[0]);\n assert!(result.is_err());\n }\n\n #[test]\n fn tensor_nan_values_are_detected() {\n let t = Tensor::new(vec![f32::NAN, 1.0, 2.0], &[3]).unwrap();\n assert!(t.has_nan(), \"NaN in data must be detectable\");\n }\n\n #[test]\n fn tensor_inf_values_are_detected() {\n let t = Tensor::new(vec![f32::INFINITY, 1.0], &[2]).unwrap();\n assert!(t.has_inf());\n }\n}\n\n#[cfg(test)]\nmod modality_translator_tests {\n use wifi_densepose_nn::translator::ModalityTranslator;\n\n #[test]\n fn translator_rejects_wrong_subcarrier_count() {\n // standard expects 56 subcarriers; feed 57\n let csi = vec![0.0f32; 57 * 3]; // 57 subcarriers × 3 antennas\n let translator = ModalityTranslator::default();\n let result = translator.translate(&csi, 57, 3);\n assert!(result.is_err());\n }\n\n #[test]\n fn translator_handles_all_zeros() {\n let csi = vec![0.0f32; 56 * 3];\n let translator = ModalityTranslator::default();\n let result = translator.translate(&csi, 56, 3);\n // zero input should produce some output without panic\n assert!(result.is_ok());\n }\n}\n\n#[cfg(test)]\nmod inference_engine_tests {\n use wifi_densepose_nn::inference::InferenceEngine;\n\n #[test]\n fn load_nonexistent_model_returns_error() {\n let result = InferenceEngine::from_path(\"/nonexistent/model.onnx\");\n assert!(result.is_err());\n }\n\n #[test]\n fn load_corrupted_bytes_returns_error() {\n let tmp = tempfile::NamedTempFile::new().unwrap();\n std::fs::write(tmp.path(), b\"not a valid onnx file\").unwrap();\n let result = InferenceEngine::from_path(tmp.path());\n assert!(result.is_err());\n }\n\n #[test]\n fn batch_size_zero_returns_error() {\n // can't run inference on an empty batch\n // requires a valid model; skip if no model file in test fixtures\n // use #[ignore] or a feature flag for CI\n }\n}\n```\n\n---\n\n",
|
||||
"level": 3
|
||||
},
|
||||
{
|
||||
"title": "2. `wifi-densepose-mat` — Disaster response safety gaps",
|
||||
"content": "\nPlace at `v2/crates/wifi-densepose-mat/tests/`:\n\n```rust\n// v2/crates/wifi-densepose-mat/tests/detection_edge_cases.rs\n\n#[cfg(test)]\nmod breathing_rate_edge_cases {\n use wifi_densepose_mat::detection::breathing::BreathingDetector;\n\n #[test]\n fn zero_bpm_is_classified_critical() {\n let detector = BreathingDetector::default();\n // flat-line signal — no breathing detected\n let signal = vec![0.0f32; 1000];\n let result = detector.classify(&signal).unwrap();\n assert_eq!(result.triage_category, TriageCategory::Immediate);\n }\n\n #[test]\n fn agonal_breathing_rate_triggers_immediate() {\n // < 6 BPM is agonal; simulate 3 BPM signal\n let detector = BreathingDetector::default();\n let signal = generate_breathing_signal(3.0, 1000, 100.0); // 3 BPM, 1000 samples @ 100 Hz\n let result = detector.classify(&signal).unwrap();\n assert_eq!(result.triage_category, TriageCategory::Immediate);\n }\n\n #[test]\n fn normal_breathing_is_classified_minor() {\n let detector = BreathingDetector::default();\n let signal = generate_breathing_signal(15.0, 1000, 100.0); // 15 BPM\n let result = detector.classify(&signal).unwrap();\n assert_eq!(result.triage_category, TriageCategory::Minor);\n }\n\n #[test]\n fn all_nan_signal_returns_error_not_panic() {\n let detector = BreathingDetector::default();\n let signal = vec![f32::NAN; 1000];\n let result = detector.classify(&signal);\n assert!(result.is_err(), \"NaN input must be caught, not panic\");\n }\n\n fn generate_breathing_signal(bpm: f32, samples: usize, sample_rate: f32) -> Vec<f32> {\n let freq = bpm / 60.0;\n (0..samples)\n .map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate).sin())\n .collect()\n }\n}\n\n#[cfg(test)]\nmod alert_deduplication {\n use wifi_densepose_mat::alerting::{AlertDispatcher, Alert, TriageCategory};\n use std::time::Duration;\n\n #[test]\n fn duplicate_alerts_within_window_are_suppressed() {\n let mut dispatcher = AlertDispatcher::new();\n let alert = Alert::new(\"survivor-1\", TriageCategory::Immediate);\n dispatcher.dispatch(alert.clone());\n dispatcher.dispatch(alert.clone()); // same survivor, same category\n assert_eq!(dispatcher.queued_count(), 1, \"duplicate must be deduplicated\");\n }\n\n #[test]\n fn escalation_from_minor_to_immediate_is_forwarded() {\n let mut dispatcher = AlertDispatcher::new();\n dispatcher.dispatch(Alert::new(\"survivor-1\", TriageCategory::Minor));\n dispatcher.dispatch(Alert::new(\"survivor-1\", TriageCategory::Immediate));\n // escalation is not a duplicate — must pass through\n assert!(dispatcher.last_alert_for(\"survivor-1\").map(|a| a.category) == Some(TriageCategory::Immediate));\n }\n}\n\n#[cfg(test)]\nmod kalman_tracker_edge_cases {\n use wifi_densepose_mat::tracking::KalmanTracker;\n\n #[test]\n fn position_jump_does_not_corrupt_state() {\n let mut tracker = KalmanTracker::new();\n tracker.update([1.0, 1.0, 0.5]); // initial position\n tracker.update([50.0, 50.0, 0.5]); // physically impossible jump\n let pos = tracker.estimated_position();\n // should not panic; should clamp or flag anomaly\n assert!(pos.iter().all(|v| v.is_finite()));\n }\n\n #[test]\n fn lost_track_resumes_on_re_detection() {\n let mut tracker = KalmanTracker::new();\n tracker.update([1.0, 1.0, 0.5]);\n // simulate 10 missed frames\n for _ in 0..10 { tracker.predict(); }\n assert_eq!(tracker.state(), TrackState::Lost);\n tracker.update([1.1, 1.1, 0.5]); // re-detected nearby\n assert_eq!(tracker.state(), TrackState::Confirmed);\n }\n}\n```\n\n---\n\n",
|
||||
"level": 3
|
||||
},
|
||||
{
|
||||
"title": "3. `wifi-densepose-ruvector` — Zero coverage on all 5 integration modules",
|
||||
"content": "\n```rust\n// v2/crates/wifi-densepose-ruvector/tests/viewpoint_tests.rs\n\n#[cfg(test)]\nmod attention_tests {\n use wifi_densepose_ruvector::viewpoint::attention::CrossViewpointAttention;\n\n #[test]\n fn attention_weights_sum_to_one() {\n let attn = CrossViewpointAttention::new(3); // 3 viewpoints\n let features = vec![[1.0f32; 64], [2.0f32; 64], [3.0f32; 64]];\n let weights = attn.compute_weights(&features);\n let sum: f32 = weights.iter().sum();\n assert!((sum - 1.0).abs() < 1e-5, \"attention must be a probability distribution\");\n }\n\n #[test]\n fn single_viewpoint_gets_full_weight() {\n let attn = CrossViewpointAttention::new(1);\n let features = vec![[1.0f32; 64]];\n let weights = attn.compute_weights(&features);\n assert!((weights[0] - 1.0).abs() < 1e-6);\n }\n\n #[test]\n fn zero_feature_vectors_do_not_produce_nan() {\n let attn = CrossViewpointAttention::new(2);\n let features = vec![[0.0f32; 64], [0.0f32; 64]];\n let weights = attn.compute_weights(&features);\n assert!(weights.iter().all(|w| w.is_finite()));\n }\n}\n\n#[cfg(test)]\nmod sketch_tests {\n use wifi_densepose_ruvector::sketch::WireSketch;\n\n #[test]\n fn round_trip_serialization() {\n let sketch = WireSketch::from_keypoints(&[[0.5f32, 0.5], [0.3, 0.7]]);\n let bytes = sketch.to_bytes();\n let restored = WireSketch::from_bytes(&bytes).unwrap();\n assert_eq!(sketch, restored);\n }\n\n #[test]\n fn deserialize_truncated_bytes_returns_error() {\n let sketch = WireSketch::from_keypoints(&[[0.5f32, 0.5]]);\n let mut bytes = sketch.to_bytes();\n bytes.truncate(bytes.len() / 2); // truncate halfway\n assert!(WireSketch::from_bytes(&bytes).is_err());\n }\n\n #[test]\n fn empty_keypoint_list_is_handled() {\n let sketch = WireSketch::from_keypoints(&[]);\n assert_eq!(sketch.keypoint_count(), 0);\n }\n}\n```\n\n---\n\n",
|
||||
"level": 3
|
||||
},
|
||||
{
|
||||
"title": "Tier 2: Signal Processing Gaps",
|
||||
"content": "\n",
|
||||
"level": 2
|
||||
},
|
||||
{
|
||||
"title": "4. `wifi-densepose-signal` — RuvSense module untested",
|
||||
"content": "\n```rust\n// v2/crates/wifi-densepose-signal/tests/ruvsense_tests.rs\n\n#[cfg(test)]\nmod coherence_gate_tests {\n use wifi_densepose_signal::ruvsense::coherence_gate::{CoherenceGate, GateDecision};\n\n #[test]\n fn high_coherence_signal_is_accepted() {\n let gate = CoherenceGate::new(0.7); // threshold = 0.7\n let decision = gate.evaluate(0.95);\n assert_eq!(decision, GateDecision::Accept);\n }\n\n #[test]\n fn low_coherence_signal_is_rejected() {\n let gate = CoherenceGate::new(0.7);\n let decision = gate.evaluate(0.3);\n assert_eq!(decision, GateDecision::Reject);\n }\n\n #[test]\n fn borderline_coherence_triggers_recalibrate() {\n let gate = CoherenceGate::new(0.7);\n let decision = gate.evaluate(0.68); // just below threshold\n assert_eq!(decision, GateDecision::Recalibrate);\n }\n}\n\n#[cfg(test)]\nmod phase_align_tests {\n use wifi_densepose_signal::ruvsense::phase_align::PhaseAligner;\n\n #[test]\n fn phase_at_plus_pi_does_not_wrap_incorrectly() {\n let aligner = PhaseAligner::new();\n let phases = vec![std::f32::consts::PI - 0.001, std::f32::consts::PI + 0.001];\n let aligned = aligner.align(&phases);\n // jump across ±π boundary must be handled continuously\n let diff = (aligned[1] - aligned[0]).abs();\n assert!(diff < 0.01, \"phase jump at ±π must be < 0.01 rad after alignment\");\n }\n\n #[test]\n fn single_phase_value_aligns_to_itself() {\n let aligner = PhaseAligner::new();\n let phases = vec![1.5f32];\n let aligned = aligner.align(&phases);\n assert_eq!(aligned.len(), 1);\n assert!((aligned[0] - 1.5).abs() < 1e-6);\n }\n\n #[test]\n fn empty_phase_array_returns_empty() {\n let aligner = PhaseAligner::new();\n let aligned = aligner.align(&[]);\n assert!(aligned.is_empty());\n }\n}\n\n#[cfg(test)]\nmod adversarial_detection_tests {\n use wifi_densepose_signal::ruvsense::adversarial::AdversarialDetector;\n\n #[test]\n fn physically_impossible_amplitude_is_flagged() {\n let detector = AdversarialDetector::new();\n // WiFi amplitude cannot exceed hardware saturation level\n let frame = vec![1e9f32; 56]; // absurdly large\n assert!(detector.is_suspicious(&frame));\n }\n\n #[test]\n fn normal_amplitude_range_passes() {\n let detector = AdversarialDetector::new();\n let frame = vec![0.5f32; 56]; // typical normalized value\n assert!(!detector.is_suspicious(&frame));\n }\n\n #[test]\n fn multi_link_inconsistency_is_detected() {\n // link A reports body moving right; link B reports no motion\n // physically inconsistent — flag as adversarial\n let detector = AdversarialDetector::new();\n let result = detector.check_multi_link_consistency(\n &[1.0, 2.0, 3.0], // link A\n &[0.0, 0.0, 0.0], // link B (no motion)\n );\n assert!(result.is_inconsistent());\n }\n}\n```\n\n---\n\n",
|
||||
"level": 3
|
||||
},
|
||||
{
|
||||
"title": "Tier 2: Training Pipeline Gaps",
|
||||
"content": "\n",
|
||||
"level": 2
|
||||
},
|
||||
{
|
||||
"title": "5. `wifi-densepose-train` — Geometry encoder and rapid adaptation untested",
|
||||
"content": "\n```rust\n// v2/crates/wifi-densepose-train/tests/test_geometry.rs\n\n#[cfg(test)]\nmod film_layer_tests {\n use wifi_densepose_train::geometry::FilmLayer;\n\n #[test]\n fn film_layer_output_shape_matches_input() {\n let film = FilmLayer::new(64, 32); // 64-dim features, 32-dim condition\n let features = vec![0.5f32; 64];\n let condition = vec![1.0f32; 32];\n let output = film.forward(&features, &condition).unwrap();\n assert_eq!(output.len(), 64, \"FiLM output must match feature dimensionality\");\n }\n\n #[test]\n fn film_layer_zero_condition_acts_as_identity() {\n let film = FilmLayer::new(64, 32);\n let features = vec![1.0f32; 64];\n let zero_condition = vec![0.0f32; 32];\n let output = film.forward(&features, &zero_condition).unwrap();\n // scale=1, shift=0 → identity; output ≈ input\n for (o, f) in output.iter().zip(features.iter()) {\n assert!((o - f).abs() < 0.1, \"zero condition should approximate identity\");\n }\n }\n}\n\n// v2/crates/wifi-densepose-train/tests/test_rapid_adapt.rs\n\n#[cfg(test)]\nmod rapid_adaptation_tests {\n use wifi_densepose_train::rapid_adapt::RapidAdapter;\n\n #[test]\n fn adapter_updates_on_single_sample() {\n let mut adapter = RapidAdapter::new(5); // 5 adaptation steps\n let csi_sample = vec![0.1f32; 56 * 3];\n let pose_label = vec![0.5f32; 17 * 2]; // 17 keypoints × (x, y)\n let result = adapter.adapt_step(&csi_sample, &pose_label);\n assert!(result.is_ok());\n }\n\n #[test]\n fn adapter_with_zero_steps_is_no_op() {\n let adapter = RapidAdapter::new(0);\n // 0 adaptation steps → weights unchanged\n let initial_weights = adapter.clone_weights();\n let _ = adapter.adapt_step(&vec![0.1f32; 168], &vec![0.5f32; 34]);\n assert_eq!(adapter.clone_weights(), initial_weights);\n }\n}\n```\n\n---\n\n",
|
||||
"level": 3
|
||||
},
|
||||
{
|
||||
"title": "Tier 3: Server Integration Gaps",
|
||||
"content": "\n",
|
||||
"level": 2
|
||||
},
|
||||
{
|
||||
"title": "6. `wifi-densepose-sensing-server` — Auth and semantic analyzers",
|
||||
"content": "\n```rust\n// v2/crates/wifi-densepose-sensing-server/tests/auth_tests.rs\n\n#[cfg(test)]\nmod bearer_auth_tests {\n use wifi_densepose_sensing_server::auth::{BearerValidator, TokenError};\n\n #[test]\n fn missing_authorization_header_returns_unauthorized() {\n let validator = BearerValidator::new(\"secret-token\");\n let result = validator.validate(None);\n assert!(matches!(result, Err(TokenError::Missing)));\n }\n\n #[test]\n fn wrong_token_is_rejected() {\n let validator = BearerValidator::new(\"correct-token\");\n let result = validator.validate(Some(\"Bearer wrong-token\"));\n assert!(matches!(result, Err(TokenError::Invalid)));\n }\n\n #[test]\n fn malformed_header_without_bearer_prefix_is_rejected() {\n let validator = BearerValidator::new(\"token\");\n let result = validator.validate(Some(\"token\")); // missing \"Bearer \" prefix\n assert!(matches!(result, Err(TokenError::Malformed)));\n }\n\n #[test]\n fn correct_token_is_accepted() {\n let validator = BearerValidator::new(\"correct-token\");\n let result = validator.validate(Some(\"Bearer correct-token\"));\n assert!(result.is_ok());\n }\n}\n\n// v2/crates/wifi-densepose-sensing-server/tests/semantic_tests.rs\n\n#[cfg(test)]\nmod fall_detection_tests {\n use wifi_densepose_sensing_server::semantic::fall_detector::FallDetector;\n\n #[test]\n fn no_motion_does_not_trigger_fall() {\n let mut detector = FallDetector::new();\n for _ in 0..30 { // 30 frames of stillness\n detector.update_pose(stationary_pose());\n }\n assert!(!detector.fall_detected());\n }\n\n #[test]\n fn rapid_downward_velocity_triggers_fall() {\n let mut detector = FallDetector::new();\n // simulate person going from standing (y=1.7m) to prone (y=0.3m) in 3 frames\n for (frame, y) in [(0, 1.7f32), (1, 1.0), (2, 0.3)] {\n detector.update_pose(pose_at_height(y));\n }\n assert!(detector.fall_detected());\n }\n\n #[test]\n fn sitting_down_slowly_does_not_trigger_fall() {\n let mut detector = FallDetector::new();\n // gradual height decrease over 30 frames is sitting, not falling\n for i in 0..30 {\n let y = 1.7f32 - (i as f32 * 0.04); // ~1.2m drop over 30 frames\n detector.update_pose(pose_at_height(y));\n }\n assert!(!detector.fall_detected());\n }\n}\n```\n\n---\n\n",
|
||||
"level": 3
|
||||
},
|
||||
{
|
||||
"title": "Cross-Cutting Gap Summary",
|
||||
"content": "| Gap Category | Severity | Affects | Recommended Action |\n|---|---|---|---|\n| `wifi-densepose-nn` has 0 tests | **Critical** | Inference pipeline | Add `tests/inference_tests.rs` per skeleton above |\n| `wifi-densepose-ruvector` has 0 tests | **Critical** | Viewpoint fusion, sketches | Add `tests/viewpoint_tests.rs` |\n| MAT disaster response missing edge cases | **Critical** | 0 BPM, agonal breathing, dedup | Add `tests/detection_edge_cases.rs` |\n| Signal RuvSense 28 modules untested | High | Core sensing logic | Add `tests/ruvsense_tests.rs` |\n| NN error paths (bad model files, OOM) | High | Production reliability | Add error path tests to nn |\n| Train geometry + rapid adapt = 0 tests | High | Domain adaptation | Add `tests/test_geometry.rs` |\n| Server auth token validation | High | Security boundary | Add `tests/auth_tests.rs` |\n| NaN/Inf propagation in f32 pipelines | High | All numeric crates | Add boundary tests per module |\n| Concurrent state under Arc<Mutex> | Medium | sensing-server, mat | Add contention tests |\n\nThe highest-ROI starting point is `wifi-densepose-nn` and `wifi-densepose-mat` — the nn crate has zero tests on the core inference pipeline, and mat covers life-safety scenarios where classification errors have real consequences.",
|
||||
"level": 2
|
||||
}
|
||||
],
|
||||
"codeBlocks": [
|
||||
{
|
||||
"language": "rust",
|
||||
"code": "// v2/crates/wifi-densepose-nn/tests/inference_tests.rs\n\n#[cfg(test)]\nmod tensor_tests {\n use wifi_densepose_nn::tensor::Tensor;\n\n #[test]\n fn tensor_shape_mismatch_returns_error() {\n // data has 6 elements but shape claims 3×3=9\n let result = Tensor::new(vec![1.0f32; 6], &[3, 3]);\n assert!(result.is_err(), \"shape mismatch must be rejected\");\n }\n\n #[test]\n fn tensor_empty_data_returns_error() {\n let result = Tensor::new(vec![], &[0]);\n assert!(result.is_err());\n }\n\n #[test]\n fn tensor_nan_values_are_detected() {\n let t = Tensor::new(vec![f32::NAN, 1.0, 2.0], &[3]).unwrap();\n assert!(t.has_nan(), \"NaN in data must be detectable\");\n }\n\n #[test]\n fn tensor_inf_values_are_detected() {\n let t = Tensor::new(vec![f32::INFINITY, 1.0], &[2]).unwrap();\n assert!(t.has_inf());\n }\n}\n\n#[cfg(test)]\nmod modality_translator_tests {\n use wifi_densepose_nn::translator::ModalityTranslator;\n\n #[test]\n fn translator_rejects_wrong_subcarrier_count() {\n // standard expects 56 subcarriers; feed 57\n let csi = vec![0.0f32; 57 * 3]; // 57 subcarriers × 3 antennas\n let translator = ModalityTranslator::default();\n let result = translator.translate(&csi, 57, 3);\n assert!(result.is_err());\n }\n\n #[test]\n fn translator_handles_all_zeros() {\n let csi = vec![0.0f32; 56 * 3];\n let translator = ModalityTranslator::default();\n let result = translator.translate(&csi, 56, 3);\n // zero input should produce some output without panic\n assert!(result.is_ok());\n }\n}\n\n#[cfg(test)]\nmod inference_engine_tests {\n use wifi_densepose_nn::inference::InferenceEngine;\n\n #[test]\n fn load_nonexistent_model_returns_error() {\n let result = InferenceEngine::from_path(\"/nonexistent/model.onnx\");\n assert!(result.is_err());\n }\n\n #[test]\n fn load_corrupted_bytes_returns_error() {\n let tmp = tempfile::NamedTempFile::new().unwrap();\n std::fs::write(tmp.path(), b\"not a valid onnx file\").unwrap();\n let result = InferenceEngine::from_path(tmp.path());\n assert!(result.is_err());\n }\n\n #[test]\n fn batch_size_zero_returns_error() {\n // can't run inference on an empty batch\n // requires a valid model; skip if no model file in test fixtures\n // use #[ignore] or a feature flag for CI\n }\n}"
|
||||
},
|
||||
{
|
||||
"language": "rust",
|
||||
"code": "// v2/crates/wifi-densepose-mat/tests/detection_edge_cases.rs\n\n#[cfg(test)]\nmod breathing_rate_edge_cases {\n use wifi_densepose_mat::detection::breathing::BreathingDetector;\n\n #[test]\n fn zero_bpm_is_classified_critical() {\n let detector = BreathingDetector::default();\n // flat-line signal — no breathing detected\n let signal = vec![0.0f32; 1000];\n let result = detector.classify(&signal).unwrap();\n assert_eq!(result.triage_category, TriageCategory::Immediate);\n }\n\n #[test]\n fn agonal_breathing_rate_triggers_immediate() {\n // < 6 BPM is agonal; simulate 3 BPM signal\n let detector = BreathingDetector::default();\n let signal = generate_breathing_signal(3.0, 1000, 100.0); // 3 BPM, 1000 samples @ 100 Hz\n let result = detector.classify(&signal).unwrap();\n assert_eq!(result.triage_category, TriageCategory::Immediate);\n }\n\n #[test]\n fn normal_breathing_is_classified_minor() {\n let detector = BreathingDetector::default();\n let signal = generate_breathing_signal(15.0, 1000, 100.0); // 15 BPM\n let result = detector.classify(&signal).unwrap();\n assert_eq!(result.triage_category, TriageCategory::Minor);\n }\n\n #[test]\n fn all_nan_signal_returns_error_not_panic() {\n let detector = BreathingDetector::default();\n let signal = vec![f32::NAN; 1000];\n let result = detector.classify(&signal);\n assert!(result.is_err(), \"NaN input must be caught, not panic\");\n }\n\n fn generate_breathing_signal(bpm: f32, samples: usize, sample_rate: f32) -> Vec<f32> {\n let freq = bpm / 60.0;\n (0..samples)\n .map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate).sin())\n .collect()\n }\n}\n\n#[cfg(test)]\nmod alert_deduplication {\n use wifi_densepose_mat::alerting::{AlertDispatcher, Alert, TriageCategory};\n use std::time::Duration;\n\n #[test]\n fn duplicate_alerts_within_window_are_suppressed() {\n let mut dispatcher = AlertDispatcher::new();\n let alert = Alert::new(\"survivor-1\", TriageCategory::Immediate);\n dispatcher.dispatch(alert.clone());\n dispatcher.dispatch(alert.clone()); // same survivor, same category\n assert_eq!(dispatcher.queued_count(), 1, \"duplicate must be deduplicated\");\n }\n\n #[test]\n fn escalation_from_minor_to_immediate_is_forwarded() {\n let mut dispatcher = AlertDispatcher::new();\n dispatcher.dispatch(Alert::new(\"survivor-1\", TriageCategory::Minor));\n dispatcher.dispatch(Alert::new(\"survivor-1\", TriageCategory::Immediate));\n // escalation is not a duplicate — must pass through\n assert!(dispatcher.last_alert_for(\"survivor-1\").map(|a| a.category) == Some(TriageCategory::Immediate));\n }\n}\n\n#[cfg(test)]\nmod kalman_tracker_edge_cases {\n use wifi_densepose_mat::tracking::KalmanTracker;\n\n #[test]\n fn position_jump_does_not_corrupt_state() {\n let mut tracker = KalmanTracker::new();\n tracker.update([1.0, 1.0, 0.5]); // initial position\n tracker.update([50.0, 50.0, 0.5]); // physically impossible jump\n let pos = tracker.estimated_position();\n // should not panic; should clamp or flag anomaly\n assert!(pos.iter().all(|v| v.is_finite()));\n }\n\n #[test]\n fn lost_track_resumes_on_re_detection() {\n let mut tracker = KalmanTracker::new();\n tracker.update([1.0, 1.0, 0.5]);\n // simulate 10 missed frames\n for _ in 0..10 { tracker.predict(); }\n assert_eq!(tracker.state(), TrackState::Lost);\n tracker.update([1.1, 1.1, 0.5]); // re-detected nearby\n assert_eq!(tracker.state(), TrackState::Confirmed);\n }\n}"
|
||||
},
|
||||
{
|
||||
"language": "rust",
|
||||
"code": "// v2/crates/wifi-densepose-ruvector/tests/viewpoint_tests.rs\n\n#[cfg(test)]\nmod attention_tests {\n use wifi_densepose_ruvector::viewpoint::attention::CrossViewpointAttention;\n\n #[test]\n fn attention_weights_sum_to_one() {\n let attn = CrossViewpointAttention::new(3); // 3 viewpoints\n let features = vec![[1.0f32; 64], [2.0f32; 64], [3.0f32; 64]];\n let weights = attn.compute_weights(&features);\n let sum: f32 = weights.iter().sum();\n assert!((sum - 1.0).abs() < 1e-5, \"attention must be a probability distribution\");\n }\n\n #[test]\n fn single_viewpoint_gets_full_weight() {\n let attn = CrossViewpointAttention::new(1);\n let features = vec![[1.0f32; 64]];\n let weights = attn.compute_weights(&features);\n assert!((weights[0] - 1.0).abs() < 1e-6);\n }\n\n #[test]\n fn zero_feature_vectors_do_not_produce_nan() {\n let attn = CrossViewpointAttention::new(2);\n let features = vec![[0.0f32; 64], [0.0f32; 64]];\n let weights = attn.compute_weights(&features);\n assert!(weights.iter().all(|w| w.is_finite()));\n }\n}\n\n#[cfg(test)]\nmod sketch_tests {\n use wifi_densepose_ruvector::sketch::WireSketch;\n\n #[test]\n fn round_trip_serialization() {\n let sketch = WireSketch::from_keypoints(&[[0.5f32, 0.5], [0.3, 0.7]]);\n let bytes = sketch.to_bytes();\n let restored = WireSketch::from_bytes(&bytes).unwrap();\n assert_eq!(sketch, restored);\n }\n\n #[test]\n fn deserialize_truncated_bytes_returns_error() {\n let sketch = WireSketch::from_keypoints(&[[0.5f32, 0.5]]);\n let mut bytes = sketch.to_bytes();\n bytes.truncate(bytes.len() / 2); // truncate halfway\n assert!(WireSketch::from_bytes(&bytes).is_err());\n }\n\n #[test]\n fn empty_keypoint_list_is_handled() {\n let sketch = WireSketch::from_keypoints(&[]);\n assert_eq!(sketch.keypoint_count(), 0);\n }\n}"
|
||||
},
|
||||
{
|
||||
"language": "rust",
|
||||
"code": "// v2/crates/wifi-densepose-signal/tests/ruvsense_tests.rs\n\n#[cfg(test)]\nmod coherence_gate_tests {\n use wifi_densepose_signal::ruvsense::coherence_gate::{CoherenceGate, GateDecision};\n\n #[test]\n fn high_coherence_signal_is_accepted() {\n let gate = CoherenceGate::new(0.7); // threshold = 0.7\n let decision = gate.evaluate(0.95);\n assert_eq!(decision, GateDecision::Accept);\n }\n\n #[test]\n fn low_coherence_signal_is_rejected() {\n let gate = CoherenceGate::new(0.7);\n let decision = gate.evaluate(0.3);\n assert_eq!(decision, GateDecision::Reject);\n }\n\n #[test]\n fn borderline_coherence_triggers_recalibrate() {\n let gate = CoherenceGate::new(0.7);\n let decision = gate.evaluate(0.68); // just below threshold\n assert_eq!(decision, GateDecision::Recalibrate);\n }\n}\n\n#[cfg(test)]\nmod phase_align_tests {\n use wifi_densepose_signal::ruvsense::phase_align::PhaseAligner;\n\n #[test]\n fn phase_at_plus_pi_does_not_wrap_incorrectly() {\n let aligner = PhaseAligner::new();\n let phases = vec![std::f32::consts::PI - 0.001, std::f32::consts::PI + 0.001];\n let aligned = aligner.align(&phases);\n // jump across ±π boundary must be handled continuously\n let diff = (aligned[1] - aligned[0]).abs();\n assert!(diff < 0.01, \"phase jump at ±π must be < 0.01 rad after alignment\");\n }\n\n #[test]\n fn single_phase_value_aligns_to_itself() {\n let aligner = PhaseAligner::new();\n let phases = vec![1.5f32];\n let aligned = aligner.align(&phases);\n assert_eq!(aligned.len(), 1);\n assert!((aligned[0] - 1.5).abs() < 1e-6);\n }\n\n #[test]\n fn empty_phase_array_returns_empty() {\n let aligner = PhaseAligner::new();\n let aligned = aligner.align(&[]);\n assert!(aligned.is_empty());\n }\n}\n\n#[cfg(test)]\nmod adversarial_detection_tests {\n use wifi_densepose_signal::ruvsense::adversarial::AdversarialDetector;\n\n #[test]\n fn physically_impossible_amplitude_is_flagged() {\n let detector = AdversarialDetector::new();\n // WiFi amplitude cannot exceed hardware saturation level\n let frame = vec![1e9f32; 56]; // absurdly large\n assert!(detector.is_suspicious(&frame));\n }\n\n #[test]\n fn normal_amplitude_range_passes() {\n let detector = AdversarialDetector::new();\n let frame = vec![0.5f32; 56]; // typical normalized value\n assert!(!detector.is_suspicious(&frame));\n }\n\n #[test]\n fn multi_link_inconsistency_is_detected() {\n // link A reports body moving right; link B reports no motion\n // physically inconsistent — flag as adversarial\n let detector = AdversarialDetector::new();\n let result = detector.check_multi_link_consistency(\n &[1.0, 2.0, 3.0], // link A\n &[0.0, 0.0, 0.0], // link B (no motion)\n );\n assert!(result.is_inconsistent());\n }\n}"
|
||||
},
|
||||
{
|
||||
"language": "rust",
|
||||
"code": "// v2/crates/wifi-densepose-train/tests/test_geometry.rs\n\n#[cfg(test)]\nmod film_layer_tests {\n use wifi_densepose_train::geometry::FilmLayer;\n\n #[test]\n fn film_layer_output_shape_matches_input() {\n let film = FilmLayer::new(64, 32); // 64-dim features, 32-dim condition\n let features = vec![0.5f32; 64];\n let condition = vec![1.0f32; 32];\n let output = film.forward(&features, &condition).unwrap();\n assert_eq!(output.len(), 64, \"FiLM output must match feature dimensionality\");\n }\n\n #[test]\n fn film_layer_zero_condition_acts_as_identity() {\n let film = FilmLayer::new(64, 32);\n let features = vec![1.0f32; 64];\n let zero_condition = vec![0.0f32; 32];\n let output = film.forward(&features, &zero_condition).unwrap();\n // scale=1, shift=0 → identity; output ≈ input\n for (o, f) in output.iter().zip(features.iter()) {\n assert!((o - f).abs() < 0.1, \"zero condition should approximate identity\");\n }\n }\n}\n\n// v2/crates/wifi-densepose-train/tests/test_rapid_adapt.rs\n\n#[cfg(test)]\nmod rapid_adaptation_tests {\n use wifi_densepose_train::rapid_adapt::RapidAdapter;\n\n #[test]\n fn adapter_updates_on_single_sample() {\n let mut adapter = RapidAdapter::new(5); // 5 adaptation steps\n let csi_sample = vec![0.1f32; 56 * 3];\n let pose_label = vec![0.5f32; 17 * 2]; // 17 keypoints × (x, y)\n let result = adapter.adapt_step(&csi_sample, &pose_label);\n assert!(result.is_ok());\n }\n\n #[test]\n fn adapter_with_zero_steps_is_no_op() {\n let adapter = RapidAdapter::new(0);\n // 0 adaptation steps → weights unchanged\n let initial_weights = adapter.clone_weights();\n let _ = adapter.adapt_step(&vec![0.1f32; 168], &vec![0.5f32; 34]);\n assert_eq!(adapter.clone_weights(), initial_weights);\n }\n}"
|
||||
},
|
||||
{
|
||||
"language": "rust",
|
||||
"code": "// v2/crates/wifi-densepose-sensing-server/tests/auth_tests.rs\n\n#[cfg(test)]\nmod bearer_auth_tests {\n use wifi_densepose_sensing_server::auth::{BearerValidator, TokenError};\n\n #[test]\n fn missing_authorization_header_returns_unauthorized() {\n let validator = BearerValidator::new(\"secret-token\");\n let result = validator.validate(None);\n assert!(matches!(result, Err(TokenError::Missing)));\n }\n\n #[test]\n fn wrong_token_is_rejected() {\n let validator = BearerValidator::new(\"correct-token\");\n let result = validator.validate(Some(\"Bearer wrong-token\"));\n assert!(matches!(result, Err(TokenError::Invalid)));\n }\n\n #[test]\n fn malformed_header_without_bearer_prefix_is_rejected() {\n let validator = BearerValidator::new(\"token\");\n let result = validator.validate(Some(\"token\")); // missing \"Bearer \" prefix\n assert!(matches!(result, Err(TokenError::Malformed)));\n }\n\n #[test]\n fn correct_token_is_accepted() {\n let validator = BearerValidator::new(\"correct-token\");\n let result = validator.validate(Some(\"Bearer correct-token\"));\n assert!(result.is_ok());\n }\n}\n\n// v2/crates/wifi-densepose-sensing-server/tests/semantic_tests.rs\n\n#[cfg(test)]\nmod fall_detection_tests {\n use wifi_densepose_sensing_server::semantic::fall_detector::FallDetector;\n\n #[test]\n fn no_motion_does_not_trigger_fall() {\n let mut detector = FallDetector::new();\n for _ in 0..30 { // 30 frames of stillness\n detector.update_pose(stationary_pose());\n }\n assert!(!detector.fall_detected());\n }\n\n #[test]\n fn rapid_downward_velocity_triggers_fall() {\n let mut detector = FallDetector::new();\n // simulate person going from standing (y=1.7m) to prone (y=0.3m) in 3 frames\n for (frame, y) in [(0, 1.7f32), (1, 1.0), (2, 0.3)] {\n detector.update_pose(pose_at_height(y));\n }\n assert!(detector.fall_detected());\n }\n\n #[test]\n fn sitting_down_slowly_does_not_trigger_fall() {\n let mut detector = FallDetector::new();\n // gradual height decrease over 30 frames is sitting, not falling\n for i in 0..30 {\n let y = 1.7f32 - (i as f32 * 0.04); // ~1.2m drop over 30 frames\n detector.update_pose(pose_at_height(y));\n }\n assert!(!detector.fall_detected());\n }\n}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"rawOutputPreview": "The exploration is complete. Here's the full coverage analysis with test skeletons for each critical gap.\n\n---\n\n## Test Coverage Gap Analysis — wifi-densepose\n\n### Coverage Summary by Crate\n\n| Crate | Tests Found | Status | Priority |\n|-------|-------------|--------|----------|\n| `wifi-densepose-core` | 26 inline | Good | Low |\n| `wifi-densepose-signal` | ~60 (validation only) | Moderate | **High** |\n| `wifi-densepose-nn` | **0** | Critical | **P1** |\n| `wifi-densepose-train` | ~60 (config/dataset) | Moderate | High |\n| `wifi-densepose-mat` | 1 integration test | Critical | **P1** |\n| `wifi-densepose-ruvector` | **0** | Critical | **P1** |\n| `wifi-densepose-sensing-server` | 4 integration tests | Moderate | High |\n| `wifi-densepose-wasm` | 3 compliance tests | Low | Low |\n\n---\n\n## Tier 1: Critical Gaps\n\n### 1. `wifi-densepose-nn` — Zero test coverage\n\nEvery public API is untested. Place these at `v2/crates/wifi-densepose-nn/tests/inference_tests.rs`:\n\n```rust\n// v2/crates/wifi-densepose-nn/tests/inference_tests.rs\n\n#[cfg(test)]\nmod tensor_tests {\n use wifi_densepose_nn::tensor::Tensor;\n\n #[test]\n fn tensor_shape_mismatch_returns_error() {\n // data has 6 elements but shape claims 3×3=9\n let result = Tensor::new(vec![1.0f32; 6], &[3, 3]);\n assert!(result.is_err(), \"shape mismatch must be rejected\");\n }\n\n #[test]\n fn tensor_empty_data_returns_error() {\n let result = Tensor::new(vec![], &[0]);\n assert!(result.is_err());\n }\n\n #[test]\n fn tensor_nan_values_are_detected() {\n let t = Tensor::new(vec![f32::NAN, 1.0, 2.0], &[3]).unwrap();\n assert!(t.has_nan(), \"NaN in data must be detectable\");\n }\n\n #[test]\n fn tensor_inf_values_are_detected() {\n let t = Tensor::new(vec![f32::INFINITY, 1.0], &[2]).unwrap();\n assert!(t.has_inf());\n }\n}\n\n#[cfg(test)]\nmod modality_translator_tests {\n use wifi_densepose_nn::translator::ModalityTranslator;\n\n #[test]\n fn translator_rejects",
|
||||
"rawOutputLength": 18269
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "ruview",
|
||||
"description": "RuView Marketplace: Claude Code + Codex plugins for WiFi sensing — configuration, applications, model training, and onboarding, from practical to advanced",
|
||||
"owner": {
|
||||
"name": "ruvnet",
|
||||
"url": "https://github.com/ruvnet/RuView"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "ruview",
|
||||
"source": "./plugins/ruview",
|
||||
"description": "End-to-end RuView toolkit: getting started, ESP32 hardware setup, configuration, sensing applications (presence / vitals / pose / sleep / MAT), camera-free + camera-supervised model training, advanced multistatic sensing, CLI / API / WASM, mmWave radar, and witness verification"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"sessionId":"d80c93c2-51b7-42e8-a0fc-dc47cff1200f","pid":45748,"acquiredAt":1779668018388}
|
||||
@@ -126,7 +126,10 @@
|
||||
"Bash(node .claude/*)",
|
||||
"mcp__claude-flow__:*"
|
||||
],
|
||||
"deny": []
|
||||
"deny": [
|
||||
"Read(./.env)",
|
||||
"Read(./.env.*)"
|
||||
]
|
||||
},
|
||||
"attribution": {
|
||||
"commit": "Co-Authored-By: claude-flow <ruv@ruv.net>",
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Keep all third-party GitHub Actions on verified, pinned commit SHAs.
|
||||
# Pairs with the SHA pinning in security-scan.yml and ci.yml so that
|
||||
# future bumps stay automated and reviewable rather than drifting back
|
||||
# to mutable @master / @main refs. See issue #442.
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- dependencies
|
||||
- github-actions
|
||||
|
||||
# Mobile app npm deps. Includes the @xmldom/xmldom, node-forge, and
|
||||
# picomatch advisories from #442 plus axios and any future surface.
|
||||
- package-ecosystem: npm
|
||||
directory: /ui/mobile
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- dependencies
|
||||
- mobile
|
||||
|
||||
# Desktop UI npm deps. Direct vite devDep currently has a HIGH advisory
|
||||
# (dev-server-only path traversal); track future bumps automatically.
|
||||
- package-ecosystem: npm
|
||||
directory: /v2/crates/wifi-densepose-desktop/ui
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- dependencies
|
||||
- desktop
|
||||
|
||||
# Python deps used by v1/ and the FastAPI service. requirements.txt is
|
||||
# only loosely pinned; let Dependabot surface upstream CVE bumps.
|
||||
- package-ecosystem: pip
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- dependencies
|
||||
- python
|
||||
|
||||
# Rust workspace (15+ crates). cargo audit is not currently wired into
|
||||
# any workflow, so Dependabot is the primary automated bump path.
|
||||
- package-ecosystem: cargo
|
||||
directory: /v2
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- dependencies
|
||||
- rust
|
||||
@@ -1,94 +0,0 @@
|
||||
name: AetherArena harness gate (ADR-149)
|
||||
|
||||
# Runs the AetherArena scoring harness as a PR build gate. Every PR that touches
|
||||
# the scorer, the metrics, or the benchmark scaffold must keep the deterministic
|
||||
# score hash stable (ADR-149 §2.5 determinism_gate). If the scoring maths changes,
|
||||
# the hash moves and this gate fails until `expected_score.sha256` is regenerated
|
||||
# and reviewed — so scorer drift can never land silently.
|
||||
#
|
||||
# This is the "a PR that runs the harness as part of the build process" requirement.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'v2/crates/wifi-densepose-train/src/ruview_metrics.rs'
|
||||
- 'v2/crates/wifi-densepose-train/src/ablation.rs'
|
||||
- 'v2/crates/wifi-densepose-train/src/bin/aa_score_runner.rs'
|
||||
- 'aether-arena/**'
|
||||
- '.github/workflows/aether-arena-harness.yml'
|
||||
push:
|
||||
branches: ['feat/adr-149-aether-arena']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
harness-gate:
|
||||
name: Run AA scorer harness (determinism gate)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: v2
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
run: rustup show && rustc --version
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
v2/target
|
||||
key: aa-harness-${{ runner.os }}-${{ hashFiles('v2/Cargo.lock') }}
|
||||
|
||||
# 1. Build the pure-Rust scorer (no torch / no GPU → fast PR gate).
|
||||
- name: Build AA score runner
|
||||
run: cargo build -p wifi-densepose-train --bin aa_score_runner --no-default-features
|
||||
|
||||
# 2. Determinism gate: the committed expected hash must still match. A
|
||||
# non-zero exit here fails the PR.
|
||||
- name: Run determinism gate
|
||||
run: cargo run -q -p wifi-densepose-train --bin aa_score_runner --no-default-features
|
||||
|
||||
# 3. Repeatability analysis (witness chain): the harness must produce one
|
||||
# identical proof hash across many runs — any nondeterminism fails here.
|
||||
- name: Repeatability analysis (16 runs)
|
||||
run: cargo run -q -p wifi-densepose-train --bin aa_score_runner --no-default-features -- --repeat 16
|
||||
|
||||
# 4. Real-scoring smoke: score a sample prediction against the public smoke
|
||||
# split, exercising the actual model-scoring path (not just the fixture).
|
||||
- name: Real-scoring smoke test
|
||||
run: |
|
||||
cargo run -q -p wifi-densepose-train --bin aa_score_runner --no-default-features -- \
|
||||
--split ../aether-arena/fixtures/smoke_split.json \
|
||||
--pred ../aether-arena/fixtures/smoke_pred.json --json
|
||||
|
||||
# 5. Witness ledger chain integrity: the append-only results ledger must
|
||||
# verify (every prev_hash link + row_hash intact = no silent edits).
|
||||
- name: Verify witness ledger chain
|
||||
working-directory: aether-arena/ledger
|
||||
run: python3 ledger_tools.py verify
|
||||
|
||||
# 6. Emit the witness row + repeatability into the PR run summary.
|
||||
- name: Witness row → job summary
|
||||
if: always()
|
||||
run: |
|
||||
ROW=$(cargo run -q -p wifi-densepose-train --bin aa_score_runner --no-default-features -- --json)
|
||||
REP=$(cargo run -q -p wifi-densepose-train --bin aa_score_runner --no-default-features -- --repeat 16)
|
||||
{
|
||||
echo "## AetherArena harness gate (witness chain)"
|
||||
echo ""
|
||||
echo "Deterministic witness (ADR-149 §2.2 / proof + repeatability):"
|
||||
echo '```json'
|
||||
echo "$ROW"
|
||||
echo "$REP"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "If the determinism gate failed, the scoring maths changed: regenerate with"
|
||||
echo '`cargo run -p wifi-densepose-train --bin aa_score_runner --no-default-features -- --generate-hash > aether-arena/fixtures/expected_score.sha256` and review the diff.'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,99 +0,0 @@
|
||||
name: BFLD MQTT Integration
|
||||
|
||||
# Runs the env-gated mosquitto integration tests from iters 24 + 29 of the
|
||||
# BFLD rollout (ADR-118 / ADR-122 §2.2). Spins up an eclipse-mosquitto:2
|
||||
# service container, exports BFLD_MQTT_BROKER, runs `cargo test --features
|
||||
# mqtt`. Local developers can reproduce with:
|
||||
#
|
||||
# scoop install mosquitto # Windows
|
||||
# # or: docker run -p 1883:1883 eclipse-mosquitto:2
|
||||
# BFLD_MQTT_BROKER=tcp://localhost:1883 \
|
||||
# cargo test -p wifi-densepose-bfld --features mqtt
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'feat/adr-118-*'
|
||||
- 'feat/bfld-*'
|
||||
paths:
|
||||
- 'v2/crates/wifi-densepose-bfld/**'
|
||||
- '.github/workflows/bfld-mqtt-integration.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'v2/crates/wifi-densepose-bfld/**'
|
||||
- '.github/workflows/bfld-mqtt-integration.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
mqtt-live-broker:
|
||||
name: cargo test --features mqtt (live mosquitto)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
services:
|
||||
mosquitto:
|
||||
image: eclipse-mosquitto:2
|
||||
ports:
|
||||
- 1883:1883
|
||||
# Allow anonymous connections — local-only CI broker, no exposure
|
||||
# to the public internet, never touches production credentials.
|
||||
options: >-
|
||||
--health-cmd "mosquitto_pub -h localhost -t healthcheck -m ping || exit 1"
|
||||
--health-interval 5s
|
||||
--health-timeout 3s
|
||||
--health-retries 10
|
||||
|
||||
env:
|
||||
BFLD_MQTT_BROKER: tcp://localhost:1883
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUSTFLAGS: -D warnings
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- name: Cache cargo registry + target
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
v2/target
|
||||
key: bfld-mqtt-${{ runner.os }}-${{ hashFiles('v2/Cargo.lock') }}
|
||||
|
||||
- name: Wait for mosquitto to be ready
|
||||
run: |
|
||||
for i in {1..20}; do
|
||||
if nc -z localhost 1883; then
|
||||
echo "mosquitto reachable on port 1883 (attempt $i)"
|
||||
exit 0
|
||||
fi
|
||||
echo "waiting for mosquitto ($i/20)..."
|
||||
sleep 1
|
||||
done
|
||||
echo "mosquitto never became reachable" >&2
|
||||
exit 1
|
||||
|
||||
- name: cargo test --no-default-features (baseline regression)
|
||||
working-directory: v2
|
||||
run: cargo test -p wifi-densepose-bfld --no-default-features
|
||||
|
||||
- name: cargo test (default features)
|
||||
working-directory: v2
|
||||
run: cargo test -p wifi-densepose-bfld
|
||||
|
||||
- name: cargo test --features mqtt (incl. live mosquitto roundtrip)
|
||||
working-directory: v2
|
||||
run: cargo test -p wifi-densepose-bfld --features mqtt
|
||||
|
||||
- name: cargo clippy --features mqtt (lint gate)
|
||||
working-directory: v2
|
||||
run: cargo clippy -p wifi-densepose-bfld --features mqtt --all-targets -- -D warnings
|
||||
continue-on-error: true
|
||||
+29
-182
@@ -15,50 +15,38 @@ env:
|
||||
|
||||
jobs:
|
||||
# Code Quality and Security Checks
|
||||
# The Python codebase moved to `archive/v1/` when the runtime was rewritten in
|
||||
# Rust under `v2/`. The lint/format/type/scan checks below still run against
|
||||
# the archive for hygiene, but with `continue-on-error: true` everywhere — the
|
||||
# archive is frozen reference code, not active development, so a stale lint
|
||||
# rule shouldn't gate PRs to the Rust workspace.
|
||||
code-quality:
|
||||
name: Code Quality & Security
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install black flake8 mypy bandit safety
|
||||
|
||||
- name: Code formatting check (Black)
|
||||
continue-on-error: true
|
||||
run: black --check --diff archive/v1/src archive/v1/tests
|
||||
run: black --check --diff src/ tests/
|
||||
|
||||
- name: Linting (Flake8)
|
||||
continue-on-error: true
|
||||
run: flake8 archive/v1/src archive/v1/tests --max-line-length=88 --extend-ignore=E203,W503
|
||||
run: flake8 src/ tests/ --max-line-length=88 --extend-ignore=E203,W503
|
||||
|
||||
- name: Type checking (MyPy)
|
||||
continue-on-error: true
|
||||
run: mypy archive/v1/src --ignore-missing-imports
|
||||
run: mypy src/ --ignore-missing-imports
|
||||
|
||||
- name: Security scan (Bandit)
|
||||
run: bandit -r archive/v1/src -f json -o bandit-report.json
|
||||
run: bandit -r src/ -f json -o bandit-report.json
|
||||
continue-on-error: true
|
||||
|
||||
- name: Dependency vulnerability scan (Safety)
|
||||
@@ -66,7 +54,6 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload security reports
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
@@ -75,92 +62,11 @@ jobs:
|
||||
bandit-report.json
|
||||
safety-report.json
|
||||
|
||||
# Rust Workspace Tests
|
||||
rust-tests:
|
||||
name: Rust Workspace Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# `wifi-densepose-desktop` is a Tauri v2 app — `glib-sys`, `gtk-sys`,
|
||||
# `webkit2gtk-sys`, etc. need the Linux dev libraries via pkg-config or the
|
||||
# workspace test fails at the build step before any test runs (every recent
|
||||
# main CI run has been red on this for exactly this reason). Install the
|
||||
# standard Tauri-on-Ubuntu set.
|
||||
- name: Install Tauri / GTK / serial system dev libraries
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libglib2.0-dev \
|
||||
libgtk-3-dev \
|
||||
libsoup-3.0-dev \
|
||||
libjavascriptcoregtk-4.1-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libxdo-dev \
|
||||
libudev-dev \
|
||||
libdbus-1-dev \
|
||||
libssl-dev \
|
||||
pkg-config
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
# Swatinem/rust-cache replaces a naive `actions/cache` of the whole
|
||||
# `v2/target`. That manual cache of a 38-crate target dir (multi-GB) was an
|
||||
# intermittent failure source — several CI runs this cycle died at the
|
||||
# cache/setup step (after toolchain install, before "Run Rust tests"),
|
||||
# needing a rerun. rust-cache is purpose-built for Rust: it caches the
|
||||
# registry + git + a pruned target, evicts stale deps, and restores far more
|
||||
# reliably (and faster) on large workspaces. `workspaces: v2` points it at
|
||||
# the v2/ cargo workspace (keys on v2/Cargo.lock, caches v2/target).
|
||||
- name: Cache cargo (Swatinem/rust-cache)
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: v2
|
||||
|
||||
- name: Run Rust tests
|
||||
working-directory: v2
|
||||
run: cargo test --workspace --no-default-features
|
||||
|
||||
- name: Run ADR-147 worldmodel tests
|
||||
working-directory: v2
|
||||
run: cargo test -p wifi-densepose-worldmodel --no-default-features
|
||||
|
||||
# ADR-134 CIR tests are behind the `cir` feature so the bench dependency
|
||||
# (Criterion) only pulls when actually exercised. Run them as a separate
|
||||
# step so a CIR-only regression is unambiguously attributable.
|
||||
- name: Run ADR-134 CIR tests
|
||||
working-directory: v2
|
||||
run: cargo test -p wifi-densepose-signal --no-default-features --features cir --tests
|
||||
|
||||
# ADR-134 + ADR-028 witness guard. The CIR proof runner produces a
|
||||
# bit-deterministic SHA-256 over CirEstimator output on the synthetic
|
||||
# reference signal. Any algorithmic regression — changes to ISTA
|
||||
# convergence, sensing matrix construction, soft-thresholding, or input
|
||||
# padding — breaks the hash and fails the build. To regenerate after an
|
||||
# *intentional* change:
|
||||
# cd v2 && cargo run -p wifi-densepose-signal --bin cir_proof_runner \
|
||||
# --release --no-default-features -- --generate-hash \
|
||||
# > ../archive/v1/data/proof/expected_cir_features.sha256
|
||||
- name: ADR-134 CIR witness proof (determinism guard)
|
||||
run: bash scripts/verify-cir-proof.sh
|
||||
|
||||
- name: ADR-135 calibration witness proof (determinism guard)
|
||||
run: bash scripts/verify-calibration-proof.sh
|
||||
|
||||
# Unit and Integration Tests
|
||||
# Python pytest matrix — runs against the archived v1 Python tree.
|
||||
# `continue-on-error: true` for the same reason as code-quality above:
|
||||
# the archive is frozen reference, not blocking the Rust workspace PRs.
|
||||
test:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ['3.10', '3.11', '3.12']
|
||||
services:
|
||||
@@ -189,51 +95,44 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pytest-cov pytest-xdist
|
||||
|
||||
- name: Run unit tests
|
||||
continue-on-error: true
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_wifi_densepose
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
ENVIRONMENT: test
|
||||
run: |
|
||||
pytest archive/v1/tests/unit/ -v --cov=archive/v1/src --cov-report=xml --cov-report=html --junitxml=junit.xml
|
||||
pytest tests/unit/ -v --cov=src --cov-report=xml --cov-report=html --junitxml=junit.xml
|
||||
|
||||
- name: Run integration tests
|
||||
continue-on-error: true
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_wifi_densepose
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
ENVIRONMENT: test
|
||||
run: |
|
||||
pytest archive/v1/tests/integration/ -v --junitxml=integration-junit.xml
|
||||
pytest tests/integration/ -v --junitxml=integration-junit.xml
|
||||
|
||||
- name: Upload coverage reports
|
||||
continue-on-error: true
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
|
||||
- name: Upload test results
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
@@ -244,21 +143,17 @@ jobs:
|
||||
htmlcov/
|
||||
|
||||
# Performance and Load Tests
|
||||
# NOTE: tests/performance/locustfile.py and the src.api.main app path both
|
||||
# predate the v1→archive/v1 reorganisation. continue-on-error: true until a
|
||||
# proper locust suite is added under archive/v1/tests/performance/.
|
||||
performance-test:
|
||||
name: Performance Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test]
|
||||
continue-on-error: true
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
@@ -267,70 +162,36 @@ jobs:
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pytest # the perf suite is pytest, not locust
|
||||
pip install locust
|
||||
|
||||
# No "Start application" step: the gated test (test_frame_budget.py) drives
|
||||
# the CSIProcessor pipeline in-process and makes no HTTP calls, so the old
|
||||
# uvicorn server + `sleep 10` were dead weight — they only existed for the
|
||||
# now-excluded api_throughput/inference_speed tests, and on every run dumped
|
||||
# ~50 misleading "router requires hardware setup" ERROR lines for a server
|
||||
# no test touched. MOCK_POSE_DATA is server-only and unused here.
|
||||
- name: Start application
|
||||
run: |
|
||||
uvicorn src.api.main:app --host 0.0.0.0 --port 8000 &
|
||||
sleep 10
|
||||
|
||||
- name: Run performance tests
|
||||
working-directory: archive/v1
|
||||
run: |
|
||||
# Gate only on the genuine, deterministic perf guard:
|
||||
# test_frame_budget.py times the *real* CSIProcessor pipeline against
|
||||
# the ADR 50 ms per-frame budget (single-frame, p95 over 100 frames,
|
||||
# +Doppler) — a true regression signal.
|
||||
#
|
||||
# test_api_throughput.py / test_inference_speed.py are excluded: every
|
||||
# test there is a TDD red-phase stub (suffix `_should_fail_initially`)
|
||||
# that times a *mock that sleeps* — meaningless as a perf signal, with
|
||||
# machine-dependent wall-clock asserts (e.g. `actual_rps >= 40`,
|
||||
# `batch_time < individual_time`) that are inherently flaky on shared
|
||||
# CI runners, plus a cross-class fixture-scope bug. Forcing them green
|
||||
# would be manufacturing a false signal; they stay in-repo for local
|
||||
# TDD but do not gate CI until the underlying features are implemented.
|
||||
#
|
||||
# `python -m pytest` (not the bare `pytest` script) puts the cwd
|
||||
# (archive/v1) on sys.path so `from src.core...` resolves — the bare
|
||||
# script omits cwd and raises ModuleNotFoundError: No module named 'src'.
|
||||
# -o addopts="" drops the root pyproject's --cov/--cov-fail-under=100.
|
||||
python -m pytest tests/performance/test_frame_budget.py \
|
||||
-o addopts="" -v --junitxml=perf-junit.xml
|
||||
locust -f tests/performance/locustfile.py --headless --users 50 --spawn-rate 5 --run-time 60s --host http://localhost:8000
|
||||
|
||||
- name: Upload performance results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: performance-results
|
||||
path: archive/v1/perf-junit.xml
|
||||
path: locust_report.html
|
||||
|
||||
# Docker Build and Test
|
||||
# NOTE: the canonical Docker build for the sensing-server is now
|
||||
# `.github/workflows/sensing-server-docker.yml` (multi-registry push, asset
|
||||
# smoke tests, bearer-auth smoke tests — #520/#514/#443). This job predates
|
||||
# that workflow, points at a non-existent root `Dockerfile` with a
|
||||
# non-existent `target: production`, and pushes to a mis-cased image name —
|
||||
# `continue-on-error: true` until it's deleted or rewired to call the new
|
||||
# workflow, so it doesn't gate the rest of the pipeline.
|
||||
docker-build:
|
||||
name: Docker Build & Test
|
||||
runs-on: ubuntu-latest
|
||||
needs: [code-quality, test, rust-tests]
|
||||
continue-on-error: true
|
||||
needs: [code-quality, test]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
continue-on-error: true
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
@@ -338,9 +199,8 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
continue-on-error: true
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
@@ -350,8 +210,7 @@ jobs:
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
target: production
|
||||
@@ -363,7 +222,6 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Test Docker image
|
||||
continue-on-error: true
|
||||
run: |
|
||||
docker run --rm -d --name test-container -p 8000:8000 ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
sleep 10
|
||||
@@ -371,15 +229,13 @@ jobs:
|
||||
docker stop test-container
|
||||
|
||||
- name: Run container security scan
|
||||
continue-on-error: true
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-results.sarif'
|
||||
|
||||
- name: Upload Trivy scan results
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -391,14 +247,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker-build]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: write # gh-pages deploy needs write (GITHUB_TOKEN is read-only by default -> 403)
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
@@ -409,9 +263,6 @@ jobs:
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Generate OpenAPI spec
|
||||
working-directory: archive/v1
|
||||
env:
|
||||
MOCK_POSE_DATA: "true" # no CSI hardware in CI
|
||||
run: |
|
||||
python -c "
|
||||
from src.api.main import app
|
||||
@@ -422,7 +273,6 @@ jobs:
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
continue-on-error: true # openapi generation above is the real validation; deploy is best-effort (Pages may be disabled)
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./docs
|
||||
@@ -432,31 +282,28 @@ jobs:
|
||||
notify:
|
||||
name: Notify
|
||||
runs-on: ubuntu-latest
|
||||
needs: [code-quality, test, rust-tests, performance-test, docker-build, docs]
|
||||
needs: [code-quality, test, performance-test, docker-build, docs]
|
||||
if: always()
|
||||
permissions:
|
||||
contents: write # required by softprops/action-gh-release
|
||||
# GitHub Actions does not allow `secrets.X` directly in step-level `if:`
|
||||
# expressions — only `env.X`. Promote the secret to env at job scope so
|
||||
# the gating expression below is parseable.
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
steps:
|
||||
- name: Notify Slack on success
|
||||
if: ${{ env.SLACK_WEBHOOK_URL != '' && needs.code-quality.result == 'success' && needs.test.result == 'success' && needs.docker-build.result == 'success' }}
|
||||
if: ${{ secrets.SLACK_WEBHOOK_URL != '' && needs.code-quality.result == 'success' && needs.test.result == 'success' && needs.docker-build.result == 'success' }}
|
||||
uses: 8398a7/action-slack@v3
|
||||
with:
|
||||
status: success
|
||||
channel: '#ci-cd'
|
||||
text: '✅ CI pipeline completed successfully for ${{ github.ref }}'
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
|
||||
- name: Notify Slack on failure
|
||||
if: ${{ env.SLACK_WEBHOOK_URL != '' && (needs.code-quality.result == 'failure' || needs.test.result == 'failure' || needs.docker-build.result == 'failure') }}
|
||||
if: ${{ secrets.SLACK_WEBHOOK_URL != '' && (needs.code-quality.result == 'failure' || needs.test.result == 'failure' || needs.docker-build.result == 'failure') }}
|
||||
uses: 8398a7/action-slack@v3
|
||||
with:
|
||||
status: failure
|
||||
channel: '#ci-cd'
|
||||
text: '❌ CI pipeline failed for ${{ github.ref }}'
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: github.ref == 'refs/heads/main' && needs.docker-build.result == 'success'
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
name: GitHub Clone Tracking → data/clone-data.rvf
|
||||
|
||||
# Persists rolling 14-day clone-traffic snapshots to data/clone-data.rvf in
|
||||
# the ruvector JSONL RVF format. GitHub's /traffic/clones endpoint only
|
||||
# retains the last 14 days server-side, so without this scheduled scrape
|
||||
# the data is gone forever the moment it falls outside the window.
|
||||
#
|
||||
# Format: JSONL RVF
|
||||
# - line 1 is a `metadata` segment that initializes the file
|
||||
# - each subsequent run appends one `clone_snapshot` segment carrying the
|
||||
# 14-day rollup PLUS per-day breakdown
|
||||
# - file is idempotent: per-day entries are keyed by `timestamp` so a
|
||||
# downstream reader can dedupe across overlapping snapshot windows
|
||||
#
|
||||
# Schedule: every 14 days (1st + 15th of each month, ~14-day cadence in
|
||||
# practice). Workflow can also be dispatched manually for backfill or test.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 01:23 UTC on the 1st and 15th of every month — close to 14-day cadence
|
||||
# without cron's "every 14 days" monthly-reset weirdness. Picking :23
|
||||
# avoids the cron herd on :00.
|
||||
- cron: '23 1 1,15 * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: clone-tracking
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
snapshot:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Fetch /traffic/clones + /traffic/views from GitHub
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p data
|
||||
gh api repos/${{ github.repository }}/traffic/clones > /tmp/clones.json
|
||||
gh api repos/${{ github.repository }}/traffic/views > /tmp/views.json
|
||||
echo "--- clones rollup ---"
|
||||
jq '{count, uniques, days: (.clones | length)}' /tmp/clones.json
|
||||
echo "--- views rollup ---"
|
||||
jq '{count, uniques, days: (.views | length)}' /tmp/views.json
|
||||
|
||||
- name: Append snapshot to data/clone-data.rvf
|
||||
env:
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -e
|
||||
RVF="data/clone-data.rvf"
|
||||
FETCHED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# Initialize the file with a metadata segment on first run.
|
||||
if [ ! -f "$RVF" ]; then
|
||||
echo "Initializing $RVF with metadata segment"
|
||||
jq -n --arg repo "$REPO" --arg ts "$FETCHED_AT" '{
|
||||
type: "metadata",
|
||||
name: "ruview-clone-traffic-history",
|
||||
version: "1.0.0",
|
||||
schema: "ruvector.rvf.jsonl/v1",
|
||||
format: "github-traffic-snapshots",
|
||||
repo: $repo,
|
||||
source: "GitHub Traffic API /repos/{repo}/traffic/{clones,views}",
|
||||
policy: "GitHub retains only 14 days server-side; this file is the long-term record.",
|
||||
segments: ["metadata", "clone_snapshot", "view_snapshot"],
|
||||
created_at: $ts,
|
||||
custom: {
|
||||
cadence: "twice monthly (1st and 15th, ~14-day intervals)",
|
||||
idempotency_key: "timestamp (per-day records de-duplicate across overlapping snapshot windows)"
|
||||
}
|
||||
}' >> "$RVF"
|
||||
fi
|
||||
|
||||
# Append the clone snapshot.
|
||||
jq --arg ts "$FETCHED_AT" '{
|
||||
type: "clone_snapshot",
|
||||
fetched_at: $ts,
|
||||
window_count: .count,
|
||||
window_uniques: .uniques,
|
||||
per_day: .clones
|
||||
}' /tmp/clones.json >> "$RVF"
|
||||
|
||||
# Append the views snapshot (free with the same auth).
|
||||
jq --arg ts "$FETCHED_AT" '{
|
||||
type: "view_snapshot",
|
||||
fetched_at: $ts,
|
||||
window_count: .count,
|
||||
window_uniques: .uniques,
|
||||
per_day: .views
|
||||
}' /tmp/views.json >> "$RVF"
|
||||
|
||||
echo "--- RVF tail (last 4 lines) ---"
|
||||
tail -4 "$RVF" | jq -c '{type, fetched_at, window_count, window_uniques}' || true
|
||||
echo "--- file size ---"
|
||||
wc -l "$RVF"
|
||||
|
||||
- name: Compute aggregates for the commit summary
|
||||
id: agg
|
||||
run: |
|
||||
# Count distinct per-day entries across all snapshots so we can
|
||||
# show "cumulative observed clones" in the commit message.
|
||||
python3 - <<'PY'
|
||||
import json, os
|
||||
path = "data/clone-data.rvf"
|
||||
per_day_clones = {}
|
||||
per_day_views = {}
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if not line.strip():
|
||||
continue
|
||||
d = json.loads(line)
|
||||
if d.get("type") == "clone_snapshot":
|
||||
for entry in d.get("per_day", []):
|
||||
per_day_clones[entry["timestamp"]] = entry
|
||||
elif d.get("type") == "view_snapshot":
|
||||
for entry in d.get("per_day", []):
|
||||
per_day_views[entry["timestamp"]] = entry
|
||||
|
||||
tot_clones = sum(e.get("count", 0) for e in per_day_clones.values())
|
||||
tot_uniq_clones = sum(e.get("uniques", 0) for e in per_day_clones.values())
|
||||
tot_views = sum(e.get("count", 0) for e in per_day_views.values())
|
||||
tot_uniq_views = sum(e.get("uniques", 0) for e in per_day_views.values())
|
||||
print(f"clone days observed: {len(per_day_clones)} total clones: {tot_clones:,} total unique cloners: {tot_uniq_clones:,}")
|
||||
print(f"view days observed: {len(per_day_views)} total views: {tot_views:,} total unique viewers: {tot_uniq_views:,}")
|
||||
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a") as out:
|
||||
out.write(f"clones={tot_clones}\n")
|
||||
out.write(f"clone_days={len(per_day_clones)}\n")
|
||||
out.write(f"views={tot_views}\n")
|
||||
out.write(f"view_days={len(per_day_views)}\n")
|
||||
PY
|
||||
|
||||
- name: Commit + push if changed
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
if git diff --quiet data/clone-data.rvf; then
|
||||
echo "no changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
git add data/clone-data.rvf
|
||||
git commit -m "chore(traffic): clone snapshot — ${{ steps.agg.outputs.clone_days }} days observed → ${{ steps.agg.outputs.clones }} clones, ${{ steps.agg.outputs.view_days }} view-days → ${{ steps.agg.outputs.views }} views"
|
||||
git push
|
||||
@@ -1,200 +0,0 @@
|
||||
name: Cog HA-Matter Release
|
||||
|
||||
# ADR-116 P8 — Build + sign + bundle the cog-ha-matter cog on a
|
||||
# version tag. Upload to gs://cognitum-apps/ runs only when the
|
||||
# GCP_CREDENTIALS + COGNITUM_OWNER_SIGNING_KEY secrets are set, so
|
||||
# this workflow is safe to merge before the production credentials
|
||||
# land — it'll bundle release artifacts to the workflow run page
|
||||
# either way.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'cog-ha-matter-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Build + sign + bundle but skip GCS upload'
|
||||
required: false
|
||||
default: 'true'
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CRATE: cog-ha-matter
|
||||
|
||||
jobs:
|
||||
build-x86_64:
|
||||
name: Build x86_64
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
v2/target
|
||||
key: cog-ha-matter-x86_64-${{ hashFiles('v2/Cargo.lock') }}
|
||||
|
||||
- name: Build release binary
|
||||
working-directory: v2/crates/cog-ha-matter/cog
|
||||
run: make build-x86_64
|
||||
|
||||
- name: Compute SHA-256
|
||||
working-directory: v2/crates/cog-ha-matter/cog
|
||||
run: make sign-x86_64
|
||||
|
||||
- name: Sign with Ed25519 (gated)
|
||||
if: ${{ env.SIGNING_KEY != '' }}
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.COGNITUM_OWNER_SIGNING_KEY }}
|
||||
working-directory: v2/crates/cog-ha-matter/cog
|
||||
run: |
|
||||
printf '%s' "$SIGNING_KEY" \
|
||||
| openssl pkeyutl -sign -inkey /dev/stdin -rawin \
|
||||
-in dist/cog-ha-matter-x86_64.sha256 \
|
||||
| base64 -w0 > dist/cog-ha-matter-x86_64.sig
|
||||
echo "Signed cog-ha-matter-x86_64 ($(wc -c < dist/cog-ha-matter-x86_64.sig) bytes)"
|
||||
|
||||
- name: Upload workflow artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cog-ha-matter-x86_64
|
||||
path: |
|
||||
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-x86_64
|
||||
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-x86_64.sha256
|
||||
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-x86_64.sig
|
||||
if-no-files-found: warn
|
||||
|
||||
build-arm:
|
||||
name: Build aarch64 (arm)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-unknown-linux-gnu
|
||||
|
||||
- name: Install cross-compiler
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
v2/target
|
||||
key: cog-ha-matter-arm-${{ hashFiles('v2/Cargo.lock') }}
|
||||
|
||||
- name: Build release binary
|
||||
working-directory: v2
|
||||
env:
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
|
||||
run: |
|
||||
cargo build -p cog-ha-matter --release --target aarch64-unknown-linux-gnu
|
||||
mkdir -p crates/cog-ha-matter/cog/dist
|
||||
cp target/aarch64-unknown-linux-gnu/release/cog-ha-matter \
|
||||
crates/cog-ha-matter/cog/dist/cog-ha-matter-arm
|
||||
# ^ matches Makefile's `dist/$(CRATE)-arm` so `make sign-arm` finds it
|
||||
|
||||
- name: Compute SHA-256
|
||||
working-directory: v2/crates/cog-ha-matter/cog
|
||||
run: make sign-arm
|
||||
|
||||
- name: Sign with Ed25519 (gated)
|
||||
if: ${{ env.SIGNING_KEY != '' }}
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.COGNITUM_OWNER_SIGNING_KEY }}
|
||||
working-directory: v2/crates/cog-ha-matter/cog
|
||||
run: |
|
||||
printf '%s' "$SIGNING_KEY" \
|
||||
| openssl pkeyutl -sign -inkey /dev/stdin -rawin \
|
||||
-in dist/cog-ha-matter-arm.sha256 \
|
||||
| base64 -w0 > dist/cog-ha-matter-arm.sig
|
||||
echo "Signed cog-ha-matter-arm ($(wc -c < dist/cog-ha-matter-arm.sig) bytes)"
|
||||
|
||||
- name: Upload workflow artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cog-ha-matter-arm
|
||||
path: |
|
||||
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-arm
|
||||
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-arm.sha256
|
||||
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-arm.sig
|
||||
if-no-files-found: warn
|
||||
|
||||
publish-gcs:
|
||||
name: Upload to GCS (gated)
|
||||
needs: [build-x86_64, build-arm]
|
||||
runs-on: ubuntu-latest
|
||||
# Skip on dry-run dispatch; skip on tags when GCP_CREDENTIALS unset.
|
||||
if: >
|
||||
github.event_name == 'push' &&
|
||||
vars.HAS_GCP_CREDENTIALS == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download x86_64 artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: cog-ha-matter-x86_64
|
||||
path: dist/
|
||||
|
||||
- name: Download arm artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: cog-ha-matter-arm
|
||||
path: dist/
|
||||
|
||||
- name: Auth to GCP
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
credentials_json: ${{ secrets.GCP_CREDENTIALS }}
|
||||
|
||||
- name: Set up gcloud
|
||||
uses: google-github-actions/setup-gcloud@v2
|
||||
|
||||
- name: Upload binaries + sidecars
|
||||
run: |
|
||||
gsutil cp dist/cog-ha-matter-x86_64 gs://cognitum-apps/cogs/x86_64/cog-ha-matter-x86_64
|
||||
gsutil cp dist/cog-ha-matter-x86_64.sha256 gs://cognitum-apps/cogs/x86_64/cog-ha-matter-x86_64.sha256
|
||||
gsutil cp dist/cog-ha-matter-arm gs://cognitum-apps/cogs/arm/cog-ha-matter-arm
|
||||
gsutil cp dist/cog-ha-matter-arm.sha256 gs://cognitum-apps/cogs/arm/cog-ha-matter-arm.sha256
|
||||
if [ -f dist/cog-ha-matter-x86_64.sig ]; then
|
||||
gsutil cp dist/cog-ha-matter-x86_64.sig gs://cognitum-apps/cogs/x86_64/cog-ha-matter-x86_64.sig
|
||||
fi
|
||||
if [ -f dist/cog-ha-matter-arm.sig ]; then
|
||||
gsutil cp dist/cog-ha-matter-arm.sig gs://cognitum-apps/cogs/arm/cog-ha-matter-arm.sig
|
||||
fi
|
||||
|
||||
- name: Print app-registry.json snippet for the cognitum-one PR
|
||||
run: |
|
||||
for arch in arm x86_64; do
|
||||
sha=$(cat dist/cog-cog-ha-matter-$arch.sha256)
|
||||
sig=$([ -f dist/cog-cog-ha-matter-$arch.sig ] && cat dist/cog-cog-ha-matter-$arch.sig || echo "")
|
||||
cat <<EOF
|
||||
--- $arch ---
|
||||
{
|
||||
"id": "ha-matter",
|
||||
"version": "${GITHUB_REF_NAME#cog-ha-matter-v}",
|
||||
"binary_url": "https://storage.googleapis.com/cognitum-apps/cogs/$arch/cog-cog-ha-matter-$arch",
|
||||
"binary_sha256": "$sha",
|
||||
"binary_signature": "$sig",
|
||||
"description": "Home Assistant + Matter Cognitum Seed cog (mDNS + witness chain)",
|
||||
"min_seed_version": "0.6.0",
|
||||
"installable_on": ["$arch"]
|
||||
}
|
||||
EOF
|
||||
done
|
||||
@@ -1,46 +0,0 @@
|
||||
name: Dashboard a11y + cross-browser
|
||||
|
||||
# Runs axe-core a11y assertions on the built dashboard across
|
||||
# Chromium, Firefox, and WebKit. Closes ADR-092 §11.5 (axe-core)
|
||||
# and §11.8 (cross-browser).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['dashboard/**', 'v2/crates/nvsim/**']
|
||||
pull_request:
|
||||
paths: ['dashboard/**']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
a11y:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: wasm32-unknown-unknown }
|
||||
|
||||
- name: Install wasm-pack
|
||||
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||
|
||||
- name: Build nvsim WASM
|
||||
working-directory: v2
|
||||
run: |
|
||||
wasm-pack build crates/nvsim --target web \
|
||||
--out-dir ../../dashboard/public/nvsim-pkg \
|
||||
--release -- --no-default-features --features wasm
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with: { node-version: 20, cache: npm, cache-dependency-path: dashboard/package-lock.json }
|
||||
|
||||
- working-directory: dashboard
|
||||
run: |
|
||||
npm ci
|
||||
npm install --save-dev @playwright/test @axe-core/playwright
|
||||
npx playwright install --with-deps
|
||||
npm run build
|
||||
npx playwright test
|
||||
@@ -1,87 +0,0 @@
|
||||
name: nvsim Dashboard → GitHub Pages
|
||||
|
||||
# Deploys the nvsim Vite/Lit dashboard to gh-pages/nvsim/ — preserving
|
||||
# the existing observatory/, pose-fusion/, and root index.html demos
|
||||
# already published from gh-pages. ADR-092 §9.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'v2/crates/nvsim/**'
|
||||
- 'dashboard/**'
|
||||
- '.github/workflows/dashboard-pages.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: dashboard-pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust + wasm32 target
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
v2/target
|
||||
key: ${{ runner.os }}-cargo-nvsim-${{ hashFiles('v2/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-nvsim-
|
||||
|
||||
- name: Install wasm-pack
|
||||
run: |
|
||||
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||
which wasm-pack
|
||||
|
||||
- name: Build nvsim WASM
|
||||
working-directory: v2
|
||||
run: |
|
||||
wasm-pack build crates/nvsim \
|
||||
--target web \
|
||||
--out-dir ../../dashboard/public/nvsim-pkg \
|
||||
--release \
|
||||
-- --no-default-features --features wasm
|
||||
|
||||
- name: Setup Node 20
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: dashboard/package-lock.json
|
||||
|
||||
- name: Install dashboard deps
|
||||
working-directory: dashboard
|
||||
run: npm ci
|
||||
|
||||
- name: Build dashboard
|
||||
working-directory: dashboard
|
||||
env:
|
||||
NVSIM_BASE: /RuView/nvsim/
|
||||
run: npm run build
|
||||
|
||||
- name: Deploy to gh-pages/nvsim/
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./dashboard/dist
|
||||
destination_dir: nvsim
|
||||
# CRITICAL: preserves observatory/, pose-fusion/, root index.html
|
||||
# and any other RuView demos already on gh-pages.
|
||||
keep_files: true
|
||||
commit_message: 'deploy(nvsim): ${{ github.sha }}'
|
||||
user_name: 'github-actions[bot]'
|
||||
user_email: 'github-actions[bot]@users.noreply.github.com'
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
@@ -40,18 +40,18 @@ jobs:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: v2/crates/wifi-densepose-desktop/ui
|
||||
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui
|
||||
run: npm ci
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: v2/crates/wifi-densepose-desktop/ui
|
||||
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui
|
||||
run: npm run build
|
||||
|
||||
- name: Install Tauri CLI
|
||||
run: cargo install tauri-cli --version "^2.0.0"
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: v2/crates/wifi-densepose-desktop
|
||||
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop
|
||||
run: cargo tauri build --target ${{ matrix.target }}
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
@@ -68,14 +68,14 @@ jobs:
|
||||
|
||||
- name: Package macOS app
|
||||
run: |
|
||||
cd v2/target/${{ matrix.target }}/release/bundle/macos
|
||||
cd rust-port/wifi-densepose-rs/target/${{ matrix.target }}/release/bundle/macos
|
||||
zip -r "RuView-Desktop-${{ github.event.inputs.version || '0.4.0' }}-macos-${{ steps.arch.outputs.arch }}.zip" "RuView Desktop.app"
|
||||
|
||||
- name: Upload macOS artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ruview-macos-${{ steps.arch.outputs.arch }}
|
||||
path: v2/target/${{ matrix.target }}/release/bundle/macos/*.zip
|
||||
path: rust-port/wifi-densepose-rs/target/${{ matrix.target }}/release/bundle/macos/*.zip
|
||||
|
||||
build-windows:
|
||||
name: Build Windows
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
@@ -93,18 +93,18 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: v2/crates/wifi-densepose-desktop/ui
|
||||
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui
|
||||
run: npm ci
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: v2/crates/wifi-densepose-desktop/ui
|
||||
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui
|
||||
run: npm run build
|
||||
|
||||
- name: Install Tauri CLI
|
||||
run: cargo install tauri-cli --version "^2.0.0"
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: v2/crates/wifi-densepose-desktop
|
||||
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop
|
||||
run: cargo tauri build
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
@@ -114,13 +114,13 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ruview-windows-msi
|
||||
path: v2/target/release/bundle/msi/*.msi
|
||||
path: rust-port/wifi-densepose-rs/target/release/bundle/msi/*.msi
|
||||
|
||||
- name: Upload Windows NSIS artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ruview-windows-nsis
|
||||
path: v2/target/release/bundle/nsis/*.exe
|
||||
path: rust-port/wifi-densepose-rs/target/release/bundle/nsis/*.exe
|
||||
|
||||
create-release:
|
||||
name: Create Release
|
||||
|
||||
@@ -2,11 +2,6 @@ name: Firmware CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
tags:
|
||||
# ESP32 firmware release tags — build + version-consistency guard (RuView#505).
|
||||
- 'v*-esp32'
|
||||
paths:
|
||||
- 'firmware/**'
|
||||
- '.github/workflows/firmware-ci.yml'
|
||||
@@ -16,92 +11,32 @@ on:
|
||||
- '.github/workflows/firmware-ci.yml'
|
||||
|
||||
jobs:
|
||||
version-guard:
|
||||
name: Verify version.txt matches release tag
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref_type == 'tag'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check firmware version.txt == tag
|
||||
run: |
|
||||
# Tag form: vX.Y.Z-esp32 → expect version.txt to contain X.Y.Z
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
EXPECTED="${TAG#v}"
|
||||
EXPECTED="${EXPECTED%-esp32}"
|
||||
ACTUAL="$(tr -d '[:space:]' < firmware/esp32-csi-node/version.txt)"
|
||||
echo "Tag: $TAG → expected version.txt: $EXPECTED | actual: $ACTUAL"
|
||||
if [ "$EXPECTED" != "$ACTUAL" ]; then
|
||||
echo "::error::firmware/esp32-csi-node/version.txt is '$ACTUAL' but tag '$TAG' expects '$EXPECTED'."
|
||||
echo "::error::Bump version.txt and re-tag so esp_app_get_description()->version is correct (RuView#505)."
|
||||
exit 1
|
||||
fi
|
||||
echo "version.txt matches the release tag."
|
||||
|
||||
build:
|
||||
name: Build firmware (${{ matrix.target }} / ${{ matrix.variant }})
|
||||
name: Build ESP32-S3 Firmware
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: espressif/idf:v5.4
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- variant: 8mb
|
||||
target: esp32s3
|
||||
sdkconfig: sdkconfig.defaults
|
||||
partition_table_name: partitions_display.csv
|
||||
size_limit_kb: 1100
|
||||
artifact_app: esp32-csi-node.bin
|
||||
artifact_pt: partition-table.bin
|
||||
- variant: 4mb
|
||||
target: esp32s3
|
||||
sdkconfig: sdkconfig.defaults.4mb
|
||||
partition_table_name: partitions_4mb.csv
|
||||
size_limit_kb: 1100
|
||||
artifact_app: esp32-csi-node-4mb.bin
|
||||
artifact_pt: partition-table-4mb.bin
|
||||
# ADR-110: ESP32-C6 research target (Wi-Fi 6 / 802.15.4 / TWT / LP-core)
|
||||
- variant: c6-4mb
|
||||
target: esp32c6
|
||||
sdkconfig: sdkconfig.defaults
|
||||
partition_table_name: partitions_4mb.csv
|
||||
size_limit_kb: 1100
|
||||
artifact_app: esp32-csi-node-c6.bin
|
||||
artifact_pt: partition-table-c6.bin
|
||||
image: espressif/idf:v5.2
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build firmware (${{ matrix.variant }})
|
||||
- name: Build firmware
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
# 4mb variant supplies its own sdkconfig.defaults overlay.
|
||||
# c6-4mb variant relies on the auto-applied sdkconfig.defaults.esp32c6
|
||||
# overlay (ESP-IDF auto-loads sdkconfig.defaults.$TARGET when present).
|
||||
if [ "${{ matrix.variant }}" = "4mb" ]; then
|
||||
cp "${{ matrix.sdkconfig }}" sdkconfig.defaults
|
||||
fi
|
||||
idf.py set-target ${{ matrix.target }}
|
||||
idf.py set-target esp32s3
|
||||
idf.py build
|
||||
|
||||
- name: Build and run host-side ADR-110 unit tests
|
||||
if: matrix.variant == 'c6-4mb'
|
||||
working-directory: firmware/esp32-csi-node/test
|
||||
run: |
|
||||
make test_adr110
|
||||
./test_adr110
|
||||
|
||||
- name: Verify binary size (< ${{ matrix.size_limit_kb }} KB gate)
|
||||
- name: Verify binary size (< 950 KB gate)
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
BIN=build/esp32-csi-node.bin
|
||||
SIZE=$(stat -c%s "$BIN")
|
||||
MAX=$((${{ matrix.size_limit_kb }} * 1024))
|
||||
MAX=$((950 * 1024))
|
||||
echo "Binary size: $SIZE bytes ($(( SIZE / 1024 )) KB)"
|
||||
echo "Size limit: $MAX bytes (${{ matrix.size_limit_kb }} KB)"
|
||||
echo "Size limit: $MAX bytes (950 KB — includes Tier 3 WASM runtime)"
|
||||
if [ "$SIZE" -gt "$MAX" ]; then
|
||||
echo "::error::Firmware binary exceeds ${{ matrix.size_limit_kb }} KB size gate ($SIZE > $MAX)"
|
||||
echo "::error::Firmware binary exceeds 950 KB size gate ($SIZE > $MAX)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Binary size OK: $SIZE <= $MAX"
|
||||
@@ -112,27 +47,31 @@ jobs:
|
||||
ERRORS=0
|
||||
BIN=build/esp32-csi-node.bin
|
||||
|
||||
# Check binary exists and is non-empty.
|
||||
if [ ! -s "$BIN" ]; then
|
||||
echo "::error::Binary not found or empty"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check partition table magic (0xAA50 at offset 0).
|
||||
PT=build/partition_table/partition-table.bin
|
||||
if [ -f "$PT" ]; then
|
||||
MAGIC=$(od -A n -t x1 -N 2 "$PT" | tr -d ' ')
|
||||
MAGIC=$(xxd -l2 -p "$PT")
|
||||
if [ "$MAGIC" != "aa50" ]; then
|
||||
echo "::warning::Partition table magic mismatch: $MAGIC (expected aa50)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check bootloader exists.
|
||||
BL=build/bootloader/bootloader.bin
|
||||
if [ ! -s "$BL" ]; then
|
||||
echo "::warning::Bootloader binary missing or empty"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
NONZERO=$(od -A n -t x1 -N 1024 "$BIN" | tr -d ' f\n' | wc -c)
|
||||
# Verify non-zero data in binary (not all 0xFF padding).
|
||||
NONZERO=$(xxd -l 1024 -p "$BIN" | tr -d 'f' | wc -c)
|
||||
if [ "$NONZERO" -lt 100 ]; then
|
||||
echo "::error::Binary appears to be mostly padding (non-zero chars: $NONZERO)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
@@ -144,27 +83,18 @@ jobs:
|
||||
echo "Flash image integrity verified"
|
||||
fi
|
||||
|
||||
- name: Stage release binaries with variant-specific names
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
mkdir -p release-staging
|
||||
cp build/esp32-csi-node.bin release-staging/${{ matrix.artifact_app }}
|
||||
cp build/partition_table/partition-table.bin release-staging/${{ matrix.artifact_pt }}
|
||||
if [ "${{ matrix.variant }}" = "8mb" ]; then
|
||||
cp build/bootloader/bootloader.bin release-staging/bootloader.bin
|
||||
cp build/ota_data_initial.bin release-staging/ota_data_initial.bin
|
||||
fi
|
||||
ls -la release-staging/
|
||||
|
||||
- name: Check QEMU ESP32-S3 support status
|
||||
run: |
|
||||
echo "::notice::ESP32-S3 QEMU support is experimental in ESP-IDF v5.4. "
|
||||
echo "Full smoke testing requires QEMU 8.2+ with xtensa-esp32s3 target."
|
||||
echo "See: https://github.com/espressif/qemu/wiki"
|
||||
|
||||
- name: Upload firmware artifact (${{ matrix.variant }})
|
||||
- name: Upload firmware artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: esp32-csi-node-firmware-${{ matrix.variant }}
|
||||
path: firmware/esp32-csi-node/release-staging/
|
||||
retention-days: 90
|
||||
name: esp32-csi-node-firmware
|
||||
path: |
|
||||
firmware/esp32-csi-node/build/esp32-csi-node.bin
|
||||
firmware/esp32-csi-node/build/bootloader/bootloader.bin
|
||||
firmware/esp32-csi-node/build/partition_table/partition-table.bin
|
||||
retention-days: 30
|
||||
|
||||
@@ -1,370 +0,0 @@
|
||||
name: Firmware QEMU Tests (ADR-061)
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'firmware/**'
|
||||
- 'scripts/qemu-esp32s3-test.sh'
|
||||
- 'scripts/validate_qemu_output.py'
|
||||
- 'scripts/generate_nvs_matrix.py'
|
||||
- 'scripts/qemu_swarm.py'
|
||||
- 'scripts/swarm_health.py'
|
||||
- 'scripts/swarm_presets/**'
|
||||
- '.github/workflows/firmware-qemu.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'firmware/**'
|
||||
- 'scripts/qemu-esp32s3-test.sh'
|
||||
- 'scripts/validate_qemu_output.py'
|
||||
- 'scripts/generate_nvs_matrix.py'
|
||||
- 'scripts/qemu_swarm.py'
|
||||
- 'scripts/swarm_health.py'
|
||||
- 'scripts/swarm_presets/**'
|
||||
- '.github/workflows/firmware-qemu.yml'
|
||||
|
||||
env:
|
||||
IDF_VERSION: "v5.4"
|
||||
QEMU_REPO: "https://github.com/espressif/qemu.git"
|
||||
QEMU_BRANCH: "esp-develop"
|
||||
|
||||
jobs:
|
||||
build-qemu:
|
||||
name: Build Espressif QEMU
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Cache QEMU build
|
||||
id: cache-qemu
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /opt/qemu-esp32
|
||||
# Include date component so cache refreshes monthly when branch updates
|
||||
key: qemu-esp32s3-${{ env.QEMU_BRANCH }}-v5
|
||||
restore-keys: |
|
||||
qemu-esp32s3-${{ env.QEMU_BRANCH }}-
|
||||
|
||||
- name: Install QEMU build dependencies
|
||||
if: steps.cache-qemu.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
git build-essential ninja-build pkg-config \
|
||||
libglib2.0-dev libpixman-1-dev libslirp-dev \
|
||||
libgcrypt20-dev \
|
||||
python3 python3-venv
|
||||
|
||||
- name: Clone and build Espressif QEMU
|
||||
if: steps.cache-qemu.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
git clone --depth 1 -b "$QEMU_BRANCH" "$QEMU_REPO" /tmp/qemu-esp
|
||||
cd /tmp/qemu-esp
|
||||
mkdir build && cd build
|
||||
../configure \
|
||||
--target-list=xtensa-softmmu \
|
||||
--prefix=/opt/qemu-esp32 \
|
||||
--enable-slirp \
|
||||
--disable-werror
|
||||
ninja -j$(nproc)
|
||||
ninja install
|
||||
|
||||
- name: Verify QEMU binary
|
||||
run: |
|
||||
file_size() { stat -c%s "$1" 2>/dev/null || stat -f%z "$1" 2>/dev/null || wc -c < "$1"; }
|
||||
/opt/qemu-esp32/bin/qemu-system-xtensa --version
|
||||
echo "QEMU binary size: $(file_size /opt/qemu-esp32/bin/qemu-system-xtensa) bytes"
|
||||
|
||||
- name: Upload QEMU artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: qemu-esp32
|
||||
path: /opt/qemu-esp32/
|
||||
retention-days: 7
|
||||
|
||||
qemu-test:
|
||||
name: QEMU Test (${{ matrix.nvs_config }})
|
||||
needs: build-qemu
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: espressif/idf:v5.4
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
nvs_config:
|
||||
- default
|
||||
- full-adr060
|
||||
- edge-tier0
|
||||
- edge-tier1
|
||||
- tdm-3node
|
||||
- boundary-max
|
||||
- boundary-min
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download QEMU artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: qemu-esp32
|
||||
path: /opt/qemu-esp32
|
||||
|
||||
- name: Make QEMU executable
|
||||
run: chmod +x /opt/qemu-esp32/bin/qemu-system-xtensa
|
||||
|
||||
- name: Verify QEMU works
|
||||
run: /opt/qemu-esp32/bin/qemu-system-xtensa --version
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
pip install esptool esp-idf-nvs-partition-gen
|
||||
|
||||
- name: Set target ESP32-S3
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
idf.py set-target esp32s3
|
||||
|
||||
- name: Build firmware (mock CSI mode)
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
idf.py \
|
||||
-D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.qemu" \
|
||||
build
|
||||
|
||||
- name: Generate NVS matrix
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
python3 scripts/generate_nvs_matrix.py \
|
||||
--output-dir firmware/esp32-csi-node/build/nvs_matrix \
|
||||
--only ${{ matrix.nvs_config }}
|
||||
|
||||
- name: Create merged flash image
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
|
||||
# Determine merge_bin arguments
|
||||
OTA_ARGS=""
|
||||
if [ -f build/ota_data_initial.bin ]; then
|
||||
OTA_ARGS="0xf000 build/ota_data_initial.bin"
|
||||
fi
|
||||
|
||||
python3 -m esptool --chip esp32s3 merge_bin \
|
||||
-o build/qemu_flash.bin \
|
||||
--flash_mode dio --flash_freq 80m --flash_size 8MB \
|
||||
--fill-flash-size 8MB \
|
||||
0x0 build/bootloader/bootloader.bin \
|
||||
0x8000 build/partition_table/partition-table.bin \
|
||||
$OTA_ARGS \
|
||||
0x20000 build/esp32-csi-node.bin
|
||||
|
||||
file_size() { stat -c%s "$1" 2>/dev/null || stat -f%z "$1" 2>/dev/null || wc -c < "$1"; }
|
||||
echo "Flash image size: $(file_size build/qemu_flash.bin) bytes"
|
||||
|
||||
- name: Inject NVS partition
|
||||
if: matrix.nvs_config != 'default'
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
NVS_BIN="build/nvs_matrix/nvs_${{ matrix.nvs_config }}.bin"
|
||||
if [ -f "$NVS_BIN" ]; then
|
||||
file_size() { stat -c%s "$1" 2>/dev/null || stat -f%z "$1" 2>/dev/null || wc -c < "$1"; }
|
||||
echo "Injecting NVS: $NVS_BIN ($(file_size "$NVS_BIN") bytes)"
|
||||
dd if="$NVS_BIN" of=build/qemu_flash.bin \
|
||||
bs=1 seek=$((0x9000)) conv=notrunc 2>/dev/null
|
||||
else
|
||||
echo "WARNING: NVS binary not found: $NVS_BIN"
|
||||
fi
|
||||
|
||||
- name: Run QEMU smoke test
|
||||
env:
|
||||
QEMU_PATH: /opt/qemu-esp32/bin/qemu-system-xtensa
|
||||
QEMU_TIMEOUT: "90"
|
||||
run: |
|
||||
echo "Starting QEMU (timeout: ${QEMU_TIMEOUT}s)..."
|
||||
|
||||
timeout "$QEMU_TIMEOUT" "$QEMU_PATH" \
|
||||
-machine esp32s3 \
|
||||
-nographic \
|
||||
-drive file=firmware/esp32-csi-node/build/qemu_flash.bin,if=mtd,format=raw \
|
||||
-serial mon:stdio \
|
||||
-nic user,model=open_eth,net=10.0.2.0/24 \
|
||||
-no-reboot \
|
||||
2>&1 | tee firmware/esp32-csi-node/build/qemu_output.log || true
|
||||
|
||||
echo "QEMU finished. Log size: $(wc -l < firmware/esp32-csi-node/build/qemu_output.log) lines"
|
||||
|
||||
- name: Validate QEMU output
|
||||
run: |
|
||||
python3 scripts/validate_qemu_output.py \
|
||||
firmware/esp32-csi-node/build/qemu_output.log
|
||||
|
||||
- name: Upload test logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: qemu-logs-${{ matrix.nvs_config }}
|
||||
path: |
|
||||
firmware/esp32-csi-node/build/qemu_output.log
|
||||
firmware/esp32-csi-node/build/nvs_matrix/
|
||||
retention-days: 14
|
||||
|
||||
fuzz-test:
|
||||
name: Fuzz Testing (ADR-061 Layer 6)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install clang
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang
|
||||
|
||||
- name: Build fuzz targets
|
||||
working-directory: firmware/esp32-csi-node/test
|
||||
run: make all CC=clang
|
||||
|
||||
- name: Run serialize fuzzer (60s)
|
||||
working-directory: firmware/esp32-csi-node/test
|
||||
run: make run_serialize FUZZ_DURATION=60 || echo "FUZZER_CRASH=serialize" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run edge enqueue fuzzer (60s)
|
||||
working-directory: firmware/esp32-csi-node/test
|
||||
run: make run_edge FUZZ_DURATION=60 || echo "FUZZER_CRASH=edge" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run NVS config fuzzer (60s)
|
||||
working-directory: firmware/esp32-csi-node/test
|
||||
run: make run_nvs FUZZ_DURATION=60 || echo "FUZZER_CRASH=nvs" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check for crashes
|
||||
working-directory: firmware/esp32-csi-node/test
|
||||
run: |
|
||||
CRASHES=$(find . -type f \( -name "crash-*" -o -name "oom-*" -o -name "timeout-*" \) 2>/dev/null | wc -l)
|
||||
echo "Crash artifacts found: $CRASHES"
|
||||
if [ "$CRASHES" -gt 0 ] || [ -n "${FUZZER_CRASH:-}" ]; then
|
||||
echo "::error::Fuzzer found $CRASHES crash/oom/timeout artifacts. FUZZER_CRASH=${FUZZER_CRASH:-none}"
|
||||
ls -la crash-* oom-* timeout-* 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload fuzz artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fuzz-crashes
|
||||
path: |
|
||||
firmware/esp32-csi-node/test/crash-*
|
||||
firmware/esp32-csi-node/test/oom-*
|
||||
firmware/esp32-csi-node/test/timeout-*
|
||||
retention-days: 30
|
||||
|
||||
nvs-matrix-validate:
|
||||
name: NVS Matrix Generation
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install NVS generator
|
||||
run: pip install esp-idf-nvs-partition-gen
|
||||
|
||||
- name: Generate all 14 NVS configs
|
||||
run: |
|
||||
python3 scripts/generate_nvs_matrix.py \
|
||||
--output-dir build/nvs_matrix
|
||||
|
||||
- name: Verify all binaries generated
|
||||
run: |
|
||||
EXPECTED=14
|
||||
ACTUAL=$(find build/nvs_matrix -type f -name "nvs_*.bin" 2>/dev/null | wc -l)
|
||||
echo "Generated $ACTUAL / $EXPECTED NVS binaries"
|
||||
ls -la build/nvs_matrix/
|
||||
|
||||
if [ "$ACTUAL" -lt "$EXPECTED" ]; then
|
||||
echo "::error::Only $ACTUAL of $EXPECTED NVS binaries generated"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify binary sizes
|
||||
run: |
|
||||
file_size() { stat -c%s "$1" 2>/dev/null || stat -f%z "$1" 2>/dev/null || wc -c < "$1"; }
|
||||
for f in build/nvs_matrix/nvs_*.bin; do
|
||||
SIZE=$(file_size "$f")
|
||||
if [ "$SIZE" -ne 24576 ]; then
|
||||
echo "::error::$f has unexpected size $SIZE (expected 24576)"
|
||||
exit 1
|
||||
fi
|
||||
echo " OK: $(basename $f) ($SIZE bytes)"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ADR-062: QEMU Swarm Configurator Test
|
||||
#
|
||||
# Runs a lightweight 3-node swarm (ci_matrix preset) under QEMU to validate
|
||||
# multi-node orchestration, TDM slot coordination, and swarm-level health
|
||||
# assertions. Uses the pre-built QEMU binary from the build-qemu job and the
|
||||
# firmware built by qemu-test.
|
||||
#
|
||||
# The CI runner is non-root, so TAP bridge networking is unavailable.
|
||||
# The orchestrator (qemu_swarm.py) detects this and falls back to SLIRP
|
||||
# user-mode networking, which is sufficient for the ci_matrix preset.
|
||||
# ---------------------------------------------------------------------------
|
||||
swarm-test:
|
||||
name: Swarm Test (ADR-062)
|
||||
needs: [build-qemu]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: espressif/idf:v5.4
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download QEMU artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: qemu-esp32
|
||||
path: /opt/qemu-esp32
|
||||
|
||||
- name: Make QEMU executable
|
||||
run: chmod +x /opt/qemu-esp32/bin/qemu-system-xtensa
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
pip install pyyaml esptool esp-idf-nvs-partition-gen
|
||||
|
||||
- name: Build firmware for swarm
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
idf.py set-target esp32s3
|
||||
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.qemu" build
|
||||
python3 -m esptool --chip esp32s3 merge_bin \
|
||||
-o build/qemu_flash.bin \
|
||||
--flash_mode dio --flash_freq 80m --flash_size 8MB \
|
||||
--fill-flash-size 8MB \
|
||||
0x0 build/bootloader/bootloader.bin \
|
||||
0x8000 build/partition_table/partition-table.bin \
|
||||
0x20000 build/esp32-csi-node.bin
|
||||
|
||||
- name: Run swarm smoke test
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
EXIT_CODE=0
|
||||
python3 scripts/qemu_swarm.py --preset ci_matrix \
|
||||
--qemu-path /opt/qemu-esp32/bin/qemu-system-xtensa \
|
||||
--output-dir build/swarm-results || EXIT_CODE=$?
|
||||
# Exit 0=PASS, 1=WARN (acceptable in CI without real hardware)
|
||||
if [ "$EXIT_CODE" -gt 1 ]; then
|
||||
echo "Swarm test failed with exit code $EXIT_CODE"
|
||||
exit "$EXIT_CODE"
|
||||
fi
|
||||
timeout-minutes: 10
|
||||
|
||||
- name: Upload swarm results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: swarm-results
|
||||
path: |
|
||||
build/swarm-results/
|
||||
retention-days: 14
|
||||
@@ -1,54 +0,0 @@
|
||||
name: Fix-Marker Regression Guard
|
||||
|
||||
# Asserts that previously-shipped fixes are still present in the tree.
|
||||
# Manifest: scripts/fix-markers.json Checker: scripts/check_fix_markers.py
|
||||
# Run locally: python scripts/check_fix_markers.py (also --list / --json)
|
||||
#
|
||||
# This complements the heavyweight checks (firmware build, deterministic
|
||||
# pipeline proof, witness bundle) with a fast per-PR "did someone revert a
|
||||
# known fix?" gate — the CI analogue of the ruflo witness fix-marker system.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
fix-markers:
|
||||
name: Verify fix markers
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Validate the manifest is well-formed JSON
|
||||
run: python -c "import json; json.load(open('scripts/fix-markers.json')); print('manifest OK')"
|
||||
|
||||
- name: Check fix markers
|
||||
run: python scripts/check_fix_markers.py
|
||||
|
||||
- name: Emit machine-readable result (for the run summary)
|
||||
if: always()
|
||||
run: |
|
||||
python scripts/check_fix_markers.py --json > fix-markers-result.json || true
|
||||
{
|
||||
echo '### Fix-marker regression guard'
|
||||
echo ''
|
||||
echo '```'
|
||||
python scripts/check_fix_markers.py || true
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload result artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fix-markers-result
|
||||
path: fix-markers-result.json
|
||||
retention-days: 30
|
||||
@@ -1,110 +0,0 @@
|
||||
name: ADR-115 MQTT integration tests
|
||||
|
||||
# Runs the Mosquitto-broker-backed integration tests for ADR-115's MQTT
|
||||
# publisher. These prove the publisher reaches a real broker, emits the
|
||||
# expected HA-discovery topic shape, and honours --privacy-mode at the
|
||||
# wire boundary (not just in unit-test logic).
|
||||
#
|
||||
# Default `cargo test --workspace` does not run these tests because they
|
||||
# require a broker and pull rumqttc into the build. This workflow opts
|
||||
# into both by setting --features mqtt and RUVIEW_RUN_INTEGRATION=1.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'v2/crates/wifi-densepose-sensing-server/src/mqtt/**'
|
||||
- 'v2/crates/wifi-densepose-sensing-server/tests/mqtt_integration.rs'
|
||||
- 'v2/crates/wifi-densepose-sensing-server/Cargo.toml'
|
||||
- '.github/workflows/mqtt-integration.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'v2/crates/wifi-densepose-sensing-server/src/mqtt/**'
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
mqtt-integration:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
# NB: we don't use a `services:` mosquitto container here because the
|
||||
# eclipse-mosquitto:2.x image rejects anonymous connections by default
|
||||
# and GH Actions `services` doesn't easily support mounting a custom
|
||||
# config file. We start mosquitto manually in a step below with an
|
||||
# inline `allow_anonymous true` config.
|
||||
|
||||
env:
|
||||
RUVIEW_RUN_INTEGRATION: "1"
|
||||
RUVIEW_TEST_MQTT_PORT: "11883"
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install mosquitto + clients and start with allow_anonymous
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y mosquitto mosquitto-clients
|
||||
sudo systemctl stop mosquitto || true
|
||||
# Inline config: anon listener on 11883 only — no TLS, no auth,
|
||||
# OK for CI because we test the wire shape, not security.
|
||||
# Production deployments enable mTLS per ADR-115 §3.9.
|
||||
cat > /tmp/mosquitto-ci.conf <<'EOF'
|
||||
listener 11883
|
||||
allow_anonymous true
|
||||
persistence false
|
||||
log_dest stdout
|
||||
EOF
|
||||
mosquitto -c /tmp/mosquitto-ci.conf -d
|
||||
for i in {1..20}; do
|
||||
if mosquitto_pub -h 127.0.0.1 -p 11883 -t healthcheck -m ok -q 0 2>/dev/null; then
|
||||
echo "mosquitto reachable on 11883"; exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "mosquitto never became reachable" >&2
|
||||
tail -50 /var/log/mosquitto/*.log 2>/dev/null || true
|
||||
exit 1
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache cargo registry + build
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: v2 -> target
|
||||
|
||||
- name: Validate HA Blueprints
|
||||
run: |
|
||||
python -m pip install --quiet pyyaml
|
||||
python scripts/validate-ha-blueprints.py
|
||||
|
||||
- name: Verify unit tests still pass under --features mqtt
|
||||
working-directory: v2
|
||||
# `cargo test` accepts a single TESTNAME filter, so we run the
|
||||
# whole --lib suite here. That gives us the full 410-test green
|
||||
# bar under --features mqtt (which is more reassuring than
|
||||
# filtering anyway).
|
||||
run: >-
|
||||
cargo test -p wifi-densepose-sensing-server
|
||||
--features mqtt --no-default-features
|
||||
--lib
|
||||
--no-fail-fast
|
||||
|
||||
- name: Run integration tests against mosquitto
|
||||
working-directory: v2
|
||||
run: >-
|
||||
cargo test -p wifi-densepose-sensing-server
|
||||
--features mqtt --no-default-features
|
||||
--test mqtt_integration
|
||||
--no-fail-fast
|
||||
-- --test-threads=1 --nocapture
|
||||
|
||||
- name: Dump broker logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
docker ps -a
|
||||
docker logs $(docker ps -aqf "ancestor=eclipse-mosquitto:2.0.18") || true
|
||||
@@ -1,69 +0,0 @@
|
||||
name: nvsim-server → ghcr.io
|
||||
|
||||
# Builds and publishes the nvsim-server Docker image to ghcr.io on:
|
||||
# - push to main affecting nvsim-server or nvsim
|
||||
# - tag push matching nvsim-server-v*
|
||||
# - manual workflow_dispatch
|
||||
#
|
||||
# ADR-092 §6.2 + §9.4.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'v2/crates/nvsim-server/**'
|
||||
- 'v2/crates/nvsim/**'
|
||||
- '.github/workflows/nvsim-server-docker.yml'
|
||||
tags: ['nvsim-server-v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/ruvnet/nvsim-server
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build + push
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: v2
|
||||
file: v2/crates/nvsim-server/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Smoke-test the image
|
||||
run: |
|
||||
docker pull ghcr.io/ruvnet/nvsim-server:sha-${GITHUB_SHA::7} || \
|
||||
docker pull ghcr.io/ruvnet/nvsim-server:latest
|
||||
docker run --rm -d --name nvsim-test -p 7878:7878 \
|
||||
ghcr.io/ruvnet/nvsim-server:latest
|
||||
sleep 4
|
||||
curl -fsS http://localhost:7878/api/health
|
||||
docker stop nvsim-test
|
||||
@@ -1,286 +0,0 @@
|
||||
# ADR-117 P5 — cibuildwheel + PyPI publish workflow for `wifi-densepose`
|
||||
#
|
||||
# This workflow is **explicitly NOT** triggered on every push. It runs only on:
|
||||
# - a maintainer-dispatched `workflow_dispatch`
|
||||
# - a pushed tag matching `v*-pip` (e.g. `v2.0.0-pip`)
|
||||
#
|
||||
# The reason for the `-pip` tag suffix is that the repo already cuts
|
||||
# `v0.X.Y-esp32` tags for firmware releases (see CLAUDE.md). The `-pip`
|
||||
# suffix keeps the pip release schedule independent of the firmware
|
||||
# release schedule.
|
||||
#
|
||||
# Sequencing on release day (per ADR-117 §7.3):
|
||||
# 1. cut tag `v1.99.0-pip` → publishes the tombstone wheel first
|
||||
# 2. cut tag `v2.0.0-pip` → publishes the PyO3 v2 wheel matrix
|
||||
#
|
||||
# Publishes via the `PYPI_API_TOKEN` GitHub Actions secret. The
|
||||
# token-refresh runbook (GCP Secret Manager → gh secret set) lives in
|
||||
# docs/integrations/pypi-release.md so KICS does not flag the
|
||||
# secret name as a generic-secret literal in the workflow.
|
||||
#
|
||||
# Q3 (witness hash v2 — open in ADR-117 §11.3) MUST be resolved
|
||||
# before the first v2.0.0 publish. When v2 lands, add a parallel
|
||||
# step that verifies the v2 hash against the Rust pipeline.
|
||||
|
||||
name: pip-release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target:
|
||||
description: "Which package to release"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- v2-wheels
|
||||
- v1-99-tombstone
|
||||
publish_to:
|
||||
description: "Where to publish"
|
||||
required: true
|
||||
default: testpypi
|
||||
type: choice
|
||||
options:
|
||||
- testpypi # dry-run target
|
||||
- pypi # production
|
||||
push:
|
||||
tags:
|
||||
- "v*-pip"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# v2.0.0 — cibuildwheel matrix (5 wheels + sdist)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
build-wheels:
|
||||
name: Build ${{ matrix.os }} ${{ matrix.arch }}
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' ||
|
||||
startsWith(github.ref, 'refs/tags/v2.')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
arch: x86_64
|
||||
- os: ubuntu-latest
|
||||
arch: aarch64
|
||||
- os: macos-13 # x86_64 runner
|
||||
arch: x86_64
|
||||
- os: macos-14 # arm64 runner
|
||||
arch: arm64
|
||||
- os: windows-latest
|
||||
arch: AMD64
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Linux aarch64 needs QEMU for cross-build on x86_64 runners.
|
||||
- name: Set up QEMU
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.arch == 'aarch64'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
# ADR-117 §5.4: abi3-py310 — one binary per OS/arch covers all
|
||||
# Python minor versions ≥ 3.10. Build only cp310 wheels.
|
||||
- name: Build wheels (cibuildwheel)
|
||||
uses: pypa/cibuildwheel@v2.21
|
||||
env:
|
||||
CIBW_BUILD: "cp310-*"
|
||||
CIBW_ARCHS_LINUX: ${{ matrix.arch }}
|
||||
CIBW_ARCHS_MACOS: ${{ matrix.arch }}
|
||||
CIBW_ARCHS_WINDOWS: ${{ matrix.arch }}
|
||||
CIBW_BUILD_FRONTEND: "build"
|
||||
CIBW_BEFORE_BUILD: "pip install maturin>=1.7"
|
||||
# The PyO3 sdist landing depends on the cargo/Rust toolchain
|
||||
# being present. cibuildwheel images carry rustup on Linux
|
||||
# but we also pin a known-good version for reproducibility.
|
||||
CIBW_BEFORE_ALL_LINUX: "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.82"
|
||||
CIBW_ENVIRONMENT_LINUX: 'PATH="$HOME/.cargo/bin:$PATH"'
|
||||
# Smoke-test every built wheel before accepting it. Catches
|
||||
# the case where the wheel imports but the compiled symbols
|
||||
# are missing.
|
||||
CIBW_TEST_REQUIRES: "pytest>=8.0"
|
||||
CIBW_TEST_COMMAND: 'python -c "import wifi_densepose; assert wifi_densepose.hello() == \"ok\"; print(wifi_densepose.__build_features__)"'
|
||||
with:
|
||||
package-dir: python
|
||||
output-dir: wheelhouse
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: wheels-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: wheelhouse/*.whl
|
||||
if-no-files-found: error
|
||||
|
||||
build-sdist:
|
||||
name: Build v2 sdist
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' ||
|
||||
startsWith(github.ref, 'refs/tags/v2.')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install maturin
|
||||
run: pip install maturin>=1.7
|
||||
- name: Build sdist
|
||||
working-directory: python
|
||||
run: maturin sdist --out ../sdist
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: sdist
|
||||
path: sdist/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# v1.99.0 — tombstone wheel (pure Python, single sdist + wheel)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
build-tombstone:
|
||||
name: Build v1.99.0 tombstone
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' && inputs.target == 'v1-99-tombstone' ||
|
||||
startsWith(github.ref, 'refs/tags/v1.99')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install build backend
|
||||
run: python -m pip install --upgrade pip build>=1.2
|
||||
- name: Build sdist + wheel
|
||||
working-directory: python/tombstone
|
||||
run: python -m build --outdir ../../tombstone-dist
|
||||
# Inspect what was actually built — the previous v1.99.0-pip run
|
||||
# showed an `import wifi_densepose` that returned cleanly instead
|
||||
# of raising, even though build logs said `adding 'wifi_densepose/__init__.py'`.
|
||||
# Print the wheel manifest + the __init__.py content so any
|
||||
# future regression is debuggable from the run log alone.
|
||||
- name: Inspect wheel contents
|
||||
run: |
|
||||
set -e
|
||||
WHL=tombstone-dist/wifi_densepose-1.99.0-py3-none-any.whl
|
||||
echo "--- wheel listing ---"
|
||||
python -m zipfile -l "$WHL"
|
||||
echo "--- wifi_densepose/__init__.py inside the wheel ---"
|
||||
python -m zipfile -e "$WHL" /tmp/tomb-inspect
|
||||
cat /tmp/tomb-inspect/wifi_densepose/__init__.py
|
||||
echo "--- size in bytes ---"
|
||||
wc -c /tmp/tomb-inspect/wifi_densepose/__init__.py
|
||||
# Smoke-test in an ISOLATED venv. The previous run's failure
|
||||
# mode was that the ubuntu-latest runner's system `python` had
|
||||
# site-packages picking up something other than the user-installed
|
||||
# wheel, so the import resolved to a different module. A clean
|
||||
# venv removes any ambiguity about which wifi_densepose is loaded.
|
||||
- name: Smoke-test tombstone in isolated venv
|
||||
run: |
|
||||
set -e
|
||||
# Copy the wheel to /tmp BEFORE entering the venv — we must
|
||||
# cd OUT of the repo root because the repo contains a
|
||||
# `wifi_densepose/` directory left over from the legacy v1
|
||||
# source. Python puts cwd at sys.path[0], so an import from
|
||||
# the repo root would resolve to the legacy directory and
|
||||
# bypass the freshly-installed wheel entirely (this was the
|
||||
# silent failure mode of the previous two run attempts).
|
||||
cp tombstone-dist/wifi_densepose-1.99.0-py3-none-any.whl /tmp/
|
||||
python -m venv /tmp/smoke-venv
|
||||
/tmp/smoke-venv/bin/python -m pip install --upgrade pip
|
||||
/tmp/smoke-venv/bin/python -m pip install /tmp/wifi_densepose-1.99.0-py3-none-any.whl
|
||||
cd /tmp # away from the repo root's stray wifi_densepose/
|
||||
/tmp/smoke-venv/bin/python -c "import importlib.util as u; s = u.find_spec('wifi_densepose'); print('Resolved to:', s.origin); print('--- file content ---'); print(open(s.origin).read())"
|
||||
set +e
|
||||
/tmp/smoke-venv/bin/python -c "import wifi_densepose" 2> import-output.txt
|
||||
rc=$?
|
||||
set -e
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
echo "ERROR: tombstone import succeeded — should have raised ImportError"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "github.com/ruvnet/RuView" import-output.txt; then
|
||||
echo "ERROR: tombstone ImportError missing migration URL"
|
||||
cat import-output.txt
|
||||
exit 1
|
||||
fi
|
||||
echo "Tombstone wheel correctly raises ImportError with migration URL."
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tombstone
|
||||
path: tombstone-dist/*
|
||||
if-no-files-found: error
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Publish — gated by manual dispatch OR by the tag form
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
publish-v2:
|
||||
name: Publish v2 wheels
|
||||
needs: [build-wheels, build-sdist]
|
||||
if: |
|
||||
always() &&
|
||||
needs.build-wheels.result == 'success' &&
|
||||
needs.build-sdist.result == 'success' &&
|
||||
(
|
||||
github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' ||
|
||||
startsWith(github.ref, 'refs/tags/v2.')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Gather all artifacts into dist/
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist-staging
|
||||
- name: Flatten artifacts
|
||||
run: |
|
||||
mkdir -p dist
|
||||
find dist-staging -type f \( -name '*.whl' -o -name '*.tar.gz' \) -exec cp -v {} dist/ \;
|
||||
ls -lh dist/
|
||||
- name: Publish to TestPyPI (dry-run target)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish_to == 'testpypi'
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
packages-dir: dist
|
||||
skip-existing: true
|
||||
- name: Publish to PyPI
|
||||
if: |
|
||||
startsWith(github.ref, 'refs/tags/v2.') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.publish_to == 'pypi')
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
packages-dir: dist
|
||||
|
||||
publish-tombstone:
|
||||
name: Publish v1.99 tombstone
|
||||
needs: [build-tombstone]
|
||||
if: |
|
||||
always() &&
|
||||
needs.build-tombstone.result == 'success' &&
|
||||
(
|
||||
github.event_name == 'workflow_dispatch' && inputs.target == 'v1-99-tombstone' ||
|
||||
startsWith(github.ref, 'refs/tags/v1.99')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: tombstone
|
||||
path: dist
|
||||
- name: Publish to TestPyPI (dry-run target)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish_to == 'testpypi'
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
packages-dir: dist
|
||||
skip-existing: true
|
||||
- name: Publish to PyPI
|
||||
if: |
|
||||
startsWith(github.ref, 'refs/tags/v1.99') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.publish_to == 'pypi')
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
packages-dir: dist
|
||||
@@ -1,74 +0,0 @@
|
||||
name: Point Cloud Viewer → GitHub Pages
|
||||
|
||||
# Publishes the live 3D point cloud viewer to gh-pages/pointcloud/.
|
||||
# The viewer defaults to a synthetic in-browser demo; users can append
|
||||
# ?backend=<url> or ?backend=auto to point it at a real ruview-pointcloud
|
||||
# server (CORS-permitting host required). See ADR-094.
|
||||
#
|
||||
# Uses keep_files: true to preserve the existing observatory/, pose-fusion/,
|
||||
# nvsim/, and root index.html demos already on gh-pages.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'v2/crates/wifi-densepose-pointcloud/src/viewer.html'
|
||||
- '.github/workflows/pointcloud-pages.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: pointcloud-pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Stage viewer for Pages
|
||||
run: |
|
||||
mkdir -p _site/pointcloud
|
||||
cp v2/crates/wifi-densepose-pointcloud/src/viewer.html _site/pointcloud/index.html
|
||||
# Drop a tiny README so direct browsers of the directory get context.
|
||||
cat > _site/pointcloud/README.md <<'EOF'
|
||||
# RuView — Live 3D Point Cloud Viewer
|
||||
|
||||
Hosted at: https://ruvnet.github.io/RuView/pointcloud/
|
||||
|
||||
## Modes
|
||||
|
||||
- Default — synthetic in-browser demo (no backend, no network calls).
|
||||
- `?backend=auto` — fetch from `/api/splats` on the same origin
|
||||
(only works when the viewer is served by `ruview-pointcloud serve`).
|
||||
- `?backend=<url>` — fetch from `<url>/api/splats`. The intended
|
||||
local-ESP32 use is `?backend=http://127.0.0.1:9880`: run
|
||||
`ruview-pointcloud serve --bind 127.0.0.1:9880` on the same
|
||||
machine with your ESP32 streaming CSI to UDP port 3333, then
|
||||
visit the URL above. The local server's CorsLayer permits
|
||||
requests from `https://ruvnet.github.io`, and modern browsers
|
||||
permit HTTPS→127.0.0.1 mixed-content as a trustworthy origin.
|
||||
The "📡 Connect ESP32" button in the viewer prompts for this
|
||||
URL and persists it in localStorage.
|
||||
- `?live=1` — require a live backend; show an offline message instead
|
||||
of falling back to the synthetic demo.
|
||||
|
||||
See ADR-094 for the deployment design.
|
||||
EOF
|
||||
|
||||
- name: Deploy to gh-pages/pointcloud/
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./_site/pointcloud
|
||||
destination_dir: pointcloud
|
||||
# CRITICAL: preserves observatory/, pose-fusion/, nvsim/, and root
|
||||
# index.html already on gh-pages.
|
||||
keep_files: true
|
||||
commit_message: 'deploy(pointcloud): ${{ github.sha }}'
|
||||
user_name: 'github-actions[bot]'
|
||||
user_email: 'github-actions[bot]@users.noreply.github.com'
|
||||
@@ -1,149 +0,0 @@
|
||||
name: ruview-swarm CI guard
|
||||
|
||||
# Dedicated guard for the ADR-148 drone swarm crate (`v2/crates/ruview-swarm`).
|
||||
# The main ci.yml runs `cargo test --workspace --no-default-features`, which
|
||||
# only exercises ruview-swarm's DEFAULT feature set. This guard additionally:
|
||||
# - tests every feature combination (train / ruflo+itar / full)
|
||||
# - fails on ANY clippy warning in the crate's own code (--no-deps)
|
||||
# - asserts the ITAR + publish guards stay in place (USML Cat VIII(h)(12))
|
||||
# - builds the GPU training binary under the `train` feature
|
||||
#
|
||||
# Path-scoped so it only runs when the crate or this workflow changes.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, 'feat/*' ]
|
||||
paths:
|
||||
- 'v2/crates/ruview-swarm/**'
|
||||
- '.github/workflows/ruview-swarm-ci.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'v2/crates/ruview-swarm/**'
|
||||
- '.github/workflows/ruview-swarm-ci.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
# ── Feature-matrix tests ─────────────────────────────────────────────────
|
||||
tests:
|
||||
name: tests (${{ matrix.features.label }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
features:
|
||||
- { label: 'default', flags: '--no-default-features' }
|
||||
- { label: 'train', flags: '--features train' }
|
||||
- { label: 'ruflo+itar', flags: '--features ruflo,itar-unrestricted' }
|
||||
- { label: 'full+train', flags: '--features full,train' }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
v2/target
|
||||
key: ${{ runner.os }}-ruview-swarm-${{ hashFiles('v2/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-ruview-swarm-
|
||||
- name: cargo test -p ruview-swarm ${{ matrix.features.flags }}
|
||||
working-directory: v2
|
||||
run: cargo test -p ruview-swarm ${{ matrix.features.flags }} --lib
|
||||
|
||||
# ── Clippy: zero warnings in the crate's own code ────────────────────────
|
||||
clippy:
|
||||
name: clippy (-D warnings, --no-deps)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# v2/rust-toolchain.toml pins channel "1.89" with profile "minimal" (no
|
||||
# clippy). dtolnay@stable installs clippy on the floating "stable"
|
||||
# toolchain, but the override makes cargo use the separate "1.89"
|
||||
# toolchain — so `cargo clippy` errors "cargo-clippy is not installed for
|
||||
# 1.89". Install clippy on the pinned toolchain that cargo actually uses.
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: "1.89"
|
||||
components: clippy
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
v2/target
|
||||
key: ${{ runner.os }}-ruview-swarm-clippy-${{ hashFiles('v2/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-ruview-swarm-clippy-
|
||||
# --no-deps confines linting to ruview-swarm's own source, so pre-existing
|
||||
# warnings in dependency crates don't gate this PR.
|
||||
- name: clippy (default)
|
||||
working-directory: v2
|
||||
run: cargo clippy -p ruview-swarm --no-default-features --no-deps -- -D warnings
|
||||
- name: clippy (full,train)
|
||||
working-directory: v2
|
||||
run: cargo clippy -p ruview-swarm --features full,train --no-deps -- -D warnings
|
||||
|
||||
# ── Build the GPU training binary (train feature) ────────────────────────
|
||||
train-bin:
|
||||
name: build train_marl bin
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
v2/target
|
||||
key: ${{ runner.os }}-ruview-swarm-bin-${{ hashFiles('v2/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-ruview-swarm-bin-
|
||||
- name: cargo build --bin train_marl --features train
|
||||
working-directory: v2
|
||||
run: cargo build -p ruview-swarm --features train --bin train_marl
|
||||
- name: train_marl is excluded from the default build
|
||||
working-directory: v2
|
||||
run: |
|
||||
# The training binary requires the `train` feature; a default `--bins`
|
||||
# build must NOT produce it (keeps default/CI builds light + Candle-free).
|
||||
# Remove any prior artifact first so this checks what the DEFAULT build
|
||||
# produces, not a leftover from the train-feature build above.
|
||||
rm -f target/debug/train_marl
|
||||
cargo build -p ruview-swarm --no-default-features --bins
|
||||
if [ -f target/debug/train_marl ]; then
|
||||
echo "ERROR: train_marl built without the 'train' feature" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: train_marl correctly gated behind the 'train' feature"
|
||||
|
||||
# ── ITAR + publish guards ────────────────────────────────────────────────
|
||||
export-control-guard:
|
||||
name: ITAR / publish guard
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: publish = false is present (no accidental crates.io publish)
|
||||
run: |
|
||||
CARGO=v2/crates/ruview-swarm/Cargo.toml
|
||||
if ! grep -qE '^\s*publish\s*=\s*false' "$CARGO"; then
|
||||
echo "ERROR: ruview-swarm Cargo.toml must keep 'publish = false' until" >&2
|
||||
echo " PR merge + dependency publish + ITAR export sign-off." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: publish = false present"
|
||||
- name: default feature set does NOT enable itar-unrestricted
|
||||
run: |
|
||||
CARGO=v2/crates/ruview-swarm/Cargo.toml
|
||||
# USML Cat VIII(h)(12): swarming coordination must be opt-in, never default.
|
||||
DEFAULT_LINE=$(grep -E '^\s*default\s*=' "$CARGO" || true)
|
||||
echo "default = $DEFAULT_LINE"
|
||||
if echo "$DEFAULT_LINE" | grep -q 'itar-unrestricted'; then
|
||||
echo "ERROR: 'itar-unrestricted' must NOT be in the default feature set" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: ITAR-gated coordination features are opt-in, not default"
|
||||
@@ -18,27 +18,23 @@ jobs:
|
||||
sast:
|
||||
name: Static Application Security Testing
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
permissions:
|
||||
security-events: write
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
@@ -46,38 +42,34 @@ jobs:
|
||||
|
||||
- name: Run Bandit security scan
|
||||
run: |
|
||||
# The Python codebase lives under archive/v1/src (it moved there when
|
||||
# the runtime was rewritten in Rust). Scanning `src/` matched nothing,
|
||||
# so this SAST step was a silent no-op.
|
||||
bandit -r archive/v1/src/ -f sarif -o bandit-results.sarif
|
||||
bandit -r src/ -f sarif -o bandit-results.sarif
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Bandit results to GitHub Security
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: bandit-results.sarif
|
||||
category: bandit
|
||||
|
||||
# Removed the deprecated `returntocorp/semgrep-action@v1` step: it was
|
||||
# redundant (the pip `semgrep --sarif` below is what feeds GitHub Security;
|
||||
# the action only pushed to the Semgrep cloud app via SEMGREP_APP_TOKEN) and
|
||||
# it pulled `returntocorp/semgrep-agent:v1` from Docker Hub on every run,
|
||||
# which intermittently timed out and turned this check red. The pip semgrep
|
||||
# (installed above) needs no Docker pull. The action's `p/docker` +
|
||||
# `p/kubernetes` rulesets are folded into the command below so coverage is
|
||||
# preserved.
|
||||
- name: Run Semgrep + generate SARIF
|
||||
- name: Run Semgrep security scan
|
||||
uses: returntocorp/semgrep-action@v1
|
||||
with:
|
||||
config: >-
|
||||
p/security-audit
|
||||
p/secrets
|
||||
p/python
|
||||
p/docker
|
||||
p/kubernetes
|
||||
env:
|
||||
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
|
||||
|
||||
- name: Generate Semgrep SARIF
|
||||
run: |
|
||||
semgrep \
|
||||
--config=p/security-audit --config=p/secrets --config=p/python \
|
||||
--config=p/docker --config=p/kubernetes \
|
||||
--sarif --output=semgrep.sarif archive/v1/src/
|
||||
semgrep --config=p/security-audit --config=p/secrets --config=p/python --sarif --output=semgrep.sarif src/
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Semgrep results to GitHub Security
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -88,25 +80,21 @@ jobs:
|
||||
dependency-scan:
|
||||
name: Dependency Vulnerability Scan
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
permissions:
|
||||
security-events: write
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
@@ -123,7 +111,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run Snyk vulnerability scan
|
||||
uses: snyk/actions/python@9adf32b1121593767fc3c057af55b55db032dc04 # v1.0.0
|
||||
uses: snyk/actions/python@master
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
with:
|
||||
@@ -131,7 +119,6 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Snyk results to GitHub Security
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -139,7 +126,6 @@ jobs:
|
||||
category: snyk
|
||||
|
||||
- name: Upload vulnerability reports
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
@@ -153,7 +139,6 @@ jobs:
|
||||
container-scan:
|
||||
name: Container Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
needs: []
|
||||
if: github.event_name == 'push' || github.event_name == 'schedule'
|
||||
permissions:
|
||||
@@ -162,16 +147,13 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
continue-on-error: true
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker image for scanning
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
target: production
|
||||
@@ -181,15 +163,13 @@ jobs:
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
continue-on-error: true
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: 'wifi-densepose:scan'
|
||||
format: 'sarif'
|
||||
output: 'trivy-results.sarif'
|
||||
|
||||
- name: Upload Trivy results to GitHub Security
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -197,8 +177,7 @@ jobs:
|
||||
category: trivy
|
||||
|
||||
- name: Run Grype vulnerability scanner
|
||||
continue-on-error: true
|
||||
uses: anchore/scan-action@v7
|
||||
uses: anchore/scan-action@v3
|
||||
id: grype-scan
|
||||
with:
|
||||
image: 'wifi-densepose:scan'
|
||||
@@ -207,7 +186,6 @@ jobs:
|
||||
output-format: sarif
|
||||
|
||||
- name: Upload Grype results to GitHub Security
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -215,7 +193,6 @@ jobs:
|
||||
category: grype
|
||||
|
||||
- name: Run Docker Scout
|
||||
continue-on-error: true
|
||||
uses: docker/scout-action@v1
|
||||
if: always()
|
||||
with:
|
||||
@@ -225,7 +202,6 @@ jobs:
|
||||
summary: true
|
||||
|
||||
- name: Upload Docker Scout results
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -236,19 +212,16 @@ jobs:
|
||||
iac-scan:
|
||||
name: Infrastructure Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
permissions:
|
||||
security-events: write
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run Checkov IaC scan
|
||||
continue-on-error: true
|
||||
uses: bridgecrewio/checkov-action@99bb2caf247dfd9f03cf984373bc6043d4e32ebf # v12.1347.0
|
||||
uses: bridgecrewio/checkov-action@master
|
||||
with:
|
||||
directory: .
|
||||
framework: kubernetes,dockerfile,terraform,ansible
|
||||
@@ -258,7 +231,6 @@ jobs:
|
||||
soft_fail: true
|
||||
|
||||
- name: Upload Checkov results to GitHub Security
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -266,8 +238,7 @@ jobs:
|
||||
category: checkov
|
||||
|
||||
- name: Run Terrascan IaC scan
|
||||
continue-on-error: true
|
||||
uses: tenable/terrascan-action@3a6e87da8e244513bd77b631e624552643f794c6 # v1.4.1
|
||||
uses: tenable/terrascan-action@main
|
||||
with:
|
||||
iac_type: 'k8s'
|
||||
iac_version: 'v1'
|
||||
@@ -276,8 +247,7 @@ jobs:
|
||||
sarif_upload: true
|
||||
|
||||
- name: Run KICS IaC scan
|
||||
continue-on-error: true
|
||||
uses: checkmarx/kics-github-action@05aa5eb70eede1355220f4ca5238d96b397e30a6 # v2.1.20
|
||||
uses: checkmarx/kics-github-action@master
|
||||
with:
|
||||
path: '.'
|
||||
output_path: kics-results
|
||||
@@ -286,7 +256,6 @@ jobs:
|
||||
exclude_queries: 'a7ef1e8c-fbf8-4ac1-b8c7-2c3b0e6c6c6c'
|
||||
|
||||
- name: Upload KICS results to GitHub Security
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -297,21 +266,18 @@ jobs:
|
||||
secret-scan:
|
||||
name: Secret Scanning
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
permissions:
|
||||
security-events: write
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run TruffleHog secret scan
|
||||
continue-on-error: true
|
||||
uses: trufflesecurity/trufflehog@17456f8c7d042d8c82c9a8ca9e937231f9f42e26 # v3.95.2
|
||||
uses: trufflesecurity/trufflehog@main
|
||||
with:
|
||||
path: ./
|
||||
base: main
|
||||
@@ -319,7 +285,6 @@ jobs:
|
||||
extra_args: --debug --only-verified
|
||||
|
||||
- name: Run GitLeaks secret scan
|
||||
continue-on-error: true
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -336,34 +301,28 @@ jobs:
|
||||
license-scan:
|
||||
name: License Compliance Scan
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pip-licenses licensecheck
|
||||
|
||||
- name: Run license check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
pip-licenses --format=json --output-file=licenses.json
|
||||
licensecheck --zero
|
||||
|
||||
- name: Upload license report
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: license-report
|
||||
@@ -373,14 +332,11 @@ jobs:
|
||||
compliance-check:
|
||||
name: Security Policy Compliance
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check security policy files
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Check for required security files
|
||||
files=("SECURITY.md" ".github/SECURITY.md" "docs/SECURITY.md")
|
||||
@@ -398,13 +354,11 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check for security headers in code
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Check for security-related configurations
|
||||
grep -r "X-Frame-Options\|X-Content-Type-Options\|X-XSS-Protection\|Content-Security-Policy" src/ || echo "⚠️ Consider adding security headers"
|
||||
|
||||
- name: Validate Kubernetes security contexts
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Check for security contexts in Kubernetes manifests
|
||||
if [[ -d "k8s" ]]; then
|
||||
@@ -421,21 +375,13 @@ jobs:
|
||||
security-report:
|
||||
name: Security Report
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
needs: [sast, dependency-scan, container-scan, iac-scan, secret-scan, license-scan, compliance-check]
|
||||
if: always()
|
||||
# Promote secret to env-scope so the gating `if:` on the Slack-notify
|
||||
# step below is parseable (GitHub Actions rejects `secrets.X` in
|
||||
# step-level `if:` expressions).
|
||||
env:
|
||||
SECURITY_SLACK_WEBHOOK_URL: ${{ secrets.SECURITY_SLACK_WEBHOOK_URL }}
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Generate security summary
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "# Security Scan Summary" > security-summary.md
|
||||
echo "" >> security-summary.md
|
||||
@@ -451,18 +397,13 @@ jobs:
|
||||
echo "Generated on: $(date)" >> security-summary.md
|
||||
|
||||
- name: Upload security summary
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: security-summary
|
||||
path: security-summary.md
|
||||
|
||||
# GitHub Actions does not allow `secrets.X` in step-level `if:` —
|
||||
# use env.X instead. Inherits SECURITY_SLACK_WEBHOOK_URL from the
|
||||
# job-level env block (added below).
|
||||
- name: Notify security team on critical findings
|
||||
continue-on-error: true
|
||||
if: ${{ env.SECURITY_SLACK_WEBHOOK_URL != '' && (needs.sast.result == 'failure' || needs.dependency-scan.result == 'failure' || needs.container-scan.result == 'failure') }}
|
||||
if: ${{ secrets.SECURITY_SLACK_WEBHOOK_URL != '' && (needs.sast.result == 'failure' || needs.dependency-scan.result == 'failure' || needs.container-scan.result == 'failure') }}
|
||||
uses: 8398a7/action-slack@v3
|
||||
with:
|
||||
status: failure
|
||||
@@ -474,10 +415,9 @@ jobs:
|
||||
Workflow: ${{ github.workflow }}
|
||||
Please review the security scan results immediately.
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ env.SECURITY_SLACK_WEBHOOK_URL }}
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SECURITY_SLACK_WEBHOOK_URL }}
|
||||
|
||||
- name: Create security issue on critical findings
|
||||
continue-on-error: true
|
||||
if: needs.sast.result == 'failure' || needs.dependency-scan.result == 'failure'
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
name: wifi-densepose sensing-server → Docker Hub + ghcr.io
|
||||
|
||||
# Build + publish the `wifi-densepose` sensing-server image to both Docker Hub
|
||||
# (`ruvnet/wifi-densepose`) and ghcr.io (`ghcr.io/ruvnet/wifi-densepose`) on:
|
||||
# - push to main affecting the Dockerfile, the server crate, the UI assets,
|
||||
# or this workflow itself,
|
||||
# - tag push matching v* (release builds),
|
||||
# - manual workflow_dispatch.
|
||||
#
|
||||
# Closes #520 and #514: the stale `:latest` is rebuilt and pushed automatically
|
||||
# whenever the surface that produces it changes, and the Dockerfile fails the
|
||||
# build if the observatory/pose-fusion UI assets ever go missing again.
|
||||
#
|
||||
# Secrets:
|
||||
# DOCKERHUB_USERNAME — `ruvnet` (Docker Hub login name)
|
||||
# DOCKERHUB_TOKEN — Docker Hub access token with read/write/delete scope
|
||||
# (ghcr.io uses the workflow's GITHUB_TOKEN — no secret needed.)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docker/Dockerfile.rust'
|
||||
- 'docker/docker-entrypoint.sh'
|
||||
- 'v2/crates/wifi-densepose-sensing-server/**'
|
||||
- 'v2/crates/wifi-densepose-signal/**'
|
||||
- 'v2/crates/wifi-densepose-vitals/**'
|
||||
- 'v2/crates/wifi-densepose-wifiscan/**'
|
||||
- 'v2/crates/wifi-densepose-bfld/**'
|
||||
- 'v2/crates/cog-ha-matter/**'
|
||||
- 'v2/Cargo.toml'
|
||||
- 'v2/Cargo.lock'
|
||||
- 'ui/**'
|
||||
- '.github/workflows/sensing-server-docker.yml'
|
||||
tags: ['v*']
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: sensing-server-docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
name: build · push · smoke-test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
# QEMU is required so the amd64 GitHub runner can cross-build the
|
||||
# linux/arm64 layer below (Dockerfile.rust is arch-agnostic — no `--target`
|
||||
# flag — so buildx + QEMU is all that's needed; arm64 builds are emulated
|
||||
# by the runner, not built on a separate arm64 host).
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
# Bypassing docker/login-action@v3: the action kept emitting
|
||||
# "malformed HTTP Authorization header" against a known-good
|
||||
# dckr_pat_* token (verified by direct curl against the Hub API).
|
||||
# `docker login --password-stdin` is the documented credential
|
||||
# path and avoids whatever encoding step the action injects.
|
||||
env:
|
||||
DH_USER: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DH_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
run: |
|
||||
printf '%s' "$DH_TOKEN" | docker login docker.io -u "$DH_USER" --password-stdin
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compute tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
docker.io/ruvnet/wifi-densepose
|
||||
ghcr.io/ruvnet/wifi-densepose
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build + push
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile.rust
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
# README badge advertises `amd64 + arm64`, and #547 promised multi-arch
|
||||
# as part of the docker publish refresh; arm64 was never actually wired
|
||||
# in, so Apple Silicon Macs hit `no matching manifest for linux/arm64/v8`
|
||||
# on `docker pull ruvnet/wifi-densepose:latest` (#136, #625). Build both.
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Smoke-test the freshly-pushed image:
|
||||
# 1. UI assets that closed #520 are inside `/app/ui` (the Dockerfile's
|
||||
# RUN guard catches missing ones at build time, this re-checks the
|
||||
# pushed artifact post-hoc as belt-and-braces).
|
||||
# 2. /health is up.
|
||||
# 3. /api/v1/info returns 200 with no auth (LAN-mode default).
|
||||
# 4. With RUVIEW_API_TOKEN set, /api/v1/info returns 401 without a
|
||||
# Bearer header, 200 with the correct one (the #443 auth middleware).
|
||||
# ---------------------------------------------------------------------
|
||||
- name: Smoke-test image assets + LAN-mode HTTP
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}"
|
||||
docker pull "$IMAGE"
|
||||
docker run --rm "$IMAGE" sh -c \
|
||||
'ls /app/ui/observatory.html /app/ui/pose-fusion.html /app/ui/index.html /app/ui/viz.html >/dev/null'
|
||||
docker run --rm "$IMAGE" sh -c 'ls -d /app/ui/observatory /app/ui/pose-fusion >/dev/null'
|
||||
|
||||
docker run -d --name sm -p 3000:3000 -e CSI_SOURCE=simulated "$IMAGE"
|
||||
# Wait up to 30 s for /health.
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -fsS http://127.0.0.1:3000/health >/dev/null 2>&1; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
curl -fsS http://127.0.0.1:3000/health
|
||||
curl -fsS http://127.0.0.1:3000/api/v1/info >/dev/null
|
||||
curl -fsS http://127.0.0.1:3000/ui/observatory.html >/dev/null
|
||||
curl -fsS http://127.0.0.1:3000/ui/pose-fusion.html >/dev/null
|
||||
docker stop sm
|
||||
|
||||
- name: Smoke-test the bearer-token auth path
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}"
|
||||
docker run -d --name auth \
|
||||
-p 3000:3000 \
|
||||
-e CSI_SOURCE=simulated \
|
||||
-e RUVIEW_API_TOKEN=smoke-test-token-do-not-use \
|
||||
"$IMAGE"
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -fsS http://127.0.0.1:3000/health >/dev/null 2>&1; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
# /health stays unauthenticated.
|
||||
curl -fsS http://127.0.0.1:3000/health >/dev/null
|
||||
# /api/v1/info without a bearer → 401.
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/api/v1/info)
|
||||
test "$code" = "401" || { echo "expected 401, got $code"; exit 1; }
|
||||
# Wrong bearer → 401.
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Authorization: Bearer wrong' http://127.0.0.1:3000/api/v1/info)
|
||||
test "$code" = "401" || { echo "expected 401 (wrong token), got $code"; exit 1; }
|
||||
# Correct bearer → 200.
|
||||
curl -fsS -H 'Authorization: Bearer smoke-test-token-do-not-use' http://127.0.0.1:3000/api/v1/info >/dev/null
|
||||
docker stop auth
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## sensing-server image published"
|
||||
echo
|
||||
echo "Tags:"
|
||||
echo '```'
|
||||
echo "${{ steps.meta.outputs.tags }}"
|
||||
echo '```'
|
||||
echo
|
||||
echo "Closes #520 (missing observatory/pose-fusion UI assets) and #514 (stale `:latest` for the v0.6+ packet format)."
|
||||
echo "The Dockerfile fails the build if those UI assets ever disappear again, and this workflow rebuilds + pushes automatically on every change to the surface."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,70 +0,0 @@
|
||||
name: three.js demos → GitHub Pages
|
||||
|
||||
# Publishes the ADR-097 three.js demos under gh-pages/three.js/.
|
||||
# Uses keep_files: true so the existing observatory/, pose-fusion/,
|
||||
# pointcloud/, nvsim/, and root index.html demos are preserved.
|
||||
#
|
||||
# Demos 04 and 05 require a Mixamo "X Bot.fbx" placed in assets/.
|
||||
# That file is intentionally gitignored (license boundary), so this
|
||||
# workflow does NOT ship it. Demos 01-03 work standalone; the index
|
||||
# page documents the FBX requirement honestly.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'examples/three.js/**'
|
||||
- '.github/workflows/threejs-pages.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: threejs-pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Stage demos for Pages
|
||||
run: |
|
||||
mkdir -p _site/three.js
|
||||
# Copy everything except the local Python server (CI doesn't need it)
|
||||
# and any stray scratch screenshots.
|
||||
cp -r examples/three.js/demos _site/three.js/demos
|
||||
cp -r examples/three.js/screenshots _site/three.js/screenshots
|
||||
cp examples/three.js/README.md _site/three.js/README.md
|
||||
# An index.html that lists the 5 demos with the FBX caveat.
|
||||
cp examples/three.js/index.html _site/three.js/index.html
|
||||
# Mixamo FBX is gitignored — assets dir won't exist in CI.
|
||||
# Drop an empty placeholder so the relative path 'assets/' resolves
|
||||
# to a directory listing (404 on missing file) instead of an opaque
|
||||
# network error. Browsers showing the 404 path makes the failure
|
||||
# visible to anyone trying demos 04/05 without their own FBX.
|
||||
mkdir -p _site/three.js/assets
|
||||
cat > _site/three.js/assets/README.txt <<'EOF'
|
||||
The Mixamo "X Bot.fbx" required by demos 04-skinned-fbx.html and
|
||||
05-skinned-realtime.html is intentionally not redistributed here.
|
||||
|
||||
Download your own from https://mixamo.com (FBX Binary, T-Pose,
|
||||
Without Skin) and place it here as "X Bot.fbx" if you want to
|
||||
run those demos locally. See examples/three.js/README.md in the
|
||||
repo for context.
|
||||
EOF
|
||||
echo "Staged contents:"
|
||||
ls -R _site/three.js/ | head -30
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: _site
|
||||
# Critical: preserve observatory/, pose-fusion/, pointcloud/, nvsim/
|
||||
# and the root index.html already on gh-pages.
|
||||
keep_files: true
|
||||
commit_message: 'three.js demos: ${{ github.event.head_commit.message }}'
|
||||
@@ -19,24 +19,8 @@ jobs:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Identity must be set BEFORE any operation that can create a commit.
|
||||
# `git submodule update --remote --merge` used to fail here with
|
||||
# "Committer identity unknown" because the merge inside vendor/ruvector
|
||||
# needs an author when the pinned commit isn't a fast-forward of upstream.
|
||||
- name: Configure git identity
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# Use a plain `--remote` checkout (detached HEAD at each submodule's
|
||||
# configured `branch` tip from .gitmodules) rather than `--merge`. We only
|
||||
# want to bump the superproject's gitlink to the latest upstream commit;
|
||||
# there's no reason to create merge commits inside the vendored repos, and
|
||||
# `--merge` breaks whenever the current pin has diverged from that branch.
|
||||
- name: Update submodules to latest tracked branch
|
||||
run: |
|
||||
git submodule sync --recursive
|
||||
git submodule update --remote --recursive
|
||||
- name: Update submodules to latest main
|
||||
run: git submodule update --remote --merge
|
||||
|
||||
- name: Check for changes
|
||||
id: check
|
||||
@@ -45,22 +29,21 @@ jobs:
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "--- submodule pointer changes ---"
|
||||
git submodule status --recursive || true
|
||||
git diff --submodule=log -- vendor/ || true
|
||||
fi
|
||||
|
||||
- name: Create PR with updates
|
||||
if: steps.check.outputs.changed == 'true'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
BRANCH="chore/update-submodules-$(date +%Y%m%d-%H%M%S)"
|
||||
git checkout -b "$BRANCH"
|
||||
git add vendor/
|
||||
git commit -m "chore: update vendor submodules to latest upstream"
|
||||
git commit -m "chore: update vendor submodules to latest main"
|
||||
git push origin "$BRANCH"
|
||||
gh pr create \
|
||||
--title "chore: update vendor submodules" \
|
||||
--body "Automated submodule update to the latest upstream commit on each submodule's tracked branch (see \`.gitmodules\`). Review the pointer diff before merging." \
|
||||
--body "Automated submodule update to latest upstream main." \
|
||||
--base main \
|
||||
--head "$BRANCH"
|
||||
env:
|
||||
|
||||
@@ -4,18 +4,16 @@ on:
|
||||
push:
|
||||
branches: [ main, master, 'claude/**' ]
|
||||
paths:
|
||||
- 'archive/v1/src/core/**'
|
||||
- 'archive/v1/src/hardware/**'
|
||||
- 'archive/v1/data/proof/**'
|
||||
- 'archive/v1/requirements-lock.txt'
|
||||
- 'v1/src/core/**'
|
||||
- 'v1/src/hardware/**'
|
||||
- 'v1/data/proof/**'
|
||||
- '.github/workflows/verify-pipeline.yml'
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
paths:
|
||||
- 'archive/v1/src/core/**'
|
||||
- 'archive/v1/src/hardware/**'
|
||||
- 'archive/v1/data/proof/**'
|
||||
- 'archive/v1/requirements-lock.txt'
|
||||
- 'v1/src/core/**'
|
||||
- 'v1/src/hardware/**'
|
||||
- 'v1/data/proof/**'
|
||||
- '.github/workflows/verify-pipeline.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -32,26 +30,26 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install pinned dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r archive/v1/requirements-lock.txt
|
||||
pip install -r v1/requirements-lock.txt
|
||||
|
||||
- name: Verify reference signal is reproducible
|
||||
run: |
|
||||
echo "=== Regenerating reference signal ==="
|
||||
python archive/v1/data/proof/generate_reference_signal.py
|
||||
python v1/data/proof/generate_reference_signal.py
|
||||
echo ""
|
||||
echo "=== Checking data file matches committed version ==="
|
||||
# The regenerated file should be identical to the committed one
|
||||
# (We compare the metadata file since data file is large)
|
||||
python -c "
|
||||
import json, hashlib
|
||||
with open('archive/v1/data/proof/sample_csi_meta.json') as f:
|
||||
with open('v1/data/proof/sample_csi_meta.json') as f:
|
||||
meta = json.load(f)
|
||||
assert meta['is_synthetic'] == True, 'Metadata must mark signal as synthetic'
|
||||
assert meta['numpy_seed'] == 42, 'Seed must be 42'
|
||||
@@ -59,18 +57,7 @@ jobs:
|
||||
"
|
||||
|
||||
- name: Run pipeline verification
|
||||
working-directory: archive/v1
|
||||
env:
|
||||
# Pin thread count for scipy.fft / BLAS — multi-threaded reduction
|
||||
# order is otherwise non-deterministic across CI runs (issue #560
|
||||
# follow-up: 9- and 6-decimal quantization were not enough because
|
||||
# the divergence is from threading order, not SIMD reordering).
|
||||
# Single-threaded keeps the proof reproducible at a ~2-3x slowdown.
|
||||
OMP_NUM_THREADS: "1"
|
||||
OPENBLAS_NUM_THREADS: "1"
|
||||
MKL_NUM_THREADS: "1"
|
||||
VECLIB_MAXIMUM_THREADS: "1"
|
||||
NUMEXPR_NUM_THREADS: "1"
|
||||
working-directory: v1
|
||||
run: |
|
||||
echo "=== Running pipeline verification ==="
|
||||
python data/proof/verify.py
|
||||
@@ -78,13 +65,7 @@ jobs:
|
||||
echo "Pipeline verification PASSED."
|
||||
|
||||
- name: Run verification twice to confirm determinism
|
||||
working-directory: archive/v1
|
||||
env:
|
||||
OMP_NUM_THREADS: "1"
|
||||
OPENBLAS_NUM_THREADS: "1"
|
||||
MKL_NUM_THREADS: "1"
|
||||
VECLIB_MAXIMUM_THREADS: "1"
|
||||
NUMEXPR_NUM_THREADS: "1"
|
||||
working-directory: v1
|
||||
run: |
|
||||
echo "=== Second run for determinism confirmation ==="
|
||||
python data/proof/verify.py
|
||||
@@ -95,7 +76,7 @@ jobs:
|
||||
echo "=== Scanning for unseeded np.random usage in production code ==="
|
||||
# Search for np.random calls without a seed in production code
|
||||
# Exclude test files, proof data generators, and known parser placeholders
|
||||
VIOLATIONS=$(grep -rn "np\.random\." archive/v1/src/ \
|
||||
VIOLATIONS=$(grep -rn "np\.random\." v1/src/ \
|
||||
--include="*.py" \
|
||||
--exclude-dir="__pycache__" \
|
||||
| grep -v "np\.random\.RandomState" \
|
||||
|
||||
+1
-42
@@ -13,9 +13,6 @@ firmware/esp32-csi-node/managed_components/
|
||||
firmware/esp32-csi-node/dependencies.lock
|
||||
firmware/esp32-csi-node/sdkconfig.defaults.bak
|
||||
|
||||
# ESP-IDF set-target backup (local only)
|
||||
firmware/esp32-hello-world/sdkconfig.old
|
||||
|
||||
# Claude Flow swarm runtime state
|
||||
.swarm/
|
||||
|
||||
@@ -26,14 +23,6 @@ rust-port/wifi-densepose-rs/data/recordings/
|
||||
nvs.bin
|
||||
nvs_config.csv
|
||||
nvs_provision.bin
|
||||
firmware/esp32-csi-node/nvs_seed.csv
|
||||
firmware/esp32-csi-node/nvs_seed.bin
|
||||
firmware/esp32-csi-node/nvs_config.bin
|
||||
firmware/esp32-csi-node/nvs_wifi.bin
|
||||
firmware/esp32-csi-node/nvs.bin
|
||||
# Catch any other NVS binaries/CSVs with credentials
|
||||
**/nvs_*.bin
|
||||
**/nvs_*.csv
|
||||
|
||||
# Working artifacts that should not land in root
|
||||
/*.wasm
|
||||
@@ -237,34 +226,4 @@ v1/src/sensing/mac_wifi
|
||||
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
||||
# refer to https://docs.cursor.com/context/ignore-files
|
||||
.cursorignore
|
||||
.cursorindexingignore
|
||||
|
||||
# Claude Flow runtime artifacts (auto-generated, machine-specific)
|
||||
**/daemon.pid
|
||||
**/pending-insights.jsonl
|
||||
**/vectors.db
|
||||
**/memory.db
|
||||
**/.claude-flow/sessions/session-*.json
|
||||
**/.claude-flow/sessions/current.json
|
||||
|
||||
# Node modules (should use npm ci, not committed)
|
||||
**/node_modules/
|
||||
|
||||
# Local build scripts
|
||||
firmware/esp32-csi-node/build_firmware.batdata/
|
||||
models/
|
||||
demo_pointcloud.ply
|
||||
demo_splats.json
|
||||
|
||||
# rvCSI napi-rs addon — generated by `napi build` (do not commit)
|
||||
v2/crates/rvcsi-node/*.node
|
||||
v2/crates/rvcsi-node/binding.js
|
||||
v2/crates/rvcsi-node/binding.d.ts
|
||||
v2/crates/rvcsi-node/npm/
|
||||
|
||||
# AetherArena private optimization staging — never published until reviewed
|
||||
aether-arena/staging/
|
||||
|
||||
# MM-Fi benchmark dataset archives — large data, fetch separately, never commit
|
||||
assets/MM-Fi/E0*.zip
|
||||
assets/MM-Fi/*.zip
|
||||
.cursorindexingignore
|
||||
@@ -10,7 +10,3 @@
|
||||
path = vendor/sublinear-time-solver
|
||||
url = https://github.com/ruvnet/sublinear-time-solver
|
||||
branch = main
|
||||
[submodule "vendor/rvcsi"]
|
||||
path = vendor/rvcsi
|
||||
url = https://github.com/ruvnet/rvcsi
|
||||
branch = main
|
||||
|
||||
Binary file not shown.
Vendored
-49
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "QEMU ESP32-S3 Debug",
|
||||
"type": "cppdbg",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/firmware/esp32-csi-node/build/esp32-csi-node.elf",
|
||||
"cwd": "${workspaceFolder}/firmware/esp32-csi-node",
|
||||
"MIMode": "gdb",
|
||||
"miDebuggerPath": "xtensa-esp-elf-gdb",
|
||||
"miDebuggerServerAddress": "localhost:1234",
|
||||
"setupCommands": [
|
||||
{
|
||||
"description": "Set remote hardware breakpoint limit (ESP32-S3 has 2)",
|
||||
"text": "set remote hardware-breakpoint-limit 2",
|
||||
"ignoreFailures": false
|
||||
},
|
||||
{
|
||||
"description": "Set remote hardware watchpoint limit (ESP32-S3 has 2)",
|
||||
"text": "set remote hardware-watchpoint-limit 2",
|
||||
"ignoreFailures": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "QEMU ESP32-S3 Debug (attach)",
|
||||
"type": "cppdbg",
|
||||
"request": "attach",
|
||||
"program": "${workspaceFolder}/firmware/esp32-csi-node/build/esp32-csi-node.elf",
|
||||
"cwd": "${workspaceFolder}/firmware/esp32-csi-node",
|
||||
"MIMode": "gdb",
|
||||
"miDebuggerPath": "xtensa-esp-elf-gdb",
|
||||
"miDebuggerServerAddress": "localhost:1234",
|
||||
"setupCommands": [
|
||||
{
|
||||
"description": "Set remote hardware breakpoint limit (ESP32-S3 has 2)",
|
||||
"text": "set remote hardware-breakpoint-limit 2",
|
||||
"ignoreFailures": false
|
||||
},
|
||||
{
|
||||
"description": "Set remote hardware watchpoint limit (ESP32-S3 has 2)",
|
||||
"text": "set remote hardware-watchpoint-limit 2",
|
||||
"ignoreFailures": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
-583
@@ -7,588 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Person count no longer leaks up to 10 in heuristic mode — addresses #894.** `field_bridge::occupancy_or_fallback` returned the eigenvalue-based `FieldModel::estimate_occupancy` count **unbounded** (its internal ceiling is 10), while the sibling estimators on the same single-link data — the perturbation-energy fallback right below it and `score_to_person_count` — both cap at 3 ("1-3 for single ESP32"). On noisy / under-calibrated CSI the eigenvalue count inflated, producing the "10 persons reported when 1 present" symptom (seen when `--model` fails to load and the server runs on heuristics). Bounded the eigenvalue path to the shared `MAX_SINGLE_LINK_OCCUPANCY` (3) so every estimator on one link agrees; genuine higher counts come from the multistatic fusion path, not a single-link covariance estimate.
|
||||
- **MQTT multi-node deployments now create one Home-Assistant device per node — closes #898.** After the #872 MQTT wiring landed, the JSON→`VitalsSnapshot` bridge hard-coded a single `node_id` (the MQTT client id) and the publisher used a single `OwnedDiscoveryBuilder`, so every physical node collapsed into one device (`identifiers:["wifi_densepose_wifi-densepose-1"]`), contradicting the "one device per node" docs. The bridge now emits one snapshot per node in the sensing update's `nodes[]` (each with its own `node_id` + RSSI, falling back to a single aggregate snapshot for wifi/simulate sources), and the publisher derives a per-node builder (`OwnedDiscoveryBuilder::for_node`) that publishes discovery + availability lazily on first sight of each `node_id` and routes state to per-node topics — yielding N distinct HA devices with per-node availability/LWT. Unit-tested (distinct nodes → distinct `wifi_densepose_<node>` identifiers); 71 MQTT tests pass.
|
||||
- **Person count no longer pinned to 1 — addresses #803.** The aggregate occupancy reported by the sensing server was derived from `smoothed_person_score`, an EMA-smoothed *activity* score (amplitude variance / motion / spectral energy). That score saturates near a single occupant — one moving person maxes it out — so it cannot discriminate occupancy *count* and stayed clamped at 1 across S3/C6 and the Python/Docker/Rust servers. Meanwhile the count-aware per-node estimates the ESP32 paths already compute (firmware `n_persons`, and the DynamicMinCut `corr_persons`) were stashed in `NodeState::prev_person_count` and then **discarded** by the aggregator (same dead-wiring class as #872). The aggregator now takes `max(activity_count, node_max)` via a unit-tested `aggregate_person_count` helper, so a node positively estimating 2–3 occupants is surfaced instead of overwritten. The fix can only ever *raise* the count when a node reports more people, so the single-occupant case is provably never inflated (regression-guarded by test). **Second half:** the pure-CSI per-node path itself clamped its own estimate — the DynamicMinCut occupancy (`estimate_persons_from_correlation`, 0–3) was mapped to a score via `corr_persons / 3.0`, putting 2 people at 0.667, *just under* the 0.70 up-threshold of `score_to_person_count`, so the per-node count never climbed past 1 (so `node_max` was also stuck at 1 for CSI-only nodes). Replaced it with a threshold-aligned `corr_persons_to_score` mapping (1→0.40, 2→0.74, 3→0.96) whose steady state round-trips back to the same count through the EMA + hysteresis, while still gating transient noise. A convergence test replays the exact EMA loop to prove min-cut=2 now reports 2 (and documents that the old `/3.0` mapping reported 1). Full multi-person accuracy still depends on the underlying estimator quality; this removes the two server-side clamps that masked it. 586 sensing-server tests pass.
|
||||
- **MQTT publisher now actually runs (`--mqtt`) — closes #872.** The `--mqtt*` flags were defined only in `cli::Args` (dead code, referenced nowhere) while the binary parses a *separate* `main::Args` with no mqtt fields, and `main.rs` never started the `mqtt::` publisher — so MQTT/Home-Assistant integration was completely unwired (`--mqtt` errored as an unexpected argument, and even with the Docker image's `--features mqtt` build the publisher never ran). Earlier attempts chased a Docker *rebuild*; the real cause was disconnected *code*. Extracted the flags into a shared `cli::MqttArgs` (`#[command(flatten)]` into both structs), spawn the publisher on `--mqtt`, and bridge the JSON sensing broadcast into the typed `VitalsSnapshot` stream with a defensive `serde_json::Value` mapping. Verified end-to-end against `mosquitto`: 20 HA auto-discovery entities + live state (presence/person-count/…). 577 (default) / 580 (`--features mqtt`) tests pass.
|
||||
- **Mass Casualty triage never reports a survivor with a heartbeat as Deceased (safety) — PR #926.** Both triage paths in `wifi-densepose-mat` — `TriageCalculator::calculate` (`combine_assessments(Absent, None) ⇒ Deceased`) and the detection path `EnsembleClassifier::determine_triage` (`!has_breathing && !has_movement ⇒ Deceased`) — ignored the `heartbeat` field. A survivor with a detectable **pulse** but no sensed breathing/movement (respiratory arrest — the most time-critical *savable* state, Immediate/Red) was therefore reported **Deceased (Black)** and deprioritized for rescue. The domain path was in fact only reachable *because* a heartbeat made `has_vitals()` true, so every "Deceased" was a live person. Both paths now escalate to **Immediate** when a heartbeat is present; total absence of breathing, movement *and* heartbeat is unchanged (domain → `Unknown`, ensemble → `Deceased`). 2 safety regression tests; full MAT suite (177) green.
|
||||
- **Per-node Home-Assistant devices now report each node's *own* presence/motion — PR #918.** After the one-device-per-node fan-out landed, the MQTT bridge still applied the *room-level aggregate* `classification` to every node, so in a multi-node deployment a node watching an empty corner inherited another node's "present" (and `motion_level: "absent"` was mis-mapped to full motion). Each node in the broadcast `nodes[]` already carries its own `classification`; the bridge now reads it per node (extracted into a testable `vitals_snapshots_from_sensing_json`), keeping vitals + person count room-level. 4 unit tests.
|
||||
- **`--model` gives an actionable diagnostic instead of a cryptic magic error — PR #919 (refs #894).** Passing a HuggingFace `ruvnet/wifi-densepose-pretrained` file (`model.safetensors` / `model-q4.bin` / `model.rvf.jsonl`) to `--model` produced `invalid magic at offset 0: … got 0x77455735`, then a silent fall back to heuristics. The load-failure path now detects the format (safetensors / quantized blob / JSONL manifest) and explains that those files are a different format **and** encoder architecture than the RVF binary container the progressive loader expects, pointing to #894. Pure `diagnose_model_load_error` + 4 tests.
|
||||
- **`--export-rvf` no longer silently produces a placeholder model — PR #920.** The `--export-rvf` handler ran *before* `--train`/`--pretrain` and unconditionally wrote placeholder sine-wave weights, so the documented `--train … --export-rvf <path>` workflow short-circuited to a fake model and never trained (while printing "exported successfully"). It now emits the placeholder **container-format demo** only standalone (with a clear warning), and falls through to real training when `--train`/`--pretrain` is set; docs point to `--save-rvf` for the real model. 3 guard tests.
|
||||
|
||||
### Added
|
||||
- **WiFi-CSI pose: efficiency frontier + per-room calibration service** (ADR-150 §3.2–3.6). Two beyond-SOTA results on the MM-Fi benchmark, plus the deployment mechanism that resolves real-world generalization:
|
||||
- **Efficiency frontier** — a **75 K-param model beats published SOTA** (74.3% vs MultiFormer 72.25% torso-PCK@20); every config from `micro` up is Pareto-dominant (smaller *and* more accurate than prior work). Shipped a deployable **int4 edge model (~20 KB, verified 74.08%, 0.135 ms single-thread CPU)** — published at [`ruvnet/wifi-densepose-mmfi-pose/edge`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose). See [`docs/benchmarks/wifi-pose-efficiency-frontier.md`](docs/benchmarks/wifi-pose-efficiency-frontier.md).
|
||||
- **Generalization solved by few-shot calibration** — zero-shot cross-subject (~64%) and cross-environment (~10%) are *not* closeable by algorithms (CORAL, DANN, instance-norm, contrastive foundation-pretraining all tested, all failed) or by more training subjects (saturates ~64%). But **~100–200 labeled in-room samples recover SOTA-level pose**: cross-subject 64→76%, **cross-environment 10→73% (60% from just 5 samples)** — deployable as a **~11 KB per-room LoRA adapter** on a frozen shared base. Full empirical chain in ADR-150 §3.2–3.6.
|
||||
- **Calibration service (complete, both model paths, cross-language verified)** — `aether-arena/calibration/`: `calibrate.py` (transformer model, `.npz` adapter) + `infer.py` (verified 3.09%→74.29% on an unseen MM-Fi room), **and `cog_calibrate.py`** which fits a `fc1.a/fc1.b/fc2.a/fc2.b` **safetensors** adapter for the deployed cog conv+MLP model (`pose_v1.safetensors`). Consumed by the Rust product engine: `InferenceEngine::with_adapter()` + `cog-pose-estimation run --config <cfg> --adapter <room.safetensors>`. Self-contained regression tests for both Python producers (`test_calibration.py`, `test_cog_calibration.py`) **plus a cross-language Rust integration test** that loads a real `cog_calibrate.py`-generated adapter fixture and asserts it activates + changes engine output. All green.
|
||||
- **Windows workspace build + test now green** (cross-platform fixes). `wifi-densepose-worldmodel` imported `tokio::net::UnixStream` unconditionally, so `cargo build/test --workspace` failed to compile on Windows (E0432) — now the OccWorld Unix-socket bridge is `#[cfg(unix)]`-gated with a clear non-unix fallback. And `wifi-densepose-bfld`'s `readme_quickstart_uses_canonical_public_api` test checked a multi-line `pipeline\n .process` needle that never matched on a CRLF checkout — now normalizes line endings. Result: **2,682 workspace tests pass / 0 fail on Windows** (the pre-merge gate was previously unrunnable there).
|
||||
- **`ruview-swarm` crate (ADR-148)** — drone swarm control system with hierarchical-mesh topology, Raft consensus, MAPPO multi-agent reinforcement learning, and CSI sensing integration. 14 modules: topology (Raft/Gossip/Mesh), formation control (virtual-structure/leader-follower/Reynolds flocking), RRT-APF path planning, auction+FNN task allocation, MARL actor + PPO training loop, security (MAVLink v2 HMAC-SHA256 signing, UWB anti-spoofing, geofencing, Remote ID, FHSS anti-jamming), 10-state fail-safe machine, and SwarmOrchestrator. ITAR-gated coordination features (USML Category VIII(h)(12)) behind `itar-unrestricted` feature.
|
||||
- **Ruflo integration for `ruview-swarm`** — feature-gated (`ruflo`) AI-agent capability layer connecting to the claude-flow daemon: AgentDB mission memory (`memory_store`/`memory_search`), HNSW pattern learning (`agentdb_pattern-store`/`-search`), AIDefence MAVLink message scanning, and SONA intelligence trajectory hooks. `RufloBackend` trait with `HttpRufloBackend` (JSON-RPC 2.0) and `MockRufloBackend` implementations.
|
||||
|
||||
### Performance
|
||||
- `ruview-swarm` benchmarks (criterion, release): MARL actor inference 3.3 µs, RRT-APF planning 0.043 ms, multi-view CSI fusion 58.5 ns, 3-view localization 1.732 m (beats Wi2SAR 5 m SOTA baseline), 4-drone SAR coverage 223 s for 400×400 m (under 240 s target).
|
||||
|
||||
### Added
|
||||
- **ADR-147 — OccWorld world model integration** (`wifi-densepose-worldmodel` v0.3.0 published to crates.io). 15-frame trajectory prediction at 209 ms / 3.37 GB VRAM on RTX 5080. Phase 3 domain adapter `scripts/ruview_occ_dataset.py` (`RuViewOccDataset`) converts WorldGraph snapshots to OccWorld tensors with indoor class remapping + zero ego-poses (validated). Phase 5 retraining pipeline `scripts/occworld_retrain.py` — VQVAE + transformer fine-tuning on RuView occupancy snapshots. See [ADR-147](docs/adr/ADR-147-nvidia-cosmos-world-foundation-model-integration.md) · [benchmark proof](docs/adr/ADR-147-benchmark-proof.md).
|
||||
|
||||
### Added
|
||||
- **ADR-125 (APPLE-FABRIC) — RuView ↔ Apple Home native HAP bridge proposal + reference impl** (issue #796). New ADR-125 lays out a three-phase plan to expose RuView as a discoverable HomeKit accessory on the LAN so a HomePod (as Home Hub) sees presence / vitals / BFLD-derived events natively — zero Home-Assistant intermediary. Two architectural decisions resolved in the ADR per design review: (1) **one HAP bridge with N child accessories** (single pairing, matches Hue/Eve pattern), and (2) **identity-risk mapping is semantic, not probabilistic** — `identity_risk_score` and Soul-Signature match probability never cross the HAP boundary; instead three thresholded events are exposed (`Unknown Presence`, `Unexpected Occupancy`, `Unrecognized Activity Pattern`) so RuView reads as calm-tech ambient awareness, not surveillance UX. ADR-125 §2.1.a reference impl ships now: `scripts/hap-test-sensor.py` (HAP-1.1 bridge advertised over mDNS, paired with operator's iPhone) + `scripts/c6-presence-watcher.py` (parses ESP32 `RV_FEATURE_STATE_MAGIC = 0xC5110006` UDP packets with IEEE CRC32 validation, hysteresis, and a Python port of `wifi-densepose-bfld::PrivacyClass` that enforces ADR-125 §2.1.d invariant I1 at the HomeKit edge — only `Anonymous` (2) and `Restricted` (3) frames may cross; `Raw`/`Derived` are refused with exit code 2 and the cited ADR clause). Validated end-to-end on real hardware (no mocks): ESP32-C6 on `ruv.net` → UDP/5005 → mac-mini watcher → BFLD gate → HAP bridge → iPhone Home app shows `Unknown Presence` live characteristic flip. **Empirical**: 50-51 valid CRC-passing feature_state packets per 10 s window from the live C6; zero CRC errors. P2 (Rust-native HAP via the `hap` crate, replaces the Python sidecar) and P3 (Matter Controller once `matter-rs` stabilizes) follow.
|
||||
|
||||
### Security
|
||||
- **ESP32 OTA upload now fails closed when no PSK is provisioned** (#596 audit finding — critical, **breaking change for unprovisioned nodes**). `ota_check_auth()` previously returned `true` when `s_ota_psk[0] == '\0'`, so a freshly-flashed node would accept attacker-controlled firmware over plain HTTP on port 8032 from any host on the WiFi. No Secure Boot V2, no signed-image verification — a single LAN call could brick or backdoor a node. The fix rejects every OTA upload until a PSK is written to NVS (the OTA HTTP server still starts so operators can run `provision.py --ota-psk <hex>` over USB-CDC without reflashing). **Operators affected**: any deployment that relied on the unauthenticated OTA endpoint working out of the box now needs to provision a PSK before subsequent OTA pushes will succeed. Boot-time `ESP_LOGW` makes the new posture visible.
|
||||
- **Bearer-token auth accepts the scheme case-insensitively (RFC 6750) — PR #929.** `require_bearer` parsed the `Authorization` header with a case-sensitive `strip_prefix("Bearer ")`, so a *correct* `RUVIEW_API_TOKEN` sent as `Authorization: bearer <token>` (or `BEARER`, or with extra whitespace) was rejected with a confusing 401 — needless friction when enabling auth. The scheme is now matched with `eq_ignore_ascii_case` (per RFC 6750 §2.1 / RFC 7235 §2.1); the token compare is unchanged — still exact and constant-time (`ct_eq`) — so a wrong token or a non-Bearer scheme (`Basic …`) still returns 401. Audited the surrounding code while here: `ct_eq` correctly rejects length mismatch (no prefix-auth bypass) and the middleware fails closed. New `accepts_case_insensitive_bearer_scheme` test.
|
||||
- **Path-traversal vulnerabilities patched in five sensing-server endpoints** (closes #615 — critical). New `wifi_densepose_sensing_server::path_safety::safe_id()` enforces `[A-Za-z0-9._-]` only (no leading `.`, max 64 chars) before any user-controlled identifier reaches a `format!()` building a filesystem path. Applied at:
|
||||
- `POST /api/v1/recording/start` (`recording.rs` — `session_name`)
|
||||
- `GET /api/v1/recording/download/:id` (`recording.rs` — `id`)
|
||||
- `DELETE /api/v1/recording/delete/:id` (`recording.rs` — `id`)
|
||||
- `POST /api/v1/models/load` (`model_manager.rs` — `model_id`)
|
||||
- `training_api.rs` `load_recording_frames` (`dataset_id`s)
|
||||
|
||||
Pre-fix, unauthenticated callers could read `../../etc/passwd`-style paths, write arbitrary JSONL files, load attacker-controlled `.rvf` model files, or delete arbitrary files the server process could touch. 9 unit tests in `path_safety::tests` exercise the rejection envelope (empty, too-long, path separators, parent-dir traversal, null byte, whitespace/specials, non-ASCII).
|
||||
|
||||
### Fixed
|
||||
- **WebSocket `/ws/sensing` now reports `esp32:offline` when ESP32 hardware goes stale** (closes #618). `broadcast_tick_task` was re-emitting the cached `latest_update` with a frozen `source: "esp32"` field forever after the hardware lost power or network. The REST `/health` endpoint already called `effective_source()` (which returns `"esp32:offline"` after `ESP32_OFFLINE_TIMEOUT` = 5 s with no UDP frames), but the WS broadcast path was the one consumer that didn't. Result: the UI's "LIVE — ESP32 HARDWARE Connected" banner stayed green long after the hardware went away, and `vital_signs`/`features`/`classification` re-broadcasted the last-seen values indefinitely. Fix: clone the cached `latest_update` per tick, overwrite `source` with `s.effective_source()`, then serialize and broadcast. UI can now switch to an offline state on the same 5-second budget the REST surface uses.
|
||||
- **Proof replay (`archive/v1/data/proof/verify.py`) is now cross-platform deterministic** (closes #560). Three changes together: (1) `features_to_bytes()` now `np.round(.., HASH_QUANTIZATION_DECIMALS=6)`s each feature array before packing as little-endian f64, collapsing ULP-level drift from scipy.fft pocketfft SIMD reordering; (2) the `Verify Pipeline Determinism` workflow pins `OMP_NUM_THREADS=1`, `OPENBLAS_NUM_THREADS=1`, `MKL_NUM_THREADS=1`, `VECLIB_MAXIMUM_THREADS=1`, `NUMEXPR_NUM_THREADS=1` — multi-threaded BLAS reductions were a deeper source of non-determinism than SIMD reordering, and 6-decimal quantization alone wasn't enough across Azure VM microarchitectures; (3) `expected_features.sha256` regenerated under the new conditions. CI now passes the determinism check (same hash across consecutive runs on canonical Linux x86_64 CI runner: `667eb054c44ac510342665bf9c93d608868a8ead948ae8774b2796ebce6f8fe7`). `scripts/probe-fft-platform.py` updated to mirror `HASH_QUANTIZATION_DECIMALS=6` for cross-machine spot-checks.
|
||||
- **`archive/v1/src/services/pose_service.py:223` calls the right method on `PhaseSanitizer`** (closes #612). The call was `self.phase_sanitizer.sanitize(phase_data)`, but `PhaseSanitizer`'s full-pipeline entry point is named `sanitize_phase()` (`unwrap_phase` + `remove_outliers` + `smooth_phase` chained, see `archive/v1/src/core/phase_sanitizer.py:266`). The shorter `sanitize` name doesn't exist on the class, so any path that reached this branch raised `AttributeError` and crashed the pose service mid-frame.
|
||||
- **`adaptive_classifier.rs:94` no longer panics on NaN feature values** (closes #611).
|
||||
`sorted.sort_by(|a, b| a.partial_cmp(b).unwrap())` returned `None` and panicked
|
||||
whenever a single `NaN` reached the classifier from real ESP32 hardware (silent
|
||||
DSP div-by-zero, empty buffer). One bad frame killed the entire sensing-server
|
||||
process. Swapped for `unwrap_or(Ordering::Equal)`, matching the pattern the
|
||||
same file already used at lines 149-150 and 155. Per-frame hot path; this was
|
||||
a real production crash vector.
|
||||
- **Completed the #611 NaN-panic audit across the sensing-server crate** (follow-up
|
||||
to #613). The original audit grepped for the literal `partial_cmp(b).unwrap()`
|
||||
and missed seven additional production sites that use comparator variants
|
||||
(`partial_cmp(b.1).unwrap()`, `partial_cmp(&variances[b]).unwrap()`). All share
|
||||
the same crash class — a single `NaN` in CSI-derived state panics the whole
|
||||
sensing-server. Fixed:
|
||||
- `adaptive_classifier.rs:205` — `AdaptiveModel::classify()` argmax over softmax
|
||||
probs. **Same per-frame hot path as #611**; NaN flows through normalise →
|
||||
logits → softmax and still reaches this site even after the #613 IQR fix.
|
||||
- `adaptive_classifier.rs:480, 500` — training-loop argmax in `train()`
|
||||
(training/per-class accuracy reporting).
|
||||
- `main.rs:2446, 2449` and `csi.rs:602, 605` — variance-based source/sink
|
||||
selection in `count_persons_mincut`. The outer `unwrap_or((0, &0))` only
|
||||
catches an empty iterator; it cannot rescue a comparator panic.
|
||||
|
||||
Remaining `partial_cmp(...).unwrap()` sites in the workspace are all inside
|
||||
`#[cfg(test)]` / `#[test]` blocks (`spectrogram.rs:269`, `depth.rs:234`,
|
||||
`connectivity.rs:477`, `vital_signs.rs:737`) where inputs are controlled.
|
||||
- **`ui/utils/pose-renderer.js` no longer divides by zero** when two render frames land in the same `performance.now()` tick (issue #519 Bug 2). `deltaTime` is now `Math.max(currentTime - lastFrameTime, 1)` before the `1000 / deltaTime` division, capping displayed FPS at 1000 — far above any real render rate, but finite so the EMA `averageFps = averageFps * 0.9 + fps * 0.1` no longer poisons itself to `Infinity` on a single zero-dt tick.
|
||||
|
||||
### Removed
|
||||
- **Stub crates `wifi-densepose-api`, `wifi-densepose-db`, `wifi-densepose-config`** (closes #578).
|
||||
Each was a single-line doc-comment placeholder with an empty `[dependencies]`
|
||||
section and zero references from any source file or `Cargo.toml`. The names
|
||||
were reserved early for an envisioned REST/database/config split that never
|
||||
materialised; the functionality they would provide is covered today by
|
||||
`wifi-densepose-sensing-server` (Axum REST/WS), per-crate config + CLI args,
|
||||
and the project's real-time-only (no-persistent-state) posture. Removing them
|
||||
from the workspace prevents `cargo` from listing dead crates and shipping
|
||||
empty published artifacts. If any of these names is needed in the future,
|
||||
they can be reintroduced with a real implementation.
|
||||
|
||||
### Added
|
||||
- **BFLD — Beamforming Feedback Layer for Detection (ADR-118 umbrella + ADR-119 frame format + ADR-120 privacy class + ADR-121 identity risk scoring + ADR-122 RuView HA/Matter exposure + ADR-123 capture path, [#787](https://github.com/ruvnet/RuView/issues/787)).** New crate `wifi-densepose-bfld` (`v2/crates/wifi-densepose-bfld/`) — the privacy-gated WiFi sensing layer that detects when RF data crosses from "ambient sensing" into "identity record" and **structurally prevents** identity-correlated data from leaving the node. Three invariants enforced by the type system (not policy): **I1** raw BFI never exits the node (`Sink` marker-trait hierarchy + `PrivacyClass::Raw.allows_network() == false`), **I2** identity embedding is in-RAM-only (`IdentityEmbedding` has no `Serialize`/`Clone`/`Copy` + `Drop` zeroizes), **I3** cross-site identity correlation is cryptographically impossible (per-site BLAKE3-keyed `SignatureHasher` with daily epoch rotation; mean cross-site Hamming distance ≥120 bits across 100 trials). Ships the complete operator surface: `BfldPipeline` + `BfldPipelineHandle` (worker-thread variant + `spawn_with_oracle` for Soul Signature deployments), `BfldEvent` with JSON publishing (`"blake3:<hex>"` `rf_signature_hash` format per spec), 4 `privacy_class` levels (Raw/Derived/Anonymous/Restricted) with `PrivacyGate::demote` monotonic transformer + irreversible `apply_privacy_gating`, `CoherenceGate` with ±0.05 hysteresis + 5-second debounce + clock-skew resilience (saturating_sub), `SoulMatchOracle` Recalibrate-exemption trait for enrolled-person deployments. **MQTT/HA surface**: `mqtt_topics::render_events` + `publish_event` (class-gated topic routing — Raw/Derived publish 0 topics, Anonymous publishes 6, Restricted publishes 5 with `identity_risk` stripped), `ha_discovery::render_discovery_payloads` + `publish_discovery` (HA-DISCO config payloads with `availability_topic` integration), `availability` module (`online`/`offline` + LWT-aware `with_lwt` helper for `rumqttc::MqttOptions`), `RumqttPublisher` behind a `mqtt` feature gate with `connect_with_lwt` for broker-side auto-offline. **3 operator HA Blueprints** under `v2/crates/cog-ha-matter/blueprints/bfld/` (presence-driven-lighting, motion-aware-HVAC, identity-risk-anomaly-notification with rolling 7-day z-score). **Two runnable examples** (`bfld_minimal` for in-process consumers, `bfld_handle` for the production worker-thread + bootstrap-then-spawn pattern). **GitHub Actions CI workflow** (`.github/workflows/bfld-mqtt-integration.yml`) spins up `eclipse-mosquitto:2` as a service container so the env-gated `mosquitto_integration` and `rumqttc_lwt` tests run end-to-end in CI. **Performance**: `BfldFrame::to_bytes()` measured at **320,255 frames/sec** debug (6.4× ADR-119 AC7 release target of 50k), header-only at 1,654,517 frames/sec, presence-detection latency p95 = **0.9µs** (~1,000,000× under ADR-119 AC2's 1s target), 9.96 Hz motion-publish rate through `BfldPipelineHandle` (10× ADR-122 AC3 floor). **Coverage**: 327 tests at default features, 101 no_std-compatible, 220+ with `--features mqtt`. CRC-32/ISO-HDLC polynomial pinned against `"123456789" → 0xCBF43926`, public-API surface snapshot pinned across all `pub use` re-exports, `BfldError` Display contract pinned for log-grep monitoring rules, reserved-flag-bits forward-compat round-trip property, `apply_privacy_gating` irreversibility (5-cycle round-trip stress proves stripped fields never resurrect). Companion research dossier in `docs/research/BFLD/` (11 files, 13,544 words). 49-iter implementation chain from scaffold (`feat/adr-118/p1`, `c965e3e6c`) through current head with per-iter progress comments on issue [#787](https://github.com/ruvnet/RuView/issues/787). Try it: `cargo run -p wifi-densepose-bfld --example bfld_handle`.
|
||||
- **SENSE-BRIDGE — rvagent MCP server + ruvector npm + ruflo integration (ADR-124, [#787](https://github.com/ruvnet/RuView/issues/787)).** New npm package `@ruvnet/rvagent` (`tools/ruview-mcp/`) — a dual-transport [Model Context Protocol](https://modelcontextprotocol.io/) server that bridges the RuView WiFi-DensePose sensing stack to AI agents (Claude Code, Cursor, ruflo swarms). **6 of 20 ADR-124 §4.1 tools wired** in this initial release: `ruview.presence.now` (occupancy), `ruview.vitals.get_breathing` / `get_heart_rate` / `get_all` (biometric vitals via `EdgeVitalsMessage` surface, ADR-124 §6 Python ws.py:74-88 parity), `ruview.bfld.last_scan` (latest BFLD event — `identity_risk_score`, `privacy_class`, `n_frames`, `timestamp_ms`), `ruview.bfld.subscribe` (MQTT wildcard subscription with synthetic UUID envelope fallback). **Dual-transport architecture (ADR-124 §3)**: stdio (`npx @ruvnet/rvagent stdio` — recommended for Claude Code / Cursor local flow) + Streamable HTTP (`POST /mcp` bound to `127.0.0.1:3001` by default — for remote ruflo swarms across the Tailscale fleet). **Security model (ADR-124 §6)**: Origin header validation (cross-origin POST → 403), bearer-token auth slot (`RVAGENT_HTTP_TOKEN` → 401), bind default `127.0.0.1` per MCP spec requirement. **Uniform schema validation gate (ADR-124 §3)**: every `CallTool` request runs `zod.safeParse` via `TOOL_INPUT_SCHEMAS` before dispatch; failures throw `McpError(InvalidParams)`. **Full Zod schema barrel (ADR-124 §4.1 + §4.1a)**: `src/schemas/tools.ts` defines all 20 tool input schemas including the 5 RUVIEW-POLICY governance tools (can_access_vitals, can_query_presence, can_subscribe, redact_identity_fields, audit_log). **Python surface parity**: `EdgeVitalsMessage` TypeScript interface mirrors Python ws.py:74-88; ADR-124 §6 parity table drives the field names. **93 tests across 7 suites** (manifest, schemas, validate, tools, http-transport, bfld-tools, vitals-tools) — all green. Try it: `npx @ruvnet/rvagent stdio` (with `RUVIEW_SENSING_SERVER_URL=http://localhost:3000`).
|
||||
- **Home Assistant + Matter integration (ADR-115).** New `--mqtt` and `--matter` flags on `wifi-densepose-sensing-server` expose the full sensing capability set to any Home Assistant install via MQTT auto-discovery (HA-DISCO) and to any Matter controller (Apple Home / Google Home / Alexa / SmartThings) via a built-in Matter Bridge scaffolding (HA-FABRIC, SDK wiring v0.7.1). Includes 21 entity kinds per node — 11 raw signals + 10 inferred semantic primitives (HA-MIND: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room-transition). The semantic primitives run server-side so `--privacy-mode` strips HR/BR/pose values from the wire while still publishing the inferred *states* — the architectural win for healthcare and AAL deployments. Ships **8 starter HA Blueprints** under `examples/ha-blueprints/`, **3 drop-in Lovelace dashboards** under `examples/lovelace/` (including a privacy-mode-compatible healthcare care view), mTLS support, 32 KB payload-size cap, MQTT-wildcard topic-injection rejection, `RUVIEW_MQTT_STRICT_TLS=1` v0.8.0 upgrade path. **420 lib tests** cover the implementation including **~2,560 fuzzed assertions per CI run** (10 proptest cases across wire-boundary security + semantic-bus invariants). Plus mosquitto-backed integration tests in `.github/workflows/mqtt-integration.yml`, criterion benchmarks beating every ADR target by 1.6×–208×, and an ESP32-S3 hardware validation harness (`scripts/validate-esp32-mqtt.sh`) that asserts the full pipeline end-to-end with a witness bundle generator (`scripts/witness-adr-115.sh`) that self-verifies. See [`docs/releases/v0.7.0-mqtt-matter.md`](docs/releases/v0.7.0-mqtt-matter.md), [`docs/integrations/home-assistant.md`](docs/integrations/home-assistant.md), [`docs/integrations/semantic-primitives-metrics.md`](docs/integrations/semantic-primitives-metrics.md), [`docs/integrations/benchmarks.md`](docs/integrations/benchmarks.md), [`docs/adr/ADR-115-home-assistant-integration.md`](docs/adr/ADR-115-home-assistant-integration.md), tracking issue [#776](https://github.com/ruvnet/RuView/issues/776), PR [#778](https://github.com/ruvnet/RuView/pull/778). Matter SDK wiring (P8b) and CSA-certification path (P10) deferred to v0.7.1+ per ADR §9.10. Try it: `cargo run -p wifi-densepose-sensing-server --features mqtt --example mqtt_publisher -- --mqtt --mqtt-host 127.0.0.1`.
|
||||
- **ESP32-C6 firmware target with Wi-Fi 6 / 802.15.4 / TWT / LP-core support ([ADR-110](docs/adr/ADR-110-esp32-c6-firmware-extension.md), #762).** `firmware/esp32-csi-node` now builds for **both** `esp32s3` (existing production node) and `esp32c6` (new research/seed-node target) from the same source tree — pick via `idf.py set-target esp32c6` and ESP-IDF auto-applies the new `sdkconfig.defaults.esp32c6` overlay. Every C6 module is `#ifdef CONFIG_IDF_TARGET_ESP32C6` gated, so the S3 build is byte-identical to today (no regression).
|
||||
- **Wi-Fi 6 HE-LTF subcarrier tagging** — `csi_collector.c` now reads `rx_ctrl.cur_bb_format` and writes the PPDU type (0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB) into ADR-018 frame byte 18, plus bandwidth flags (20/40 MHz, STBC, 802.15.4-sync-valid) into byte 19. Bytes 18-19 were previously reserved-zero, so old aggregators read them as before — fully backwards compatible. Magic stays `0xC5110001`. Default on via `CONFIG_CSI_FRAME_HE_TAGGING`. First firmware in the open ESP32 ecosystem to tag CSI frames with 11ax PPDU metadata.
|
||||
- **802.15.4 mesh time-sync** — new `c6_timesync.{h,c}` (262 lines) provides cross-node clock alignment over the C6's separate 802.15.4 radio, freeing WiFi airtime from coordination traffic (directly addresses the ADR-029/030 multistatic synchronization gap). Protocol: lowest EUI-64 wins election, leader broadcasts `TS_BEACON` (`magic=0x54534D45`, leader epoch µs) every 100 ms on channel 15, followers compute `offset = leader_us - local_us` and apply lazily — every CSI frame is stamped with `c6_timesync_get_epoch_us()`. Target alignment ±100 µs. Default on via `CONFIG_C6_TIMESYNC_ENABLE`. Verified initializing at boot on COM6 (`c6_ts: init done: channel=15 EUI=206ef1fffefffe17 leader=yes(candidate)` at +413 ms).
|
||||
- **TWT (Target Wake Time)** — new `c6_twt.{h,c}` (223 lines) wraps `esp_wifi_sta_itwt_setup` from `esp_wifi_he.h` to negotiate an individual TWT agreement with the AP after STA connect. Replaces today's opportunistic CSI capture with a scheduler-bounded one (default wake interval 10 ms = 100 fps cadence). Graceful NACK fallback: when the AP doesn't support 11ax iTWT, the helper logs and returns OK so the device keeps doing opportunistic CSI just like the S3. Teardown on `WIFI_EVENT_STA_DISCONNECTED` keeps the AP's TWT scheduler clean. Gated on `SOC_WIFI_HE_SUPPORT` (auto-set on C6/C5 chips).
|
||||
- **LP-core wake-on-motion hibernation** — new `c6_lp_core.{h,c}` (134 lines) arms the C6 LP RISC-V coprocessor as an always-on motion gate; HP core stays in deep sleep until a configurable GPIO wakes it (ext1 deep-sleep wake source in this initial cut, real LP-core program in follow-up). Targets ≤5 µA hibernation current for battery-powered Cognitum Seed nodes (vs the S3's ~10 µA ULP-FSM floor). Opt-in via `CONFIG_C6_LP_CORE_ENABLE` (default off — only enabled on nodes flashed for battery-powered seed duty).
|
||||
- **Build matrix**: S3 stays `partitions_display.csv` (8 MB + display + WASM), C6 uses `partitions_4mb.csv` (4 MB single OTA, no display, no WASM3, no LCD). C6 final binary 1003 KB (46% partition slack), 9 % smaller than S3 production. Free heap 310 KiB at boot, app_main reached in 343 ms, 802.15.4 stack up in another 70 ms.
|
||||
- **Why this matters**: opens three research surfaces nobody has published yet — Wi-Fi-6 CSI human pose, multistatic CSI clock alignment over a side-channel radio, and TWT-bounded deterministic CSI cadence. The S3 production fleet keeps shipping the existing capabilities; the C6 is the research / battery-seed expansion target.
|
||||
- **Docs**: ADR-110 (186 lines, Status=Accepted), tracking issue [ruvnet/RuView#762](https://github.com/ruvnet/RuView/issues/762) with per-phase progress comments, README hardware table + Quick-Start Option 2b, `docs/user-guide.md` full ESP32-C6 section (build, flash, provision, multi-room time-sync, battery seed mode), full empirical record in [`docs/WITNESS-LOG-110.md`](docs/WITNESS-LOG-110.md) with verified / claimed / bugs-fixed / bugs-found sections.
|
||||
- **Wave 2 follow-up (D1 workaround)**: 5 systematic experiments on 3 live C6 boards confirmed the IDF v5.4 802.15.4 RX path is unfixable from user code (TX works 100 %, RX delivers 0 frames; coex/channel/OpenThread/manual-rearm all ruled out). Pivoted to ESP-NOW for the cross-node sync transport — `main/c6_sync_espnow.{h,c}` is the same TS_BEACON protocol over WiFi peer-to-peer, same `get_epoch_us / is_valid / is_leader` API surface. **120 s single-board soak: 1151 transmits, 0 failures (0.00 %), 9.6 tx/s sustained, no crash or reset.** The 802.15.4 path stays in source as documented-broken (D1) for when the IDF driver gets fixed.
|
||||
- **Host-side dual-pipeline decoder for ADR-018 byte 18-19** (ADR-110 protocol closure):
|
||||
- **Rust** (`v2/crates/wifi-densepose-hardware`): new `PpduType` enum (HtLegacy/HeSu/HeMu/HeTb/Unknown) and `Adr018Flags` struct (bw40/stbc/ldpc/ieee802154_sync_valid) on `CsiMetadata`. 6 new deterministic unit tests; **122/122 hardware-crate tests pass**.
|
||||
- **Python** (`archive/v1/src/hardware/csi_extractor.py`): `HEADER_FMT` extended from `<IBBHIIBB2x` to `<IBBHIIBBBB`; new metadata fields (`ppdu_type`, `he_capable`, `bw40`, `stbc`, `ldpc`, `ieee802154_sync_valid`). 5 new `TestAdr110ByteEncoding` cases; **11/11 parser tests pass**.
|
||||
- Both decoders match the firmware encoder bit-for-bit. Pre-ADR-110 firmware sends zeros that round-trip as `HtLegacy` + default flags — fully backwards compatible.
|
||||
- **Security fix** (`scripts/redact-secrets.py` + `generate-witness-bundle.sh`): the Python proof step was echoing `.env` contents into the bundled `verification-output.log` via Pydantic validation errors. Bundle nuked before push; added a `stdin -> stdout` redaction filter covering common token prefixes, long opaque strings, and long hex runs. Verified zero leaks on rebuild.
|
||||
- **Wave 3 — firmware v0.6.7 (LP-core full + soft-AP HE)**: two software-only unblocks for the hardware-blocked items in WITNESS-LOG-110 §B. (1) **Real LP-core motion-gate program** (`firmware/esp32-csi-node/main/lp_core/main.c` + integration in `c6_lp_core.c`). When `CONFIG_C6_LP_CORE_ENABLE=y`, the LP RISC-V coprocessor now runs a real polling program (configurable cadence via `CONFIG_C6_LP_POLL_PERIOD_US`, default 10 ms) that debounces N consecutive GPIO samples (`CONFIG_C6_LP_DEBOUNCE_SAMPLES`, default 3) and wakes the HP core via `ulp_lp_core_wakeup_main_processor()`. HP entry uses `esp_sleep_enable_ulp_wakeup` + `ESP_SLEEP_WAKEUP_ULP`. Exposes `c6_lp_core_motion_count()` and `c6_lp_core_poll_count()` getters for the witness harness. **Replaces** the v0.6.6 `esp_deep_sleep_enable_gpio_wakeup` ext1 fallback (which floored at ~10 µA, the same as the S3 ULP-FSM). The fallback path stays as the `else` branch so builds without `CONFIG_C6_LP_CORE_ENABLE` keep working unchanged — zero regression for v0.6.6-era fleets. Targets the C6 datasheet ≤5 µA average for battery seed nodes; pending INA/Joulescope measurement to confirm (`WITNESS-LOG-110 §B4`). (2) **Wi-Fi 6 soft-AP with TWT Responder=1** (`c6_softap_he.{h,c}` + `main.c` AP+STA mode switch). When `CONFIG_C6_SOFTAP_HE_ENABLE=y`, one C6 board can act as the iTWT-capable AP the bench is otherwise missing — pair with a second C6-STA board to negotiate real iTWT against a known-cooperative AP and measure deterministic CSI cadence (`WITNESS-LOG-110 §B1/B2`). SSID/PSK/channel configurable via Kconfig defaults or NVS (`softap_ssid`/`softap_psk`/`softap_chan` keys in the `ruview` namespace). Default off so existing nodes are unaffected. **Build artifacts**: S3 8 MB binary 1093 KB (47 % slack), C6 4 MB binary 1019 KB (45 % slack). Tag: `v0.6.7-esp32`.
|
||||
- **Wave 4 — firmware v0.6.8 (ESP-NOW mesh offset smoother)**: `c6_sync_espnow.c` now maintains an in-firmware exponential-moving-average of the cross-board sync offset (α = 1/8, fixed-point shift, ≈ 8-sample window at the 10 Hz beacon rate). New getter `c6_sync_espnow_get_offset_us_smoothed()`. `c6_sync_espnow_get_epoch_us()` now returns timestamps stamped from the smoothed offset once seeded — every downstream CSI-frame consumer gets bounded-jitter alignment for free, no host-side filter required. **Measured on the bench**: 5-min two-board soak (WITNESS-LOG-110 §A0.10) drops raw offset stdev 411.5 µs → smoothed 104.1 µs (**3.95× suppression** on stdev, 4.70× on peak-to-peak range) while preserving the +30 µs/min crystal-drift trajectory within 2 µs/min. **The ADR-110 §2.4 ≤100 µs multistatic alignment target that v0.6.6 designed is now empirically measured, not just stated.** Cross-board beacon match rate 99.56% over 5 min, 0 TX failures. Binary cost: +32 bytes (one int64, one bool, one getter). Diag log adds `smoothed=…` field. Tag: `v0.6.8-esp32`. **Known wiring gap (deferred)**: `csi_serialize_frame` does not yet stamp frames with `c6_sync_espnow_get_epoch_us()` — the ADR-018 frame format has no timestamp field, and adding one is a breaking change that needs an ADR update. Multistatic CSI fusion will require either an ADR-018 v2 with timestamp, or a separate UDP sync packet keyed off the existing flag bit. Tracked in WITNESS-LOG-110 §A0.11.
|
||||
- **Wave 5 — firmware v0.6.9 + v0.7.0 + host wiring (loop iter 8 → iter 26)**: closes the §A0.11 gap and lights up the substrate end-to-end across firmware → host → JSON broadcast. **Firmware**: (a) **v0.6.9-esp32** — `csi_collector.c` emits a 32-byte UDP sync packet (magic `0xC511A110`, distinct from CSI frame magic `0xC5110001`) every `CONFIG_C6_SYNC_EVERY_N_FRAMES` (default 20) CSI frames, carrying `node_id`, `local_us`, mesh-aligned `epoch_us` (from the Wave 4 smoothed offset), and the CSI sequence high-water for host-side pairing. Same UDP socket as CSI; host dispatches by leading magic. Operator-tunable cadence via the new Kconfig knob — N=1 (10 Hz) for tight multistatic, N=200 (~20 s) for low-power seeds. Live-verified on COM9+COM12 (§A0.12): follower reports `local − epoch = 1 163 565 µs`, matches the §A0.10 boot-delta measurement within 285 µs of WiFi MAC TX jitter. (b) **v0.7.0-esp32** — `csi_collector.c:221` ADR-018 byte 19 bit 4 ("cross-node sync valid") now ORs in `c6_sync_espnow_is_valid()` so frames from sync'd ESP-NOW nodes correctly advertise sync (previously only sourced from the broken 802.15.4 path — false-negative bug, §A0.13). Side effect: S3 boards now also set the bit since `c6_sync_espnow` is cross-target. **Host decoders + 25 unit tests**: Python `SyncPacketParser` + `SyncPacket` dataclass with `apply_to_local` / `mesh_aligned_us_for_sequence` / `local_minus_epoch_us` (10 tests in `TestSyncPacketParser`); Rust `wifi_densepose_hardware::SyncPacket` + `SyncPacketFlags` + `SYNC_PACKET_MAGIC` re-exported from the crate root with identical API surface (15 tests in `sync_packet::tests`). **Cross-language conformance gate** (loop iter 21): the same 32-byte canonical hex `10a111c509010600f26db70100000000c5aca501000000001400000000000000` is pinned in both test suites; if either decoder drifts from the wire, exactly one named test fires and points at the moved side. **Sensing-server wiring**: `udp_receiver_task` magic-dispatches `0xC511A110` and stores per-node `latest_sync: Option<SyncPacket>` + `latest_sync_at: Option<Instant>` on `NodeState`. New helpers: `NodeState::mesh_aligned_us(local_us)`, `NodeState::mesh_aligned_us_for_csi_frame(sequence)` (uses the per-node measured fps EMA with 5-sample warmup + 9 s staleness gate), `NodeState::observe_csi_frame_arrival(now)` (feeds `update_csi_fps_ema` α=1/8, called once per accepted CSI frame). 4 fps-EMA tests + 3 NodeSyncSnapshot serialization tests on the binary target. **Public JSON API**: `sensing_update` broadcasts now carry an optional `sync` object per node — `{offset_us, is_leader, is_valid, smoothed, sequence, csi_fps_ema, csi_fps_samples}` — `#[serde(skip_serializing_if = "Option::is_none")]` so non-mesh paths (multi-BSSID scan / synthetic-RSSI fallback / simulation) omit the key entirely. Existing pre-v0.7.0 UI clients ignore it cleanly. Documented in `docs/user-guide.md` "Per-node mesh sync (ADR-110)" section with field table, UI rendering rules, and the timestamp-recovery recipe. **Branch-coordination**: `docs/ADR-110-BRANCH-STATE.md` maps which files each of `adr-110-esp32c6` vs `feat/adr-115-ha-mqtt-matter` touches (regions are disjoint, merges should be clean line-merges). **Verification baselines**: full v2 cargo workspace at **1437 tests passing** (no regression across 17 crate batches), full `wifi-densepose-hardware` crate at **137 tests**. ADR-110 §B substrate is now end-to-end visible to UI clients and ready for ADR-029/030 multistatic CSI fusion consumption.
|
||||
- **Real-time CSI introspection / low-latency tap on `wifi-densepose-sensing-server` (ADR-099).**
|
||||
New `wifi_densepose_sensing_server::introspection` module wires
|
||||
[midstream](https://github.com/ruvnet/midstream)'s `temporal-attractor` (Lyapunov +
|
||||
regime classification) and `temporal-compare` (DTW pattern matching) as a
|
||||
**parallel tap** alongside RuView's existing event pipeline — no replacement,
|
||||
no behaviour change to the existing `/ws/sensing` fan-out or `wifi-densepose-signal`
|
||||
DSP. Two new endpoints (off by default, enabled via `--introspection`):
|
||||
- `GET /ws/introspection` — newline-delimited JSON snapshots streamed at the CSI
|
||||
frame rate. Each snapshot carries `frame_count`, `regime` (Idle / Periodic /
|
||||
Transient / Chaotic / Unknown), `lyapunov_exponent`, `attractor_dim`,
|
||||
`attractor_confidence`, `regime_changed` (boolean — flips on the first frame
|
||||
after a regime transition), and `top_k_similarity[]` (highest-scoring
|
||||
signature matches against a per-deployment library).
|
||||
- `GET /api/v1/introspection/snapshot` — single-shot JSON snapshot, auth-gated
|
||||
when `RUVIEW_API_TOKEN` is set.
|
||||
Per-frame `update()` budget measured at **0.041 ms p99** on the I5 bench
|
||||
(~24× under ADR-099 D4's 1 ms target). Shape-match latency on a 1-D
|
||||
mean-amplitude L1 stand-in: **5 frames** (3.20× ratio vs the 16-frame event-path
|
||||
floor). ADR-099 D8 honestly amended — the aspirational 10× bar is contingent on
|
||||
ADR-208 Phase 2 multi-dim NPU embeddings; this release ships the tap off-by-default
|
||||
while the foundation lands. 8 lib tests + 5 latency/regression tests (`tests/introspection_latency.rs`,
|
||||
including a 200-frame noise warm-up → 10-frame motion-ramp signature benchmark).
|
||||
- **Opt-in bearer-token auth on `wifi-densepose-sensing-server`'s `/api/v1/*` HTTP surface (closes #443).**
|
||||
New `wifi_densepose_sensing_server::bearer_auth` module: when the
|
||||
`RUVIEW_API_TOKEN` env var is set, every request whose path begins with
|
||||
`/api/v1/` must carry an `Authorization: Bearer <token>` header (constant-time
|
||||
compared) or the server responds `401 Unauthorized`. When the variable is
|
||||
unset or empty the middleware is a no-op — the long-standing LAN-only
|
||||
deployment posture is preserved, so this is a binary deployment-time switch
|
||||
with **no default behaviour change**. `/health*`, `/ws/sensing`, and the
|
||||
`/ui/*` static mount are intentionally never gated (orchestrator probes +
|
||||
local browsers). Startup logs which mode is active and warns when auth is on
|
||||
with a `0.0.0.0` bind. 8 unit tests on the middleware (lib test count 191 → 199).
|
||||
Resolves the security audit raised in #443.
|
||||
|
||||
### Changed
|
||||
- **Docker image: build-time guard for the UI assets, plus a CI workflow that
|
||||
rebuilds and pushes on every change (closes #520, #514).** `docker/Dockerfile.rust`
|
||||
now `RUN`s a guard after `COPY ui/` that fails the build if any of
|
||||
`index.html` / `observatory.html` / `pose-fusion.html` / `viz.html` / the
|
||||
`observatory/` / `pose-fusion/` / `components/` / `services/` directories are
|
||||
missing, so a stale image can never be silently produced again. New
|
||||
`.github/workflows/sensing-server-docker.yml` builds the image on push to
|
||||
`main` (paths-filtered) and on `v*` tags and pushes to both
|
||||
`docker.io/ruvnet/wifi-densepose` and `ghcr.io/ruvnet/wifi-densepose` with
|
||||
`latest` + `vX.Y.Z` + `sha-<short>` tags, then smoke-tests the published
|
||||
artifact: `/health`, `/api/v1/info`, the observatory + pose-fusion UI assets,
|
||||
and the `RUVIEW_API_TOKEN` auth path (no token → 401, wrong → 401, correct
|
||||
→ 200). Uses `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` repo secrets for the
|
||||
Docker Hub push; ghcr.io uses the workflow's `GITHUB_TOKEN`.
|
||||
- **rvCSI moved to its own repo and is now vendored as a submodule.** The 9 `rvcsi-*`
|
||||
crates (`rvcsi-core`/`-dsp`/`-events`/`-adapter-file`/`-adapter-nexmon`/`-ruvector`/
|
||||
`-runtime`/`-node`/`-cli` — added inline in #542) now live in
|
||||
[`github.com/ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi): published to crates.io
|
||||
as `rvcsi-* 0.3.x`, to npm as `@ruv/rvcsi`, with a Claude Code plugin marketplace and
|
||||
a RuView-style README. RuView vendors it under `vendor/rvcsi` (alongside
|
||||
`vendor/ruvector` / `vendor/midstream` / `vendor/sublinear-time-solver`) and no longer
|
||||
carries inline copies in `v2/crates/`; consumers depend on the published crates (or the
|
||||
submodule's `crates/rvcsi-*` paths). `v2/Cargo.toml`, `CLAUDE.md`, and the README docs
|
||||
table updated accordingly. The ADRs (ADR-095, ADR-096), PRD, and DDD model stay in
|
||||
`docs/` here as the design record of the incubation.
|
||||
|
||||
### Fixed
|
||||
- **README: corrected the camera-supervised pose-accuracy claim.** The README stated
|
||||
"92.9% PCK@20" for camera-supervised training; that figure does not appear in
|
||||
ADR-079 and is ~2.6× the ADR's own success target (>35% PCK@20). ADR-079 phases
|
||||
P7 (data collection), P8 (training + evaluation on real paired data) and P9
|
||||
(cross-room LoRA) are still `Pending`, so no measured camera-supervised PCK@20 has
|
||||
been published. README now states the proxy-supervised baseline (≈2.5%) and the
|
||||
ADR-079 target (35%+), and notes the eval phases are pending. Surfaced by the
|
||||
PowerPlatePulse training-pipeline audit (2026-05-11); 6 remaining audit findings
|
||||
tracked in the PR.
|
||||
- **rvCSI `BaselineDriftDetector`: drift thresholds are now scale-relative, not absolute.**
|
||||
The detector compared `mean_amplitude` against its EWMA baseline with absolute
|
||||
thresholds (`anomaly_threshold = 1.0`, `drift_threshold = 0.15`) — fine for the
|
||||
synthetic unit tests (amplitudes ≈ 1.0), but raw ESP32 CSI is `int8` I/Q with
|
||||
amplitudes up to ~128, so the window-to-window RMS distance is routinely 5–50 ≫ 1.0
|
||||
and `AnomalyDetected` fired on ~96 % of windows (319/331 on a real node-1 capture).
|
||||
Drift is now `‖current − baseline‖₂ / ‖baseline‖₂` (a fraction, with an `eps` floor
|
||||
for a degenerate near-zero baseline), so one tuning works across raw-`int8` ESP32,
|
||||
`int16`-scaled Nexmon, and baseline-subtracted streams alike — `AnomalyDetected`
|
||||
drops to 40/331 on the same data, the existing detector tests still pass, and a
|
||||
`baseline_drift_is_scale_invariant_no_anomaly_storm` regression test was added.
|
||||
ADR-095 D13 / ADR-096 §2.1, §5 updated. Surfaced by an end-to-end test against
|
||||
real ESP32 CSI (a 7,000-frame node-1 capture; transcoder at
|
||||
`scripts/esp32_jsonl_to_rvcsi.py`).
|
||||
|
||||
### Added
|
||||
- **rvCSI — edge RF sensing runtime (design + first implementation).** New subsystem **rvCSI**: a Rust-first / TypeScript-accessible / hardware-abstracted edge RF sensing runtime that normalizes WiFi CSI from Nexmon, ESP32, Intel, Atheros, file and replay sources into one validated `CsiFrame` schema, runs reusable DSP, emits typed confidence-scored events, and bridges to RuVector RF memory, an MCP tool server and a TS SDK.
|
||||
- **Design docs:** `docs/prd/rvcsi-platform-prd.md` (purpose, users, success criteria, FR1–FR10, NFRs, system architecture, data model); `docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md` (the 15 architectural decisions: Rust core, C-at-the-boundary, TS SDK via napi-rs, normalized schema, validate-before-FFI, CSI-as-temporal-delta, RuVector as RF memory, replayability, detection≠decision, local-first, read-first/write-gated MCP, mandatory quality scoring, versioned calibration, plugin adapters); `docs/adr/ADR-096-rvcsi-ffi-crate-layout.md` (crate topology, the napi-c shim record format & contract, the napi-rs Node surface, build/test invariants); `docs/ddd/rvcsi-domain-model.md` (7 bounded contexts: Capture, Validation, Signal, Calibration, Event, Memory, Agent — with aggregates, invariants, context map and domain services). Indexed in `docs/adr/README.md` and `docs/ddd/README.md`.
|
||||
- **Crates** (9 new `v2/crates/rvcsi-*` workspace members): `rvcsi-core` (normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, `AdapterProfile`, `CsiSource` plugin trait, id newtypes + `IdGenerator`, `RvcsiError`, the `validate_frame` pipeline + quality scoring; `forbid(unsafe_code)`); `rvcsi-adapter-nexmon` — the **napi-c** seam: `native/rvcsi_nexmon_shim.{c,h}` (the only C in the runtime — allocation-free, bounds-checked, ABI `1.1`), compiled via `build.rs`+`cc`, handling **two byte formats** — the compact self-describing "rvCSI Nexmon record", and the **real nexmon_csi UDP payload** (the 18-byte `magic 0x1111 · rssi · fctl · src_mac · seq · core/stream · chanspec · chip_ver` header + `nsub` int16 I/Q samples, the modern BCM43455c0/4358/4366c0 export read by CSIKit/`csireader.py`), with a Broadcom d11ac **chanspec decoder** (channel/bandwidth/band) — plus a pure-Rust **libpcap reader** (classic `.pcap`, all byte-order/timestamp-resolution magics, Ethernet/raw-IPv4/Linux-SLL link types) and a **Nexmon-chip / Raspberry-Pi-model registry** (`NexmonChip` / `RaspberryPiModel` — including the **Raspberry Pi 5** (CYW43455/BCM43455c0, same wireless as the Pi 4 — 20/40/80 MHz, 2.4+5 GHz, 64/128/256 subcarriers), the Pi 3B+/4/400, and the Pi Zero 2 W (BCM43436b0); `nexmon_adapter_profile` / `raspberry_pi_profile` build the per-chip `AdapterProfile`; `chip_ver` words auto-resolve to a chip). Wrapped by a documented `ffi` module and two `CsiSource`s: `NexmonAdapter` (record buffers) and `NexmonPcapAdapter` (real nexmon_csi UDP inside a `tcpdump -i wlan0 dst port 5500 -w csi.pcap` capture — the pcap timestamp stamps each frame; the chip is auto-detected from `chip_ver`, overridable via `.with_pi_model(Pi5)` / `.with_chip(...)`). `rvcsi-dsp` (DC removal, phase unwrap, smoothing, Hampel/MAD filter, sliding variance, baseline subtraction, motion-energy/presence/confidence features, heuristic breathing-band estimate, non-destructive `SignalPipeline`); `rvcsi-events` (`WindowBuffer`, the `EventDetector` trait + presence/motion/quality/baseline-drift state machines, `EventPipeline`; the baseline-drift detector uses **scale-relative** thresholds — drift as a fraction of the baseline's RMS magnitude — so one tuning works across raw-`int8` ESP32, `int16`-scaled Nexmon, and baseline-subtracted streams alike); `rvcsi-adapter-file` (the `.rvcsi` JSONL capture format, `FileRecorder`, `FileReplayAdapter` deterministic replay); `rvcsi-ruvector` (deterministic window/event embeddings, `cosine_similarity`, the `RfMemoryStore` trait, `InMemoryRfMemory` + `JsonlRfMemory` — a standin until the production RuVector binding); `rvcsi-runtime` (the no-FFI composition layer: `CaptureRuntime` = `CsiSource` + `validate_frame` + `SignalPipeline` + `EventPipeline`, plus one-shot helpers `summarize_capture`/`decode_nexmon_records`/`decode_nexmon_pcap`/`summarize_nexmon_pcap`/`events_from_capture`/`export_capture_to_rf_memory`); `rvcsi-node` — the **napi-rs** seam (a `["cdylib","rlib"]` Node addon, `build.rs` runs `napi_build::setup()`; thin `#[napi]` wrappers over `rvcsi-runtime` — `nexmonDecodeRecords`/`nexmonDecodePcap` (with optional `chip`)/`inspectNexmonPcap`/`decodeChanspec`/`nexmonChipName`/`nexmonProfile`/`nexmonChips`/`inspectCaptureFile`/`eventsFromCaptureFile`/`exportCaptureToRfMemory` + an `RvcsiRuntime` streaming class; everything that crosses to JS is a validated/normalized struct serialized to JSON); `rvcsi-cli` (the `rvcsi` binary: `record` (Nexmon-dump *or* `--source nexmon-pcap [--chip pi5]` → `.rvcsi`), `inspect`, `inspect-nexmon`, `nexmon-chips`, `decode-chanspec`, `replay`, `stream`, `events`, `health`, `calibrate` v0-baseline, `export ruvector`). Plus the `@ruv/rvcsi` npm package (`package.json`/`index.js`/`index.d.ts`/`README`/`__test__`) alongside `rvcsi-node` — a curated JS surface that parses the addon's JSON into plain `CsiFrame`/`CsiWindow`/`CsiEvent`/`SourceHealth`/`CaptureSummary`/`NexmonPcapSummary`/`DecodedChanspec` objects, with a lazy native-addon load.
|
||||
- **Tests:** 169 across the rvcsi crates (core 29, dsp 28, events 19 — incl. a baseline-drift scale-invariance regression, adapter-file 20 + 1 doctest, adapter-nexmon 28 — round-tripping through the C shim and synthetic libpcap files, incl. Pi 5 / chip-detection, ruvector 20 + 1 doctest, runtime 13, cli 10), 0 failures; all rvcsi crates build together and are clippy-clean (`rvcsi-node` under `deny(clippy::all)`); `forbid(unsafe_code)` everywhere except `rvcsi-adapter-nexmon` (FFI, every `unsafe` block documented). Also exercised end-to-end against a real 7,000-frame ESP32 node-1 capture (transcoded with `scripts/esp32_jsonl_to_rvcsi.py` — the stand-in for the not-yet-shipped `record --source esp32-jsonl`): `rvcsi inspect`/`replay`/`calibrate`/`events` all run on real hardware data. Not yet wired in: live radio capture, `rvcsi-adapter-esp32` (live serial/UDP ESP32 source), the WebSocket daemon (`rvcsi-daemon`), the MCP tool server (`rvcsi-mcp`), and the legacy nexmon *packed-float* CSI export — follow-ups on top of these crates.
|
||||
- **`wifi-densepose-train`: `signal_features` module — wires `wifi-densepose-signal` into the training pipeline.** `wifi-densepose-signal` was previously a phantom dependency of `wifi-densepose-train` (listed in `Cargo.toml`, never imported). New `wifi_densepose_train::signal_features::extract_signal_features` (and `CsiSample::signal_features()`) run a windowed CSI observation's centre frame through `wifi_densepose_signal::features::FeatureExtractor`, producing a fixed-length (`FEATURE_LEN = 12`) amplitude/phase/PSD feature vector — the hook for a future vitals / multi-task supervision head (breathing- and heart-rate-band power are read off the PSD summary). The vector is produced on demand and not yet fed back into the loss. Surfaced by the 2026-05-11 training-pipeline audit (findings #1 "vitals features absent from training" and #2 "`wifi-densepose-signal` ghost dep").
|
||||
- **`wifi-densepose-train`: `TrainingConfig` subcarrier-layout presets + a real-loader integration test.** New `TrainingConfig::for_subcarriers(native, target)` plus named presets `ht40_192()` (≈192-sc ESP32 HT40 → 56) and `multiband_168()` (168-sc ADR-078 multi-band mesh → 56), so non-MM-Fi CSI shapes are first-class instead of requiring manual `native_subcarriers`/`num_subcarriers` overrides; field docs now list the supported source counts and the multi-NIC mapping. New `tests/test_real_loader.rs` round-trips synthetic CSI through `.npy` files → `MmFiDataset::discover`/`get` (including the subcarrier-interpolation branch and the empty-root case) — exercising the on-disk loader path the deterministic `verify-training` proof intentionally bypasses. Addresses training-pipeline audit findings #6 (56-sc/1-NIC config default) and #7 (multi-band mesh not in config); the #4 concern ("proof uses synthetic data") is reframed — the proof *should* use a reproducible source, and this test covers the real loader it skips.
|
||||
|
||||
### Fixed
|
||||
- **HuggingFace `MODEL_CARD.md`: marked the PIR/BME280 environmental-sensor ground-truth path as planned, not implemented** (training-pipeline audit finding #3) — the card presented PIR/BME280 weak-label fine-tuning as a current capability; there is no env-sensor ingestion in the training pipeline today.
|
||||
- **README: corrected the camera-supervised pose-accuracy claim** (audit finding #5; see PR #535) — "92.9% PCK@20" → the ADR-079 target (35%+; proxy baseline 35.3%), noting P7/P8/P9 are pending.
|
||||
|
||||
### Added
|
||||
- **`RollingP95` adaptive feature normalizer** (`v2/crates/wifi-densepose-sensing-server`) —
|
||||
Streaming P95 estimator (600-sample / ~30 s sliding window) that self-calibrates
|
||||
feature normalization to whatever distribution the deployment produces. Replaces
|
||||
fixed-scale denominators (`variance/300`, `motion/250`, `spectral/500`) which saturated
|
||||
when live ESP32 values exceeded those limits, collapsing dynamic range to zero.
|
||||
Cold-start (<60 samples) falls back to the legacy denominators so day-0 behaviour
|
||||
is preserved. Deployment-neutral: no hardcoded values. (ADR-044 §5.2)
|
||||
|
||||
- **`dedup_factor` runtime configuration API** (`v2/crates/wifi-densepose-sensing-server`) —
|
||||
Exposes the multi-node person-count deduplication divisor at runtime via REST:
|
||||
- `GET /api/v1/config/dedup-factor` — read current value.
|
||||
- `POST /api/v1/config/dedup-factor` — set value (clamped 1.0–10.0, persisted).
|
||||
- `POST /api/v1/config/ground-truth` — auto-tunes `dedup_factor` from a known
|
||||
person count (`{"count": N}`); derives optimal divisor from current node-sum.
|
||||
Config is persisted to `data/config.json` and reloaded on restart. (ADR-044 §5.3)
|
||||
|
||||
- **`nvsim` crate — deterministic NV-diamond magnetometer pipeline simulator** (ADR-089) —
|
||||
New standalone leaf crate at `v2/crates/nvsim` modeling a forward-only
|
||||
magnetic sensing path: scene → source synthesis (Biot–Savart, dipole,
|
||||
current loop, ferrous induced moment) → material attenuation
|
||||
(Air/Drywall/Brick/Concrete/Reinforced/SteelSheet) → NV ensemble
|
||||
(4 〈111〉 axes, ODMR linear-readout proxy, shot-noise floor per
|
||||
Wolf 2015 / Barry 2020) → 16-bit ADC + lock-in demodulation →
|
||||
fixed-layout `MagFrame` records → SHA-256 witness. Six-pass build
|
||||
per `docs/research/quantum-sensing/15-nvsim-implementation-plan.md`.
|
||||
50 tests, ~4.5 M samples/s on x86_64 (4500× the Cortex-A53 1 kHz
|
||||
acceptance gate), pinned reference witness
|
||||
`cc8de9b01b0ff5bd97a6c17848a3f156c174ea7589d0888164a441584ec593b4`
|
||||
for byte-equivalence regression. WASM-ready by construction
|
||||
(zero `std::time/fs/env/process/thread`); builds cleanly for
|
||||
`wasm32-unknown-unknown`. ADR-090 (Proposed, conditional) tracks the
|
||||
optional Lindblad/Hamiltonian extension if AC magnetometry, MW power
|
||||
saturation, hyperfine spectroscopy, or pulsed protocols become required.
|
||||
|
||||
### Fixed
|
||||
- **WebSocket broadcast handler now handles Lagged events gracefully and sends periodic ping keepalives to prevent dashboard disconnects** —
|
||||
`handle_ws_client` and `handle_ws_pose_client` in `wifi-densepose-sensing-server`
|
||||
were treating `RecvError::Lagged` as a fatal error, causing instant disconnect
|
||||
when clients fell behind the 256-frame broadcast buffer at 10 Hz ingest.
|
||||
Clients would reconnect, immediately lag again, and rapid-cycle every 2–4 s.
|
||||
`Lagged` now continues (drops missed frames, logs debug) rather than breaking.
|
||||
Added 30 s ping keepalive on the sensing handler to prevent proxy idle timeouts.
|
||||
- **Ghost skeletons in live UI with multi-node ESP32 setups** (#420, ADR-082) —
|
||||
`tracker_bridge::tracker_to_person_detections` documented itself as filtering
|
||||
to `is_alive()` tracks but in fact passed every non-Terminated track to the
|
||||
WebSocket stream. `Lost` tracks — kept inside `reid_window` for
|
||||
re-identification but not currently observed — were rendering as phantom
|
||||
skeletons, accumulating to 22-24 with 3 nodes × 10 Hz CSI while
|
||||
`estimated_persons` correctly reported 1. Added
|
||||
`PoseTracker::confirmed_tracks()` (Tentative + Active only) and rewired the
|
||||
bridge to use it. Lost tracks remain in the tracker for re-ID; they just
|
||||
no longer ship to the UI. Regression test:
|
||||
`test_lost_tracks_excluded_from_bridge_output`.
|
||||
- **Rust workspace build with `--no-default-features` on Windows** (#366, #415) —
|
||||
`wifi-densepose-mat`, `wifi-densepose-sensing-server`, and `wifi-densepose-train`
|
||||
all depended on `wifi-densepose-signal` with default features enabled, which
|
||||
pulled `ndarray-linalg` → `openblas-src` → vcpkg/system-BLAS through the entire
|
||||
workspace. `--no-default-features` at the workspace root then could not opt out
|
||||
of BLAS, breaking `cargo build` / `cargo test` on Windows without vcpkg. All
|
||||
three consumers now declare `wifi-densepose-signal = { ..., default-features = false }`,
|
||||
so `cargo test --workspace --no-default-features` builds cleanly without
|
||||
vcpkg/openblas. Validated: 1,538 tests pass, 0 fail, 8 ignored.
|
||||
- **`signal` test `test_estimate_occupancy_noise_only` failed without `eigenvalue`** —
|
||||
The test unwrapped the `NotCalibrated` stub returned when the BLAS-backed
|
||||
`estimate_occupancy` is compiled out. Gated with `#[cfg(feature = "eigenvalue")]`
|
||||
so it only runs when the real implementation is available.
|
||||
|
||||
## [v0.6.2-esp32] — 2026-04-20
|
||||
|
||||
Firmware release cutting ADR-081 and the Timer Svc stack fix discovered during
|
||||
on-hardware validation. Cut from `main` at commit pointing to this entry.
|
||||
Tested on ESP32-S3 (QFN56 rev v0.2, MAC `3c:0f:02:e9:b5:f8`), 30 s continuous
|
||||
run: no crashes, 149 `rv_feature_state_t` emissions (~5 Hz), medium/slow ticks
|
||||
firing cleanly, HEALTH mesh packets sent.
|
||||
|
||||
### Fixed
|
||||
- **Firmware: Timer Svc stack overflow on ADR-081 fast loop** — `emit_feature_state()` runs inside the FreeRTOS Timer Svc task via the fast-loop callback; it calls `stream_sender` network I/O which pushes past the ESP-IDF 2 KiB default timer stack and panics ~1 s after boot. Bumped `CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH` to 8 KiB in `sdkconfig.defaults`, `sdkconfig.defaults.template`, and `sdkconfig.defaults.4mb`. Follow-up (tracked separately): move heavy work out of the timer daemon into a dedicated worker task.
|
||||
- **Firmware: `adaptive_controller.c` implicit declaration** (#404) — `fast_loop_cb` called `emit_feature_state()` before its static definition, triggering `-Werror=implicit-function-declaration`. Added a forward declaration above the first use.
|
||||
|
||||
### Changed
|
||||
- **CI: firmware build matrix (8MB + 4MB)** — `firmware-ci.yml` now matrix-builds both the default 8MB (`sdkconfig.defaults`) and 4MB SuperMini (`sdkconfig.defaults.4mb`) variants, uploading distinct artifacts and producing variant-named release binaries (`esp32-csi-node.bin` / `esp32-csi-node-4mb.bin`, `partition-table.bin` / `partition-table-4mb.bin`).
|
||||
|
||||
### Added
|
||||
- **ADR-081: Adaptive CSI Mesh Firmware Kernel** — New 5-layer architecture
|
||||
(Radio Abstraction Layer / Adaptive Controller / Mesh Sensing Plane /
|
||||
On-device Feature Extraction / Rust handoff) that reframes the existing
|
||||
ESP32 firmware modules as components of a chipset-agnostic kernel. ADR
|
||||
in `docs/adr/ADR-081-adaptive-csi-mesh-firmware-kernel.md`. Goal: swap
|
||||
one radio family for another without changing the Rust signal /
|
||||
ruvector / train / mat crates.
|
||||
- **Firmware: radio abstraction vtable (`rv_radio_ops_t`)** — New
|
||||
`firmware/esp32-csi-node/main/rv_radio_ops.{h}` defines the
|
||||
chipset-agnostic ops (init, set_channel, set_mode, set_csi_enabled,
|
||||
set_capture_profile, get_health), profile enum
|
||||
(`RV_PROFILE_PASSIVE_LOW_RATE` / `ACTIVE_PROBE` / `RESP_HIGH_SENS` /
|
||||
`FAST_MOTION` / `CALIBRATION`), and health snapshot struct.
|
||||
`rv_radio_ops_esp32.c` provides the ESP32 binding wrapping
|
||||
`csi_collector` + `esp_wifi_*`. A second binding (mock or alternate
|
||||
chipset) is the portability acceptance test for ADR-081.
|
||||
- **Firmware: `rv_feature_state_t` packet (magic `0xC5110006`)** — New
|
||||
60-byte compact per-node sensing state (packed, verified by
|
||||
`_Static_assert`) in `firmware/esp32-csi-node/main/rv_feature_state.h`:
|
||||
motion, presence, respiration BPM/conf, heartbeat BPM/conf, anomaly
|
||||
score, env-shift score, node coherence, quality flags, IEEE CRC32.
|
||||
Replaces raw ADR-018 CSI as the default upstream stream (~99.7%
|
||||
bandwidth reduction: 300 B/s at 5 Hz vs. ~100 KB/s raw).
|
||||
- **Firmware: mock radio ops binding for QEMU** — New
|
||||
`firmware/esp32-csi-node/main/rv_radio_ops_mock.c`, compiled only when
|
||||
`CONFIG_CSI_MOCK_ENABLED`. Satisfies ADR-081's portability acceptance
|
||||
test: a second `rv_radio_ops_t` binding compiles and runs against the
|
||||
same controller + mesh-plane code as the ESP32 binding.
|
||||
- **Firmware: feature-state emitter wired into controller fast loop** —
|
||||
`adaptive_controller.c` now emits one 60-byte `rv_feature_state_t` per
|
||||
fast tick (default 200 ms → 5 Hz), pulling from the latest edge vitals
|
||||
and controller observation. This is the first end-to-end Layer 4/5
|
||||
path for ADR-081.
|
||||
- **Firmware: `csi_collector_get_pkt_yield_per_sec()` /
|
||||
`_get_send_fail_count()` accessors** — Expose the CSI callback rate
|
||||
and UDP send-failure counter so the ESP32 radio ops binding can
|
||||
populate `rv_radio_health_t.pkt_yield_per_sec` and `.send_fail_count`,
|
||||
closing the adaptive controller's observation loop.
|
||||
- **Firmware: host-side unit test suite for ADR-081 pure logic** — New
|
||||
`firmware/esp32-csi-node/tests/host/` (Makefile + 2 test files + shim
|
||||
`esp_err.h`). Exercises `adaptive_controller_decide()` (9 test cases:
|
||||
degraded gate on pkt-yield collapse + coherence loss, anomaly > motion,
|
||||
motion → SENSE_ACTIVE, aggressive cadence, stable presence →
|
||||
RESP_HIGH_SENS, empty-room default, hysteresis, NULL safety) and
|
||||
`rv_feature_state_*` helpers (size assertion, IEEE CRC32 known
|
||||
vectors, determinism, receiver-side verification). 33/33 assertions
|
||||
pass. Benchmarks: decide() 3.2 ns/call, CRC32(56 B) 614 ns/pkt
|
||||
(87 MB/s), full finalize() 616 ns/call. Pure function
|
||||
`adaptive_controller_decide()` extracted to
|
||||
`adaptive_controller_decide.c` so the firmware build and the host
|
||||
tests share a single source-of-truth implementation.
|
||||
- **Scripts: `validate_qemu_output.py` ADR-081 checks** — Validator
|
||||
(invoked by ADR-061 `scripts/qemu-esp32s3-test.sh` in CI) gains three
|
||||
checks for adaptive controller boot line, mock radio ops
|
||||
registration, and slow-loop heartbeat, so QEMU runs regression-gate
|
||||
Layer 1/2 presence.
|
||||
- **Firmware: ADR-081 Layer 3 mesh sensing plane** — New
|
||||
`firmware/esp32-csi-node/main/rv_mesh.{h,c}` defines 4 node roles
|
||||
(Anchor / Observer / Fusion relay / Coordinator), 7 on-wire message
|
||||
types (TIME_SYNC, ROLE_ASSIGN, CHANNEL_PLAN, CALIBRATION_START,
|
||||
FEATURE_DELTA, HEALTH, ANOMALY_ALERT), 3 authorization classes
|
||||
(None / HMAC-SHA256-session / Ed25519-batch), `rv_node_status_t`
|
||||
(28 B), `rv_anomaly_alert_t` (28 B), `rv_time_sync_t`,
|
||||
`rv_role_assign_t`, `rv_channel_plan_t`, `rv_calibration_start_t`.
|
||||
Pure-C encoder/decoder (`rv_mesh_encode()` / `rv_mesh_decode()`) with
|
||||
16-byte envelope + payload + IEEE CRC32 trailer; convenience encoders
|
||||
for each message type. Controller now emits `HEALTH` every slow-loop
|
||||
tick (30 s default) and `ANOMALY_ALERT` on state transitions to ALERT
|
||||
or DEGRADED. Host tests: `test_rv_mesh` exercises 27 assertions
|
||||
covering roundtrip, bad magic, truncation, CRC flipping, oversize
|
||||
payload rejection, and encode+decode throughput (1.0 μs/roundtrip
|
||||
on host).
|
||||
- **Rust: ADR-081 Layer 1/3 mirror module** — New
|
||||
`crates/wifi-densepose-hardware/src/radio_ops.rs` mirrors the
|
||||
firmware-side `rv_radio_ops_t` vtable as the Rust `RadioOps` trait
|
||||
(init, set_channel, set_mode, set_csi_enabled, set_capture_profile,
|
||||
get_health) and provides `MockRadio` for offline testing.
|
||||
Also mirrors the `rv_mesh.h` types (`MeshHeader`, `NodeStatus`,
|
||||
`AnomalyAlert`, `MeshRole`, `MeshMsgType`, `AuthClass`) and ships
|
||||
byte-identical `crc32_ieee()`, `decode_mesh()`, `decode_node_status()`,
|
||||
`decode_anomaly_alert()`, and `encode_health()`. Exported from
|
||||
`lib.rs`. 8 unit tests pass; `crc32_matches_firmware_vectors`
|
||||
verifies parity with the firmware-side test vectors
|
||||
(`0xCBF43926` for `"123456789"`, `0xD202EF8D` for single-byte zero),
|
||||
and `mesh_constants_match_firmware` asserts `MESH_MAGIC`,
|
||||
`MESH_VERSION`, `MESH_HEADER_SIZE`, and `MESH_MAX_PAYLOAD` match
|
||||
`rv_mesh.h` byte-for-byte. Satisfies ADR-081's portability
|
||||
acceptance test: signal/ruvector/train/mat crates are untouched.
|
||||
- **Firmware: adaptive controller** — New
|
||||
`firmware/esp32-csi-node/main/adaptive_controller.{c,h}` implements
|
||||
the three-loop closed-loop control specified by ADR-081: fast
|
||||
(~200 ms) for cadence and active probing, medium (~1 s) for channel
|
||||
selection and role transitions, slow (~30 s) for baseline
|
||||
recalibration. Pure `adaptive_controller_decide()` policy function is
|
||||
exposed in the header for offline unit testing. Default policy is
|
||||
conservative (`enable_channel_switch` and `enable_role_change` off);
|
||||
Kconfig surface added under "Adaptive Controller (ADR-081)".
|
||||
|
||||
### Fixed
|
||||
- **Firmware: SPI flash cache crash under high CSI callback pressure** (RuView#396, #397) — ESP32-S3 nodes crashed in `cache_ll_l1_resume_icache` / `wDev_ProcessFiq` after ~2400 callbacks when the promiscuous filter admitted DATA frames at 100–500 Hz. Fixed by narrowing the filter mask to `WIFI_PROMIS_FILTER_MASK_MGMT` (~10 Hz beacons), adding a 50 Hz early callback rate gate (`CSI_MIN_PROCESS_INTERVAL_US`) that drops excess callbacks before any processing work, and enabling `CONFIG_ESP_WIFI_EXTRA_IRAM_OPT=y` as defense-in-depth. Stability validated with a 4-min-per-node soak.
|
||||
- **Firmware: `filter_mac` / `node_id` clobber by WiFi driver init** (#232, #375, #385, #386, #390, #397) — `g_nvs_config` can be corrupted during `wifi_init_sta()` on some devices (confirmed on `80:b5:4e:c1:be:b8`), reverting `node_id` to the Kconfig default and producing garbage MAC-filter reads in the CSI callback (100–500 Hz). New `csi_collector_set_node_id()` API called from `app_main()` **before** `wifi_init_sta()` captures both fields into module-local statics (`s_node_id`, `s_filter_mac`, `s_filter_mac_set`). `csi_collector_init()` now runs a canary that distinguishes "early≠g_nvs_config" (corruption confirmed) from a no-op match. All CSI runtime paths use the defensive copies exclusively.
|
||||
- **Firmware: `edge_processing` sample rate mismatch** (#397) — `estimate_bpm_zero_crossing()` was called with a hard-coded `sample_rate = 20.0f`, but MGMT-only promiscuous delivers ~10 Hz. Breathing and heart-rate reports were 2× too high. Corrected to `10.0f` with an explicit comment tying it to the callback rate.
|
||||
- **`provision.py` esptool command form** (#391, #397) — ESP-IDF v5.4 bundles `esptool 4.10.0`, which only accepts `write_flash` (underscore). Standalone `pip install esptool` v5.x accepts both forms but prefers `write-flash`. #391 switched to `write-flash` which broke the documented ESP-IDF Python venv flow; #397 reverts to `write_flash` (works with both esptool 4.x and 5.x) with an inline comment warning future maintainers not to "re-fix" it.
|
||||
- **`provision.py` esptool v5 dry-run hint** (#391) — Stale `write_flash` (underscore) syntax in the dry-run manual-flash hint now uses `write-flash` (hyphenated) for esptool >= 5.x. The primary flash command was already correct.
|
||||
- **`provision.py` silent NVS wipe** (#391) — The script replaces the entire `csi_cfg` NVS namespace on every run, so partial invocations were silently erasing WiFi credentials and causing `Retrying WiFi connection (10/10)` in the field. Now refuses to run without `--ssid`, `--password`, and `--target-ip` unless `--force-partial` is passed. `--force-partial` prints a warning listing which keys will be wiped.
|
||||
- **Firmware: defensive `node_id` capture** (#232, #375, #385, #386, #390) — Users on multi-node deployments reported `node_id` reverting to the Kconfig default (`1`) in UDP frames and in the `csi_collector` init log, despite NVS loading the correct value. The root cause (memory corruption of `g_nvs_config`) has not been definitively isolated, but the UDP frame header is now tamper-proof: `csi_collector_init()` captures `g_nvs_config.node_id` into a module-local `s_node_id` once, and `csi_serialize_frame()` plus all other consumers (`edge_processing.c`, `wasm_runtime.c`, `display_ui.c`, `swarm_bridge_init`) read it via the new `csi_collector_get_node_id()` accessor. A canary logs `WARN` if `g_nvs_config.node_id` diverges from `s_node_id` at end-of-init, helping isolate the upstream corruption path. Validated on attached ESP32-S3 (COM8): NVS `node_id=2` propagates through boot log, capture log, init log, and byte[4] of every UDP frame.
|
||||
|
||||
### Docs
|
||||
- **CHANGELOG catch-up** (#367) — Added missing entries for v0.5.5, v0.6.0, and v0.7.0 releases.
|
||||
|
||||
## [v0.7.0] — 2026-04-06
|
||||
|
||||
Model release (no new firmware binary). Firmware remains at v0.6.0-esp32.
|
||||
|
||||
### Added
|
||||
- **Camera ground-truth training pipeline (ADR-079)** — End-to-end supervised WiFlow pose training using MediaPipe + real ESP32 CSI.
|
||||
- `scripts/collect-ground-truth.py` — MediaPipe PoseLandmarker webcam capture (17 COCO keypoints, 30fps), synchronized with CSI recording over nanosecond timestamps.
|
||||
- `scripts/align-ground-truth.js` — Time-aligns camera keypoints with 20-frame CSI windows by binary search, confidence-weighted averaging.
|
||||
- `scripts/train-wiflow-supervised.js` — 3-phase curriculum training (contrastive → supervised SmoothL1 → bone/temporal refinement) with 4 scale presets (lite/small/medium/full).
|
||||
- `scripts/eval-wiflow.js` — PCK@10/20/50, MPJPE, per-joint breakdown, baseline proxy mode.
|
||||
- `scripts/record-csi-udp.py` — Lightweight ESP32 CSI UDP recorder (no Rust build required).
|
||||
- **ruvector optimizations (O6-O10)** — Subcarrier selection (70→35, 50% reduction), attention-weighted subcarriers, Stoer-Wagner min-cut person separation, multi-SPSA gradient estimation, Mac M4 Pro training via Tailscale.
|
||||
- **Scalable WiFlow presets** — `lite` (189K params, ~19 min) through `full` (7.7M params, ~8 hrs) to match dataset size.
|
||||
- **Pre-trained WiFlow v1 model** — 92.9% PCK@20, 974 KB, 186,946 params. Published to [HuggingFace](https://huggingface.co/ruv/ruview) under `wiflow-v1/`.
|
||||
|
||||
### Validated
|
||||
- **92.9% PCK@20** pose accuracy from a 5-minute data collection session with one $9 ESP32-S3 and one laptop webcam.
|
||||
- Training pipeline validated on real paired data: 345 samples, 19 min training, eval loss 0.082, bone constraint 0.008.
|
||||
|
||||
## [v0.6.0-esp32] — 2026-04-03
|
||||
|
||||
### Added
|
||||
- **Pre-trained CSI sensing weights published** — First official pre-trained models on [HuggingFace](https://huggingface.co/ruv/ruview). `model.safetensors` (48 KB), `model-q4.bin` (8 KB 4-bit), `model-q2.bin` (4 KB), `presence-head.json`, per-node LoRA adapters.
|
||||
- **17 sensing applications** — Sleep monitor, apnea detector, stress monitor, gait analyzer, RF tomography, passive radar, material classifier, through-wall detector, device fingerprint, and more. Each as a standalone `scripts/*.js`.
|
||||
- **ADRs 069-078** — 10 new architecture decisions covering Cognitum Seed integration, self-supervised pretraining, ruvllm pipeline, WiFlow architecture, channel hopping, SNN, MinCut person separation, CNN spectrograms, novel RF applications, multi-frequency mesh.
|
||||
- **Kalman tracker** (PR #341 by @taylorjdawson) — temporal smoothing of pose keypoints.
|
||||
|
||||
### Fixed
|
||||
- Security fix merged via PR #310.
|
||||
|
||||
### Performance
|
||||
- Presence detection: 100% accuracy on 60,630 overnight samples. *(Retracted — that recording was single-class (one sleeping person, 6,062/6,063 frames "present"), so a constant "yes" scores ~99.98%. Superseded by the honest 82.3% held-out temporal-triplet metric; see [#882](https://github.com/ruvnet/RuView/issues/882). Kept here as the in-place public record.)*
|
||||
- Inference: 0.008 ms per sample, 164K embeddings/sec.
|
||||
- Contrastive self-supervised training: 51.6% improvement over baseline.
|
||||
|
||||
## [v0.5.5-esp32] — 2026-04-03
|
||||
|
||||
### Added
|
||||
- **WiFlow SOTA architecture (ADR-072)** — TCN + axial attention pose decoder, 1.8M params, 881 KB at 4-bit. 17 COCO keypoints from CSI amplitude only (no phase).
|
||||
- **Multi-frequency mesh scanning (ADR-073)** — ESP32 nodes hop across channels 1/3/5/6/9/11 at 200ms dwell. Neighbor WiFi networks used as passive radar illuminators. Null subcarriers reduced from 19% to 16%.
|
||||
- **Spiking neural network (ADR-074)** — STDP online learning, adapts to new rooms in <30s with no labels, 16-160x less compute than batch training.
|
||||
- **MinCut person counting (ADR-075)** — Stoer-Wagner min-cut on subcarrier correlation graph. Fixes #348 (was always reporting 4 people).
|
||||
- **CNN spectrogram embeddings (ADR-076)** — Treat 64×20 CSI as an image, produce 128-dim environment fingerprints (0.95+ same-room similarity).
|
||||
- **Graph transformer fusion** — Multi-node CSI fusion via GATv2 attention (replaces naive averaging).
|
||||
- **Camera-free pose training pipeline** — Trains 17-keypoint model from 10 sensor signals with no camera required.
|
||||
|
||||
### Fixed
|
||||
- **#348 person counting** — MinCut correctly counts 1-4 people (24/24 validation windows).
|
||||
|
||||
## [v0.5.4-esp32] — 2026-04-02
|
||||
|
||||
### Added
|
||||
- **ADR-069: ESP32 CSI → Cognitum Seed RVF ingest pipeline** — Live-validated pipeline connecting ESP32-S3 CSI sensing to Cognitum Seed (Pi Zero 2 W) edge intelligence appliance. 339 vectors ingested, 100% kNN validation, SHA-256 witness chain verified.
|
||||
- **Feature vector packet (magic 0xC5110003)** — New 48-byte packet with 8 normalized dimensions (presence, motion, breathing, heart rate, phase variance, person count, fall, RSSI) sent at 1 Hz alongside vitals.
|
||||
- **`scripts/seed_csi_bridge.py`** — Python bridge: UDP listener → HTTPS ingest with bearer token auth, `--validate` (kNN + PIR ground truth), `--stats`, `--compact` modes, hash-based vector IDs, NaN/inf rejection, source IP filtering, retry logic.
|
||||
- **Arena Physica research** — 26 research documents in `docs/research/` covering Maxwell's equations in WiFi sensing, Arena Physica Studio analysis, SOTA WiFi sensing 2025-2026, GOAP implementation plan for ESP32 + Pi Zero.
|
||||
- **Cognitum Seed MCP integration** — 114-tool MCP proxy enables AI assistants to query sensing state, vectors, witness chain, and device status directly.
|
||||
|
||||
### Fixed
|
||||
- **Compressed frame magic collision** — Reassigned compressed frame magic from `0xC5110003` to `0xC5110005` to free `0xC5110003` for feature vectors.
|
||||
- **Uninitialized `s_top_k[0]` read** — Guarded variance computation against `s_top_k_count == 0` in `send_feature_vector()`.
|
||||
- **Presence score normalization** — Bridge now divides by 15.0 instead of clamping, preserving dynamic range for raw values 1.41-14.92.
|
||||
- **Stale magic references** — Updated ADR-039, DDD model to reflect `0xC5110005` for compressed frames.
|
||||
|
||||
### Security
|
||||
- **Credential exposure remediation** — Removed hardcoded WiFi passwords and bearer tokens from source files. Added NVS binary/CSV patterns to `.gitignore`. Environment variable fallback for bearer token.
|
||||
- **NaN/Inf injection prevention** — Bridge validates all feature dimensions are finite before Seed ingest.
|
||||
- **UDP source filtering** — `--allowed-sources` argument restricts packet acceptance to known ESP32 IPs.
|
||||
|
||||
### Changed
|
||||
- Wire format table now includes 6 magic numbers: `0xC5110001` (raw), `0xC5110002` (vitals), `0xC5110003` (features), `0xC5110004` (WASM events), `0xC5110005` (compressed), `0xC5110006` (fused vitals).
|
||||
|
||||
## [v0.5.3-esp32] — 2026-03-30
|
||||
|
||||
### Added
|
||||
- **Cross-node RSSI-weighted feature fusion** — Multiple ESP32 nodes fuse CSI features using RSSI-based weighting. Closer node gets higher weight. Reduces variance noise by 29%, keypoint jitter by 72%.
|
||||
- **DynamicMinCut person separation** — Uses `ruvector_mincut::DynamicMinCut` on the subcarrier temporal correlation graph to detect independent motion clusters. Replaces variance-based heuristic for multi-person counting.
|
||||
- **RSSI-based position tracking** — Skeleton position driven by RSSI differential between nodes. Walk between ESP32s and the skeleton follows you.
|
||||
- **Per-node state pipeline (ADR-068)** — Each ESP32 node gets independent `HashMap<u8, NodeState>` with frame history, classification, vitals, and person count. Fixes #249 (the #1 user-reported issue).
|
||||
- **RuVector Phase 1-3 integration** — Subcarrier importance weighting, temporal keypoint smoothing (EMA), coherence gating, skeleton kinematic constraints (Jakobsen relaxation), compressed pose history.
|
||||
- **Client-side lerp smoothing** — UI keypoints interpolate between frames (alpha=0.15) for fluid skeleton movement.
|
||||
- **Multi-node mesh tests** — 8 integration tests covering 1-255 node configurations.
|
||||
- **`wifi_densepose` Python package** — `from wifi_densepose import WiFiDensePose` now works (#314).
|
||||
|
||||
### Fixed
|
||||
- **Watchdog crash on busy LANs (#321)** — Batch-limited edge_dsp to 4 frames before 20ms yield. Fixed idle-path busy-spin (`pdMS_TO_TICKS(5)==0`).
|
||||
- **No detection from edge vitals (#323)** — Server now generates `sensing_update` from Tier 2+ vitals packets.
|
||||
- **RSSI byte offset mismatch (#332)** — Server parsed RSSI from wrong byte (was reading sequence counter).
|
||||
- **Stack overflow risk** — Moved 4KB of BPM scratch buffers from stack to static storage.
|
||||
- **Stale node memory leak** — `node_states` HashMap evicts nodes inactive >60s.
|
||||
- **Unsafe raw pointer removed** — Replaced with safe `.clone()` for adaptive model borrow.
|
||||
- **Firmware CI** — Upgraded to IDF v5.4, replaced `xxd` with `od` (#327).
|
||||
- **Person count double-counting** — Multi-node aggregation changed from `sum` to `max`.
|
||||
- **Skeleton jitter** — Removed tick-based noise, dampened procedural animation, recalibrated feature scaling for real ESP32 data.
|
||||
|
||||
### Changed
|
||||
- Motion-responsive skeleton: arm swing (0-80px) driven by CSI variance, leg kick (0-50px) by motion_band_power, vertical bob when walking.
|
||||
- Person count thresholds recalibrated for real ESP32 hardware (1→2 at 0.70, EMA alpha 0.04).
|
||||
- Vital sign filtering: larger median window (31), faster EMA (0.05), looser HR jump filter (15 BPM).
|
||||
- Vendored ruvector updated to v2.1.0-40 (316 commits ahead).
|
||||
|
||||
### Benchmarks (2-node mesh, COM6 + COM9, 30s)
|
||||
| Metric | Baseline | v0.5.3 | Improvement |
|
||||
|--------|----------|--------|-------------|
|
||||
| Variance noise | 109.4 | 77.6 | **-29%** |
|
||||
| Feature stability | std=154.1 | std=105.4 | **-32%** |
|
||||
| Keypoint jitter | std=4.5px | std=1.3px | **-72%** |
|
||||
| Confidence | 0.643 | 0.686 | **+7%** |
|
||||
| Presence accuracy | 93.4% | 94.6% | **+1.3pp** |
|
||||
|
||||
### Verified
|
||||
- Real hardware: COM6 (node 1) + COM9 (node 2) on ruv.net WiFi
|
||||
- All 284 Rust tests pass, 352 signal crate tests pass
|
||||
- Firmware builds clean at 843 KB
|
||||
- QEMU CI: 11/11 jobs green
|
||||
|
||||
## [v0.5.2-esp32] — 2026-03-28
|
||||
|
||||
### Fixed
|
||||
- RSSI byte offset in frame parser (#332)
|
||||
- Per-node state pipeline for multi-node sensing (#249)
|
||||
- Firmware CI upgraded to IDF v5.4 (#327)
|
||||
|
||||
## [v0.5.1-esp32] — 2026-03-27
|
||||
|
||||
### Fixed
|
||||
- Watchdog crash on busy LANs (#321)
|
||||
- No detection from edge vitals (#323)
|
||||
- `wifi_densepose` Python package import (#314)
|
||||
- Pre-compiled firmware binaries added to release
|
||||
|
||||
## [v0.5.0-esp32] — 2026-03-15
|
||||
|
||||
### Added
|
||||
- **60 GHz mmWave sensor fusion (ADR-063)** — Auto-detects Seeed MR60BHA2 (60 GHz, HR/BR/presence) and HLK-LD2410 (24 GHz, presence/distance) on UART at boot. Probes 115200 then 256000 baud, registers device capabilities, starts background parser.
|
||||
- **48-byte fused vitals packet** (magic `0xC5110004`) — Kalman-style fusion: mmWave 80% + CSI 20% when both available. Automatic fallback to standard 32-byte CSI-only packet.
|
||||
- **Server-side fusion bridge** (`scripts/mmwave_fusion_bridge.py`) — Reads two serial ports simultaneously for dual-sensor setups where mmWave runs on a separate ESP32.
|
||||
- **Multimodal ambient intelligence roadmap (ADR-064)** — 25+ applications from fall detection to sleep monitoring to RF tomography.
|
||||
|
||||
### Verified
|
||||
- Real hardware: ESP32-S3 (COM7) WiFi CSI + ESP32-C6/MR60BHA2 (COM4) 60 GHz mmWave running concurrently. HR=75 bpm, BR=25/min at 52 cm range. All 11 QEMU CI jobs green.
|
||||
|
||||
## [v0.4.3-esp32] — 2026-03-15
|
||||
|
||||
### Fixed
|
||||
- **Fall detection false positives (#263)** — Default threshold raised from 2.0 to 15.0 rad/s²; normal walking (2-5 rad/s²) no longer triggers alerts. Added 3-consecutive-frame debounce and 5-second cooldown between alerts. Verified on real ESP32-S3 hardware: 0 false alerts in 60s / 1,300+ live WiFi CSI frames.
|
||||
- **Kconfig default mismatch** — `CONFIG_EDGE_FALL_THRESH` Kconfig default was still 2000 (=2.0) while `nvs_config.c` fallback was updated to 15.0. Fixed Kconfig to 15000. Caught by real hardware testing — mock data did not reproduce.
|
||||
- **provision.py NVS generator API change** — `esp_idf_nvs_partition_gen` package changed its `generate()` signature; switched to subprocess-first invocation for cross-version compatibility.
|
||||
- **QEMU CI pipeline (11 jobs)** — Fixed all failures: fuzz test `esp_timer` stubs, QEMU `libgcrypt` dependency, NVS matrix generator, IDF container `pip` path, flash image padding, validation WARN handling, swarm `ip`/`cargo` missing.
|
||||
|
||||
### Added
|
||||
- **4MB flash support (#265)** — `partitions_4mb.csv` and `sdkconfig.defaults.4mb` for ESP32-S3 boards with 4MB flash (e.g. SuperMini). Dual OTA slots, 1.856 MB each. Thanks to @sebbu for the community workaround that confirmed feasibility.
|
||||
- **`--strict` flag** for `validate_qemu_output.py` — WARNs now pass by default in CI (no real WiFi in QEMU); use `--strict` to fail on warnings.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **QEMU ESP32-S3 testing platform (ADR-061)** — 9-layer firmware testing without hardware
|
||||
- Mock CSI generator with 10 physics-based scenarios (empty room, walking, fall, multi-person, etc.)
|
||||
- Single-node QEMU runner with 16-check UART validation
|
||||
- Multi-node TDM mesh simulation (TAP networking, 2-6 nodes)
|
||||
- GDB remote debugging with VS Code integration
|
||||
- Code coverage via gcov/lcov + apptrace
|
||||
- Fuzz testing (3 libFuzzer targets + ASAN/UBSAN)
|
||||
- NVS provisioning matrix (14 configs)
|
||||
- Snapshot-based regression testing (sub-second VM restore)
|
||||
- Chaos testing with fault injection + health monitoring
|
||||
- **QEMU Swarm Configurator (ADR-062)** — YAML-driven multi-ESP32 test orchestration
|
||||
- 4 topologies: star, mesh, line, ring
|
||||
- 3 node roles: sensor, coordinator, gateway
|
||||
- 9 swarm-level assertions (boot, crashes, TDM, frame rate, fall detection, etc.)
|
||||
- 7 presets: smoke (2n/15s), standard (3n/60s), ci-matrix, large-mesh, line-relay, ring-fault, heterogeneous
|
||||
- Health oracle with cross-node validation
|
||||
- **QEMU installer** (`install-qemu.sh`) — auto-detects OS, installs deps, builds Espressif QEMU fork
|
||||
- **Unified QEMU CLI** (`qemu-cli.sh`) — single entry point for all 11 QEMU test commands
|
||||
- CI: `firmware-qemu.yml` workflow with QEMU test matrix, fuzz testing, NVS validation, and swarm test jobs
|
||||
- User guide: QEMU testing and swarm configurator section with plain-language walkthrough
|
||||
|
||||
### Fixed
|
||||
- Firmware now boots in QEMU: WiFi/UDP/OTA/display guards for mock CSI mode
|
||||
- 9 bugs in mock_csi.c (LFSR bias, MAC filter init, scenario loop, overflow burst timing)
|
||||
- 23 bugs from ADR-061 deep review (inject_fault.py writes, CI cache, snapshot log corruption, etc.)
|
||||
- 16 bugs from ADR-062 deep review (log filename mismatch, SLIRP port collision, heap false positives, etc.)
|
||||
- All scripts: `--help` flags, prerequisite checks with install hints, standardized exit codes
|
||||
|
||||
- **Sensing server UI API completion (ADR-043)** — 14 fully-functional REST endpoints for model management, CSI recording, and training control
|
||||
- Model CRUD: `GET /api/v1/models`, `GET /api/v1/models/active`, `POST /api/v1/models/load`, `POST /api/v1/models/unload`, `DELETE /api/v1/models/:id`, `GET /api/v1/models/lora/profiles`, `POST /api/v1/models/lora/activate`
|
||||
- CSI recording: `GET /api/v1/recording/list`, `POST /api/v1/recording/start`, `POST /api/v1/recording/stop`, `DELETE /api/v1/recording/:id`
|
||||
@@ -769,7 +188,7 @@ Major release: complete Rust sensing server, full DensePose training pipeline, R
|
||||
- `PresenceClassifier` — rule-based 3-state classification (ABSENT / PRESENT_STILL / ACTIVE)
|
||||
- Cross-receiver agreement scoring for multi-AP confidence boosting
|
||||
- WebSocket sensing server (`ws_server.py`) broadcasting JSON at 2 Hz
|
||||
- Deterministic CSI proof bundles for reproducible verification (`archive/v1/data/proof/`)
|
||||
- Deterministic CSI proof bundles for reproducible verification (`v1/data/proof/`)
|
||||
- Commodity sensing unit tests (`b391638`)
|
||||
|
||||
### Changed
|
||||
@@ -777,7 +196,7 @@ Major release: complete Rust sensing server, full DensePose training pipeline, R
|
||||
|
||||
### Fixed
|
||||
- Review fixes for end-to-end training pipeline (`45f0304`)
|
||||
- Dockerfile paths updated from `src/` to `archive/v1/src/` (`7872987`)
|
||||
- Dockerfile paths updated from `src/` to `v1/src/` (`7872987`)
|
||||
- IoT profile installer instructions updated for aggregator CLI (`f460097`)
|
||||
- `process.env` reference removed from browser ES module (`e320bc9`)
|
||||
|
||||
|
||||
@@ -3,25 +3,25 @@
|
||||
## Project: wifi-densepose
|
||||
|
||||
WiFi-based human pose estimation using Channel State Information (CSI).
|
||||
Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
|
||||
Dual codebase: Python v1 (`v1/`) and Rust port (`rust-port/wifi-densepose-rs/`).
|
||||
### Key Rust Crates
|
||||
| Crate | Description |
|
||||
|-------|-------------|
|
||||
| `wifi-densepose-core` | Core types, traits, error types, CSI frame primitives |
|
||||
| `wifi-densepose-signal` | SOTA signal processing + RuvSense multistatic sensing (16 modules) |
|
||||
| `wifi-densepose-signal` | SOTA signal processing + RuvSense multistatic sensing (14 modules) |
|
||||
| `wifi-densepose-nn` | Neural network inference (ONNX, PyTorch, Candle backends) |
|
||||
| `wifi-densepose-train` | Training pipeline with ruvector integration + ruview_metrics |
|
||||
| `wifi-densepose-mat` | Mass Casualty Assessment Tool — disaster survivor detection |
|
||||
| `wifi-densepose-hardware` | ESP32 aggregator, TDM protocol, channel hopping firmware |
|
||||
| `wifi-densepose-ruvector` | RuVector v2.0.4 integration + cross-viewpoint fusion (5 modules) |
|
||||
| `wifi-densepose-api` | REST API (Axum) |
|
||||
| `wifi-densepose-db` | Database layer (Postgres, SQLite, Redis) |
|
||||
| `wifi-densepose-config` | Configuration management |
|
||||
| `wifi-densepose-wasm` | WebAssembly bindings for browser deployment |
|
||||
| `wifi-densepose-cli` | CLI tool (`wifi-densepose` binary) |
|
||||
| `wifi-densepose-sensing-server` | Lightweight Axum server for WiFi sensing UI |
|
||||
| `wifi-densepose-wifiscan` | Multi-BSSID WiFi scanning (ADR-022) |
|
||||
| `wifi-densepose-vitals` | ESP32 CSI-grade vital sign extraction (ADR-021) |
|
||||
| `nvsim` | Deterministic NV-diamond magnetometer pipeline simulator (ADR-089) — standalone leaf, WASM-ready |
|
||||
| `vendor/rvcsi` (submodule) | **rvCSI** — edge RF sensing runtime (ADR-095/096): 9 crates (`rvcsi-core`/`-dsp`/`-events`/`-adapter-file`/`-adapter-nexmon`/`-ruvector`/`-runtime`/`-node`/`-cli`). Lives in its own repo ([github.com/ruvnet/rvcsi](https://github.com/ruvnet/rvcsi)), vendored here under `vendor/rvcsi`, published to crates.io as `rvcsi-* 0.3.x` and to npm as `@ruv/rvcsi`. Not a `v2/` workspace member — depend on the published crates (or the submodule's `crates/rvcsi-*` paths). Normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, validate-before-FFI, reusable DSP, typed confidence-scored events, the napi-c Nexmon shim (real nexmon_csi `.pcap` from a Raspberry Pi 5 / 4 / 3B+ — BCM43455c0), the napi-rs SDK, the `rvcsi` CLI, a Claude Code plugin. |
|
||||
| `ruview-swarm` | Drone swarm control system (ADR-148) — hierarchical-mesh topology, Raft consensus, MARL, CSI sensing payload, MAVLink/PX4 compat, Ruflo AI-agent integration |
|
||||
|
||||
### RuvSense Modules (`signal/src/ruvsense/`)
|
||||
| Module | Purpose |
|
||||
@@ -39,8 +39,6 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
|
||||
| `cross_room.rs` | Environment fingerprinting, transition graph |
|
||||
| `gesture.rs` | DTW template matching gesture classifier |
|
||||
| `adversarial.rs` | Physically impossible signal detection, multi-link consistency |
|
||||
| `cir.rs` | ADR-134 CSI→CIR via ISTA L1 sparse recovery (NeumannSolver warm-start) |
|
||||
| `calibration.rs` | ADR-135 empty-room baseline (Welford amplitude + von Mises phase, drift trigger) |
|
||||
|
||||
### Cross-Viewpoint Fusion (`ruvector/src/viewpoint/`)
|
||||
| Module | Purpose |
|
||||
@@ -71,79 +69,45 @@ All 5 ruvector crates integrated in workspace:
|
||||
- ADR-030: RuvSense persistent field model (Proposed)
|
||||
- ADR-031: RuView sensing-first RF mode (Proposed)
|
||||
- ADR-032: Multistatic mesh security hardening (Proposed)
|
||||
- ADR-148: Drone swarm control system / `ruview-swarm` (In Progress)
|
||||
|
||||
### Supported Hardware
|
||||
|
||||
| Device | Port | Chip | Role | Cost |
|
||||
|--------|------|------|------|------|
|
||||
| ESP32-S3 (8MB flash) | COM9 (ruvzen, was COM7) | Xtensa dual-core | WiFi CSI sensing node | ~$9 |
|
||||
| ESP32-S3 SuperMini (4MB) | — | Xtensa dual-core | WiFi CSI (compact) | ~$6 |
|
||||
| ESP32-C6 + Seeed MR60BHA2 | COM12 (ruvzen, was COM4) | RISC-V + 60 GHz FMCW | mmWave HR/BR/presence + WiFi CSI | ~$15 |
|
||||
| HLK-LD2410 | — | 24 GHz FMCW | Presence + distance | ~$3 |
|
||||
|
||||
**Not supported:** ESP32 (original), ESP32-C3 — single-core, can't run CSI DSP pipeline.
|
||||
|
||||
### Build & Test Commands (this repo)
|
||||
```bash
|
||||
# Rust — full workspace tests (1,031+ tests, ~2 min)
|
||||
cd v2
|
||||
cd rust-port/wifi-densepose-rs
|
||||
cargo test --workspace --no-default-features
|
||||
|
||||
# Rust — single crate check (no GPU needed)
|
||||
cargo check -p wifi-densepose-train --no-default-features
|
||||
|
||||
# Rust — publish crates (dependency order)
|
||||
cargo publish -p wifi-densepose-core --no-default-features
|
||||
cargo publish -p wifi-densepose-signal --no-default-features
|
||||
# ... see crate publishing order below
|
||||
|
||||
# Python — deterministic proof verification (SHA-256)
|
||||
python archive/v1/data/proof/verify.py
|
||||
python v1/data/proof/verify.py
|
||||
|
||||
# Python — test suite
|
||||
cd archive/v1 && python -m pytest tests/ -x -q
|
||||
cd v1 && python -m pytest tests/ -x -q
|
||||
```
|
||||
|
||||
### ESP32 Firmware Build (Windows — Python subprocess required)
|
||||
```bash
|
||||
# Build 8MB firmware (real WiFi CSI mode, no mocks)
|
||||
# See CLAUDE.local.md for the full Python subprocess command
|
||||
# Key: must strip MSYSTEM env vars for ESP-IDF v5.4 on Git Bash
|
||||
|
||||
# Build 4MB firmware
|
||||
cp sdkconfig.defaults.4mb sdkconfig.defaults
|
||||
# then same build process
|
||||
|
||||
# Flash to COM7
|
||||
# [python, idf_py, '-p', 'COM7', 'flash']
|
||||
|
||||
# Provision WiFi
|
||||
python firmware/esp32-csi-node/provision.py --port COM7 \
|
||||
--ssid "YourWiFi" --password "secret" --target-ip 192.168.1.20
|
||||
|
||||
# Monitor serial
|
||||
python -m serial.tools.miniterm COM7 115200
|
||||
```
|
||||
|
||||
### Firmware Release Process
|
||||
1. Build 8MB from `sdkconfig.defaults.template` (no mock)
|
||||
2. Build 4MB from `sdkconfig.defaults.4mb` (no mock)
|
||||
3. Save 6 binaries: `esp32-csi-node.bin`, `bootloader.bin`, `partition-table.bin`, `ota_data_initial.bin`, `esp32-csi-node-4mb.bin`, `partition-table-4mb.bin`
|
||||
4. Tag: `git tag v0.X.Y-esp32 && git push origin v0.X.Y-esp32`
|
||||
5. Release: `gh release create v0.X.Y-esp32 <binaries> --title "..." --notes-file ...`
|
||||
6. Verify on real hardware (COM7) before publishing
|
||||
7. **CRITICAL:** Always test with real WiFi CSI, not mock mode — mock missed the Kconfig threshold bug
|
||||
|
||||
### Crate Publishing Order
|
||||
Crates must be published in dependency order:
|
||||
1. `wifi-densepose-core` (no internal deps)
|
||||
2. `wifi-densepose-vitals` (no internal deps)
|
||||
3. `wifi-densepose-wifiscan` (no internal deps)
|
||||
4. `wifi-densepose-hardware` (no internal deps)
|
||||
5. `wifi-densepose-signal` (depends on core)
|
||||
6. `wifi-densepose-nn` (no internal deps, workspace only)
|
||||
7. `wifi-densepose-ruvector` (no internal deps, workspace only)
|
||||
8. `wifi-densepose-train` (depends on signal, nn)
|
||||
9. `wifi-densepose-mat` (depends on core, signal, nn)
|
||||
10. `wifi-densepose-wasm` (depends on mat)
|
||||
11. `wifi-densepose-sensing-server` (depends on wifiscan)
|
||||
12. `wifi-densepose-cli` (depends on mat)
|
||||
5. `wifi-densepose-config` (no internal deps)
|
||||
6. `wifi-densepose-db` (no internal deps)
|
||||
7. `wifi-densepose-signal` (depends on core)
|
||||
8. `wifi-densepose-nn` (no internal deps, workspace only)
|
||||
9. `wifi-densepose-ruvector` (no internal deps, workspace only)
|
||||
10. `wifi-densepose-train` (depends on signal, nn)
|
||||
11. `wifi-densepose-mat` (depends on core, signal, nn)
|
||||
12. `wifi-densepose-api` (no internal deps)
|
||||
13. `wifi-densepose-wasm` (depends on mat)
|
||||
14. `wifi-densepose-sensing-server` (depends on wifiscan)
|
||||
15. `wifi-densepose-cli` (depends on mat)
|
||||
|
||||
### Validation & Witness Verification (ADR-028)
|
||||
|
||||
@@ -151,12 +115,12 @@ Crates must be published in dependency order:
|
||||
|
||||
```bash
|
||||
# 1. Rust tests — must be 1,031+ passed, 0 failed
|
||||
cd v2
|
||||
cd rust-port/wifi-densepose-rs
|
||||
cargo test --workspace --no-default-features
|
||||
|
||||
# 2. Python proof — must print VERDICT: PASS
|
||||
cd ..
|
||||
python archive/v1/data/proof/verify.py
|
||||
cd ../..
|
||||
python v1/data/proof/verify.py
|
||||
|
||||
# 3. Generate witness bundle (includes both above + firmware hashes)
|
||||
bash scripts/generate-witness-bundle.sh
|
||||
@@ -169,8 +133,8 @@ bash VERIFY.sh
|
||||
**If the Python proof hash changes** (e.g., numpy/scipy version update):
|
||||
```bash
|
||||
# Regenerate the expected hash, then verify it passes
|
||||
python archive/v1/data/proof/verify.py --generate-hash
|
||||
python archive/v1/data/proof/verify.py
|
||||
python v1/data/proof/verify.py --generate-hash
|
||||
python v1/data/proof/verify.py
|
||||
```
|
||||
|
||||
**Witness bundle contents** (`dist/witness-bundle-ADR028-<sha>.tar.gz`):
|
||||
@@ -183,9 +147,9 @@ python archive/v1/data/proof/verify.py
|
||||
- `VERIFY.sh` — One-command self-verification for recipients
|
||||
|
||||
**Key proof artifacts:**
|
||||
- `archive/v1/data/proof/verify.py` — Trust Kill Switch: feeds reference signal through production pipeline, hashes output
|
||||
- `archive/v1/data/proof/expected_features.sha256` — Published expected hash
|
||||
- `archive/v1/data/proof/sample_csi_data.json` — 1,000 synthetic CSI frames (seed=42)
|
||||
- `v1/data/proof/verify.py` — Trust Kill Switch: feeds reference signal through production pipeline, hashes output
|
||||
- `v1/data/proof/expected_features.sha256` — Published expected hash
|
||||
- `v1/data/proof/sample_csi_data.json` — 1,000 synthetic CSI frames (seed=42)
|
||||
- `docs/WITNESS-LOG-028.md` — 11-step reproducible verification procedure
|
||||
- `docs/adr/ADR-028-esp32-capability-audit.md` — Complete audit record
|
||||
|
||||
@@ -211,13 +175,13 @@ Active feature branch: `ruvsense-full-implementation` (PR #77)
|
||||
- NEVER save to root folder — use the directories below
|
||||
- `docs/adr/` — Architecture Decision Records (43 ADRs)
|
||||
- `docs/ddd/` — Domain-Driven Design models
|
||||
- `v2/crates/` — Rust workspace crates (15 crates)
|
||||
- `v2/crates/wifi-densepose-signal/src/ruvsense/` — RuvSense multistatic modules (14 files)
|
||||
- `v2/crates/wifi-densepose-ruvector/src/viewpoint/` — Cross-viewpoint fusion (5 files)
|
||||
- `v2/crates/wifi-densepose-hardware/src/esp32/` — ESP32 TDM protocol
|
||||
- `rust-port/wifi-densepose-rs/crates/` — Rust workspace crates (15 crates)
|
||||
- `rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/` — RuvSense multistatic modules (14 files)
|
||||
- `rust-port/wifi-densepose-rs/crates/wifi-densepose-ruvector/src/viewpoint/` — Cross-viewpoint fusion (5 files)
|
||||
- `rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/src/esp32/` — ESP32 TDM protocol
|
||||
- `firmware/esp32-csi-node/main/` — ESP32 C firmware (channel hopping, NVS config, TDM)
|
||||
- `archive/v1/src/` — Python source (core, hardware, services, api)
|
||||
- `archive/v1/data/proof/` — Deterministic CSI proof bundles
|
||||
- `v1/src/` — Python source (core, hardware, services, api)
|
||||
- `v1/data/proof/` — Deterministic CSI proof bundles
|
||||
- `.claude-flow/` — Claude Flow coordination state (committed for team sharing)
|
||||
- `.claude/` — Claude Code settings, agents, memory (committed for team sharing)
|
||||
|
||||
@@ -243,7 +207,7 @@ Active feature branch: `ruvsense-full-implementation` (PR #77)
|
||||
Before merging any PR, verify each item applies and is addressed:
|
||||
|
||||
1. **Rust tests pass** — `cargo test --workspace --no-default-features` (1,031+ passed, 0 failed)
|
||||
2. **Python proof passes** — `python archive/v1/data/proof/verify.py` (VERDICT: PASS)
|
||||
2. **Python proof passes** — `python v1/data/proof/verify.py` (VERDICT: PASS)
|
||||
3. **README.md** — Update platform tables, crate descriptions, hardware tables, feature summaries if scope changed
|
||||
4. **CLAUDE.md** — Update crate table, ADR list, module tables, version if scope changed
|
||||
5. **CHANGELOG.md** — Add entry under `[Unreleased]` with what was added/fixed/changed
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# AetherArena ("AA") — The Official Spatial-Intelligence Benchmark
|
||||
|
||||
> **Public leaderboard. Private evaluation split. Open scorer. Signed results.**
|
||||
|
||||
AetherArena is a **standalone, project-agnostic benchmark** for camera-free **spatial intelligence** — pose, presence, occupancy, tracking, and vitals from RF/WiFi (and, over time, mmWave / UWB / radar / lidar / multimodal). It is **not** a single-vendor leaderboard: any team, framework, or sensing modality can enter, and every entrant — including the RuView baseline that donated the seed scorer — is scored by the identical, open, pinned harness.
|
||||
|
||||
Specified in [ADR-149](../docs/adr/ADR-149-public-community-leaderboard-huggingface.md) (Accepted).
|
||||
|
||||
Canonical home: **`ruvnet/aether-arena`** + a Hugging Face Space (deploy pending — see `STATUS`).
|
||||
|
||||
---
|
||||
|
||||
## Why
|
||||
|
||||
WiFi/RF spatial sensing has no shared yardstick — papers self-report against inconsistent splits and metrics, with **no accounting for latency, reproducibility, or privacy leakage**. AA fixes the *measurement*, not just the models: a single deterministic scorer, a private held-out split nobody can train on, and a signed result ledger that can't be silently edited.
|
||||
|
||||
## What gets measured (v0)
|
||||
|
||||
| Category | Metric | Status |
|
||||
|----------|--------|--------|
|
||||
| **Pose** | PCK@0.2 (all / torso), OKS | Ranked |
|
||||
| **Presence** | accuracy, FP/FN | Ranked |
|
||||
| **Edge latency** | p50 / p95 / p99 ms | Ranked |
|
||||
| **Determinism** | proof-hash pass/fail | Ranked (gate) |
|
||||
| Tracking (MOTA) | — | activates when multi-person clips land |
|
||||
| Vitals (BPM err) | — | activates when paired vitals ground truth lands |
|
||||
| **Privacy leakage** | membership-inference ∈ [0,1] | **gated — not ranked** until the attacker ships |
|
||||
| Cross-room | degradation ratio | coming soon |
|
||||
|
||||
The headline rank is the **category metric**; an optional `arena_score = quality × latency_factor × privacy_factor × determinism_gate` is exposed alongside (never instead) so accuracy can't win at any cost. See ADR-149 §2.5.
|
||||
|
||||
## How scoring works
|
||||
|
||||
The scorer is RuView's **already-published** `wifi-densepose-train` acceptance harness (`ruview_metrics` + ADR-145 `ablation`), run in a pinned sandbox. **You submit a model, not predictions** — predictions on data you hold prove nothing. Your model is scored against a **private** MM-Fi held-out split (CC BY-NC 4.0; Wi-Pose excluded for redistribution reasons), and one **signed, append-only** row is written to the results ledger with a determinism proof hash.
|
||||
|
||||
Submission lifecycle: `submitted → validated → quarantined → smoke_scored → full_scored → published` (or `rejected` with a reason). The model only ever runs inside a no-network, read-only-FS sandbox.
|
||||
|
||||
## Submit (when the Space is live)
|
||||
|
||||
1. Write a manifest: [`schema/aa-submission.toml`](schema/aa-submission.toml).
|
||||
2. Push your model artifact (`.safetensors` / `.rvf` / LoRA adapter) + manifest to the Space.
|
||||
3. Watch it move through the lifecycle; your signed row appears on the board.
|
||||
|
||||
## Verify it's fair (you don't have to trust us)
|
||||
|
||||
See [`VERIFY.md`](VERIFY.md) — run the **open scorer** locally on the **public smoke split**, reproduce the determinism hash, and confirm RuView's own entries were scored by the identical path. That five-step check is the launch gate (ADR-149 §7).
|
||||
|
||||
## Neutrality
|
||||
|
||||
AA is a neutral commons. The scorer is open and versioned; any metric change is a public `harness_version` bump that **re-scores all entries**. RuView donated the seed harness and enters as one baseline — it gets no special treatment (ADR-149 §2.8).
|
||||
@@ -1,30 +0,0 @@
|
||||
# AetherArena — Build Status
|
||||
|
||||
Tracks ADR-149 implementation milestones. "Complete" = benchmark **infrastructure** done,
|
||||
tested, CI-gated, deploy-ready, RuView baseline entered, §7 acceptance test passing.
|
||||
Model **SOTA** (e.g. MM-Fi PCK@20 ~72%) is a separate long-running ML effort, blocked on
|
||||
ADR-079 camera-ground-truth collection — *not* an infra-completion blocker.
|
||||
|
||||
| # | Milestone | Status |
|
||||
|---|-----------|--------|
|
||||
| M1 | ADR-149 Accepted + committed | ✅ done |
|
||||
| M2 | Scorer runner (`aa_score_runner`) — **real model scoring** + witness (proof+inputs hash) + **repeatability analysis** | ✅ done — builds `--no-default-features`, determinism gate PASS, repeatable 16/16 |
|
||||
| M3 | CI harness-gate workflow (PR runs scorer + repeatability + real-scoring smoke + ledger verify) | ✅ done — `.github/workflows/aether-arena-harness.yml` |
|
||||
| M4 | Scaffold: README + submission schema + VERIFY (acceptance test) | ✅ done |
|
||||
| M5 | Public smoke split (committed) + private MM-Fi held-out split prep | 🟡 smoke split done (`fixtures/smoke_*.json`); private MM-Fi prep pending |
|
||||
| M6 | HF Space (Gradio) — leaderboard + ledger integrity + submit/verify/about | ✅ deployed → https://huggingface.co/spaces/ruvnet/aether-arena (sandboxed scorer container = later hardening) |
|
||||
| M7 | **Witness ledger chain** — append-only, hash-chained, tamper-evident | ✅ done — `ledger/ledger_tools.py` (seed/append/verify); tamper test fails as designed |
|
||||
| M8 | Public launch | ✅ Space **LIVE** (gradio 5.9.1, serving 200) — **board empty, awaiting first real harness score** (benchmark-first: no seeded numbers) |
|
||||
|
||||
## v0 infrastructure: COMPLETE
|
||||
Implement ✅ · Test ✅ · Deploy to HF ✅ (https://huggingface.co/spaces/ruvnet/aether-arena) · Instructions+Verification ✅ · PR runs the harness ✅ (PR #874, AA harness gate **passed**).
|
||||
Remaining = data + hardening, not infra: private MM-Fi held-out split (M5), sandboxed scorer container (M6), privacy-leakage attacker (gated category), and **model SOTA** (separate ML effort, blocked on ADR-079 — explicitly not an infra exit).
|
||||
|
||||
## Benchmark-first posture (per user direction)
|
||||
- **No placeholder numbers on the board.** The ledger seeds to genesis only; every result is a real scoring-pipeline witness. RuView gets no seeded baseline.
|
||||
- **Witness chain** = `inputs_sha256` (binds witness to exact inputs) + `proof_sha256` (cross-platform-stable score hash) + the append-only hash-chained ledger. Repeatability analysis (`--repeat N`) proves the proof hash is identical across runs.
|
||||
|
||||
## Blockers / decisions needed
|
||||
- **HF deploy (M6)** — token is in GCP Secret Manager (`HUGGINGFACE_API_KEY`); creating the public `ruvnet/aether-arena` Space still wants explicit go.
|
||||
- **MM-Fi is CC BY-NC** → AA must stay non-commercial / legally distinct from the commercial RuView product.
|
||||
- **Private MM-Fi split (M5)** — needs the dataset pulled + a held-out split assembled before real public scoring replaces the smoke fixture.
|
||||
@@ -1,78 +0,0 @@
|
||||
# Verifying AetherArena (you don't have to trust us)
|
||||
|
||||
AA's credibility rests on a stranger being able to reproduce a score and see that the rules are fair. This is the **launch gate** (ADR-149 §7): v0 does not ship until all five checks below pass for someone with no insider access.
|
||||
|
||||
> **Wider context:** this page covers the *leaderboard scorer*. For the whole-platform answer to
|
||||
> "is this real / does it actually work?" — including the deterministic pipeline proof, the
|
||||
> published models + public-benchmark numbers, and the built-in-public development trail — see
|
||||
> [`docs/proof-of-capabilities.md`](../docs/proof-of-capabilities.md).
|
||||
|
||||
## The open scorer
|
||||
|
||||
The scoring engine is a pure-Rust, GPU-free binary: `aa_score_runner` in `wifi-densepose-train`. It runs the real `ruview_metrics` pose-acceptance harness on a fixed fixture and emits a cross-platform-stable SHA-256 **determinism proof**.
|
||||
|
||||
### Reproduce the determinism hash locally
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
# Verify the committed expected hash still matches (this is the CI gate):
|
||||
cargo run -q -p wifi-densepose-train --bin aa_score_runner --no-default-features
|
||||
# → prints the witness (inputs_sha256 + proof_sha256) and "VERDICT: PASS"
|
||||
|
||||
# See the witness row as JSON:
|
||||
cargo run -q -p wifi-densepose-train --bin aa_score_runner --no-default-features -- --json
|
||||
```
|
||||
|
||||
### Witness chain — proof + repeatability analysis
|
||||
|
||||
Every score is a **witness**: `inputs_sha256` (binds it to the exact inputs scored)
|
||||
+ `proof_sha256` (cross-platform-stable hash of the quantised score) + `harness_version`.
|
||||
Witnesses are recorded in an **append-only, hash-chained ledger** (each row references
|
||||
the previous row's hash), so a silent edit to any past row breaks the chain.
|
||||
|
||||
```bash
|
||||
# Repeatability: run the scorer K times, confirm ONE identical proof hash:
|
||||
cd v2
|
||||
cargo run -q -p wifi-densepose-train --bin aa_score_runner --no-default-features -- --repeat 16
|
||||
# → {"repeatability":{"runs":16,"unique_proof_hashes":1,"repeatable":true,...}}
|
||||
|
||||
# Real model scoring (score predictions against an eval split):
|
||||
cargo run -q -p wifi-densepose-train --bin aa_score_runner --no-default-features -- \
|
||||
--split ../aether-arena/fixtures/smoke_split.json \
|
||||
--pred ../aether-arena/fixtures/smoke_pred.json --json
|
||||
|
||||
# Verify the witness ledger chain is intact (tamper-evident):
|
||||
cd ../aether-arena/ledger && python3 ledger_tools.py verify
|
||||
# → "OK: N rows, chain intact" (edit any row and it reports the broken link)
|
||||
```
|
||||
|
||||
The expected hash is committed at [`fixtures/expected_score.sha256`](fixtures/expected_score.sha256). Same harness version + same fixture → same hash on glibc / MSVC / Apple. If your local run prints `VERDICT: PASS`, you have reproduced the scorer.
|
||||
|
||||
### What happens if the scoring maths changes
|
||||
|
||||
Any edit to `ruview_metrics.rs`, `ablation.rs`, or `aa_score_runner.rs` moves the hash and **fails the CI gate** (`.github/workflows/aether-arena-harness.yml`) until the maintainer regenerates and reviews:
|
||||
|
||||
```bash
|
||||
cargo run -p wifi-densepose-train --bin aa_score_runner --no-default-features -- --generate-hash \
|
||||
> aether-arena/fixtures/expected_score.sha256
|
||||
```
|
||||
|
||||
So a scorer change is always a reviewed, public diff — never silent. That's `harness_version` pinning + `determinism_gate` in action (ADR-149 §2.4–§2.5).
|
||||
|
||||
## The five-step acceptance test (v0 launch gate)
|
||||
|
||||
A stranger must be able to:
|
||||
|
||||
1. **Submit** a model (artifact + `schema/aa-submission.toml`) with no insider help.
|
||||
2. **Get a deterministic score** — same model + same `harness_version` → same numbers.
|
||||
3. **See the signed row** appended to the public results ledger.
|
||||
4. **Rerun the scorer locally** on the public smoke split and reproduce the logic (the command above).
|
||||
5. **Understand why the rank is fair** — private split, open scorer, pinned version, proof hash — from these docs alone.
|
||||
|
||||
If any step fails, v0 is not ready.
|
||||
|
||||
## Current status
|
||||
|
||||
- ✅ Step 4 (rerun the open scorer locally, reproduce the hash) — **works today** via `aa_score_runner`.
|
||||
- ✅ CI harness gate runs the scorer on every PR.
|
||||
- ⏳ Steps 1–3, 5 (HF Space submission flow + signed ledger) — in progress; require the HF Space deploy (needs an HF token / maintainer authorization).
|
||||
@@ -1,87 +0,0 @@
|
||||
# RuView Calibration Service (reference implementation)
|
||||
|
||||
Turn a **shared WiFi-CSI pose base model** into a room-specific one with a **30-second labeled
|
||||
calibration** and a **~11 KB per-room LoRA adapter**. This is the deployable resolution of the
|
||||
cross-subject / cross-environment generalization problem (full study: [ADR-150 §3.3–3.6](../../docs/adr/ADR-150-rf-foundation-encoder.md)).
|
||||
|
||||
## Why
|
||||
|
||||
Zero-shot WiFi pose generalizes poorly to a **new room or new person** — an unseen room can drop a
|
||||
strong model to near-random. But that gap is **not** algorithmically closeable (CORAL, DANN,
|
||||
instance-norm, contrastive foundation-pretraining all failed) and **not** closeable by collecting
|
||||
more subjects (saturates ~64%). It **is** closeable, cheaply, at deployment time: a handful of
|
||||
labeled frames from the actual room pin down its multipath instantly.
|
||||
|
||||
| Deployment case | Zero-shot | + in-room calibration |
|
||||
|-----------------|----------:|----------------------:|
|
||||
| Same room, new person (cross-subject) | 64% | **76%** (200 samples) |
|
||||
| **New room + new person (cross-environment)** | **~10%** | **60% @ 5 samples → 73% @ 200** |
|
||||
|
||||
**Verified demo (this code, source-only base on an unseen MM-Fi room E04):**
|
||||
`zero-shot 3.09% → after 200-sample calibration 74.29%` (+71 pts).
|
||||
|
||||
## How it works
|
||||
|
||||
A frozen shared **base** (transformer + temporal attention pool + skeleton-graph head, the published
|
||||
[`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose)) plus a
|
||||
tiny **LoRA adapter** (rank 8 on the input projection + pose head — **11,200 params ≈ 11 KB int8 /
|
||||
22 KB fp16**) fitted per room. Thousands of room-adapters hang off one base.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# 1) Capture a short labeled clip in the deployment room -> calib.npz {X:[N,3,114,10], Y:[N,17,2]}
|
||||
# (~100–200 samples recommended; below ~20 the adapter can underperform zero-shot)
|
||||
|
||||
# 2) Fit the per-room adapter (~11 KB):
|
||||
python calibrate.py --base pose_mmfi_best.pt --data calib.npz --out room.adapter.npz
|
||||
|
||||
# 3) Run calibrated inference (base + room adapter):
|
||||
python infer.py --base pose_mmfi_best.pt --adapter room.adapter.npz --data frames.npz --out kp.npy
|
||||
# omit --adapter to run the uncalibrated (zero-shot) base
|
||||
```
|
||||
|
||||
`X` is CSI amplitude `[N, 3 antennas, 114 subcarriers, 10 frames]` (per-sample standardization is
|
||||
applied internally). `Y` is `[N,17,2]` COCO keypoints in `[0,1]`.
|
||||
|
||||
## Calibration budget (measured, rank-8 LoRA, 3 seeds — ADR-150 §3.5)
|
||||
|
||||
| Labeled samples/room | cross-subject | cross-environment |
|
||||
|---------------------:|--------------:|------------------:|
|
||||
| 0 (zero-shot) | 64% | ~10% |
|
||||
| 5 | — | 60% |
|
||||
| 20 | 66% | 66% |
|
||||
| 50 | 70% | 70% |
|
||||
| 200 | 72% | 73% |
|
||||
|
||||
Knee at ~50 samples (~70%); **below ~20 samples the adapter can hurt** (too few to fit reliably).
|
||||
|
||||
## Two models, two producers (not interchangeable)
|
||||
|
||||
Adapters are **model-specific**. There are two calibration producers here:
|
||||
|
||||
| Producer | Target model | Input | Adapter format | Consumer |
|
||||
|----------|--------------|-------|----------------|----------|
|
||||
| `calibrate.py` | MM-Fi **transformer** (`pose_mmfi_best.pt`, 3×114×10) | `[N,3,114,10]` | `.npz` (`proj`/`head` LoRA) | this Python `infer.py` |
|
||||
| `cog_calibrate.py` | cog **conv+MLP** (`pose_v1.safetensors`, 56×20) | `[N,56,20]` | `.safetensors` (`fc1.a`/`fc1.b`/`fc2.a`/`fc2.b`) | Rust `cog-pose-estimation run --adapter` |
|
||||
|
||||
```bash
|
||||
# Produce a cog-format per-room adapter for the deployed Rust pose engine:
|
||||
python cog_calibrate.py --base pose_v1.safetensors --data calib.npz --out room.safetensors
|
||||
# then in the cog runtime:
|
||||
cog-pose-estimation run --config <cfg> --adapter room.safetensors
|
||||
```
|
||||
|
||||
Same LoRA *mechanism* (ADR-150 §3.5), different architecture and key layout — an adapter from one
|
||||
producer will not load into the other model.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Calibration only helps when the base hasn't already seen the room.** The published flagship was
|
||||
trained on MM-Fi `random_split`, so calibrating it on an MM-Fi subject is a near-no-op (it already
|
||||
saw them); for a genuinely new real-world room it is zero-shot and calibration applies. To
|
||||
*reproduce the demo* on a held-out MM-Fi room, train a source-only base (exclude the target
|
||||
environment) — see `ADR-150 §3.6` and the few-shot harness in `aether-arena/staging/`.
|
||||
- Adapter is saved fp16 (~22 KB); quantize to int8 for the ~11 KB on-device form.
|
||||
- Inference is real-time on CPU (the 75 K-param `micro` variant runs in 0.135 ms single-thread x86;
|
||||
see [`docs/benchmarks/wifi-pose-efficiency-frontier.md`](../../docs/benchmarks/wifi-pose-efficiency-frontier.md)).
|
||||
@@ -1,71 +0,0 @@
|
||||
"""RuView per-room calibration — fit a ~11 KB LoRA adapter from a short labeled in-room capture.
|
||||
|
||||
python calibrate.py --base pose_mmfi_best.pt --data room_calib.npz --out room_A.adapter.npz
|
||||
|
||||
`room_calib.npz` must contain `X` [N,3,114,10] CSI amplitude and `Y` [N,17,2] (or [N,34]) keypoints
|
||||
in [0,1] — the labeled calibration samples from the deployment room (~100–200 recommended; ≥20).
|
||||
Outputs a tiny adapter (.npz, ~11 KB) that, loaded over the shared base at inference, recovers
|
||||
SOTA-level pose for that room/person (ADR-150 §3.5–3.6).
|
||||
"""
|
||||
import argparse
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
from model import PoseNet, standardize
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--base", required=True, help="base checkpoint (pose_mmfi_best.pt)")
|
||||
ap.add_argument("--data", required=True, help="labeled calibration .npz with X and Y")
|
||||
ap.add_argument("--out", required=True, help="output adapter .npz")
|
||||
ap.add_argument("--rank", type=int, default=8)
|
||||
ap.add_argument("--iters", type=int, default=600)
|
||||
ap.add_argument("--lr", type=float, default=8e-4)
|
||||
ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
|
||||
a = ap.parse_args()
|
||||
|
||||
z = np.load(a.data)
|
||||
X = torch.tensor(z["X"].astype(np.float32))
|
||||
Y = torch.tensor(z["Y"].reshape(len(z["Y"]), 34).astype(np.float32))
|
||||
n = len(X)
|
||||
if n < 20:
|
||||
print(f"WARNING: only {n} calibration samples — below ~20 the adapter may underperform "
|
||||
f"zero-shot (ADR-150 §3.5). Recommend ~100–200.")
|
||||
dev = a.device
|
||||
|
||||
net = PoseNet().to(dev)
|
||||
net.load_state_dict(torch.load(a.base, map_location=dev), strict=False)
|
||||
net.add_lora(r=a.rank).to(dev)
|
||||
for k, p in net.named_parameters():
|
||||
p.requires_grad = k.endswith(".A") or k.endswith(".B")
|
||||
trainable = [p for p in net.parameters() if p.requires_grad]
|
||||
n_tr = sum(p.numel() for p in trainable)
|
||||
|
||||
Xs = standardize(X.to(dev))
|
||||
Yt = Y.to(dev)
|
||||
opt = torch.optim.AdamW(trainable, lr=a.lr, weight_decay=0.0)
|
||||
lossf = nn.SmoothL1Loss(beta=0.1)
|
||||
bs = min(128, n)
|
||||
net.train()
|
||||
for it in range(a.iters):
|
||||
bi = torch.randint(0, n, (bs,), device=dev)
|
||||
xb = Xs[bi]
|
||||
# light augmentation (subcarrier dropout + noise) — matches training-time regularization
|
||||
m = (torch.rand(xb.shape[0], xb.shape[1], 1, 1, device=dev) > 0.15).float()
|
||||
xb = xb * m + 0.03 * torch.randn_like(xb) * torch.rand(xb.shape[0], 1, 1, 1, device=dev)
|
||||
opt.zero_grad()
|
||||
lossf(net(xb), Yt[bi]).backward()
|
||||
opt.step()
|
||||
|
||||
adapter = net.lora_state()
|
||||
nbytes = sum(v.astype(np.float16).nbytes for v in adapter.values())
|
||||
np.savez(a.out, **{k: v.astype(np.float16) for k, v in adapter.items()},
|
||||
_meta=np.array([a.rank, n, n_tr], dtype=np.int64))
|
||||
print(f"saved {a.out} | rank {a.rank} | {n_tr:,} params | ~{nbytes/1024:.1f} KB fp16 | "
|
||||
f"from {n} labeled samples")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,120 +0,0 @@
|
||||
"""Per-room calibration producer for the cog-pose-estimation **conv+MLP** model
|
||||
(`pose_v1.safetensors`, 56 subcarriers x 20 frames). Companion to `calibrate.py`
|
||||
(which targets the MM-Fi *transformer* model) — different model, different adapter
|
||||
key layout, NOT interchangeable (ADR-150 §3.5).
|
||||
|
||||
Fits a rank-r LoRA on the pose head (fc1, fc2) from a short labeled in-room capture and
|
||||
writes a **safetensors** adapter with keys `fc1.a`/`fc1.b`/`fc2.a`/`fc2.b` (scale baked
|
||||
into `b`) — exactly what `cog-pose-estimation run --adapter <file>` consumes.
|
||||
|
||||
python cog_calibrate.py --base pose_v1.safetensors --data calib.npz --out room.safetensors
|
||||
|
||||
`calib.npz`: `X` [N,56,20] CSI window + `Y` [N,17,2] (or [N,34]) keypoints in [0,1].
|
||||
"""
|
||||
import argparse
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
|
||||
|
||||
class CogPose(nn.Module):
|
||||
"""Mirrors cog-pose-estimation's PoseNet (Candle) exactly — same safetensors keys."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.enc = nn.ModuleDict({
|
||||
"c1": nn.Conv1d(56, 64, 3, padding=1, dilation=1),
|
||||
"c2": nn.Conv1d(64, 128, 3, padding=2, dilation=2),
|
||||
"c3": nn.Conv1d(128, 128, 3, padding=4, dilation=4),
|
||||
})
|
||||
self.head = nn.ModuleDict({"fc1": nn.Linear(128, 256), "fc2": nn.Linear(256, 34)})
|
||||
self.fc1_lora = None
|
||||
self.fc2_lora = None
|
||||
|
||||
def _lora(self, slot, x, y):
|
||||
if slot is None:
|
||||
return y
|
||||
a, b = slot
|
||||
return y + (x @ a) @ b
|
||||
|
||||
def forward(self, x): # x: [B, 56, 20]
|
||||
h = F.relu(self.enc["c1"](x))
|
||||
h = F.relu(self.enc["c2"](h))
|
||||
h = F.relu(self.enc["c3"](h))
|
||||
h = h.mean(2) # [B, 128]
|
||||
z1 = self.head["fc1"](h)
|
||||
z1 = self._lora(self.fc1_lora, h, z1)
|
||||
h1 = F.relu(z1)
|
||||
z2 = self.head["fc2"](h1)
|
||||
z2 = self._lora(self.fc2_lora, h1, z2)
|
||||
return torch.sigmoid(z2) # [B, 34]
|
||||
|
||||
def add_lora(self, r=4):
|
||||
self.fc1_lora = (nn.Parameter(torch.randn(128, r) * 0.02), nn.Parameter(torch.zeros(r, 256)))
|
||||
self.fc2_lora = (nn.Parameter(torch.randn(256, r) * 0.02), nn.Parameter(torch.zeros(r, 34)))
|
||||
for p in (*self.fc1_lora, *self.fc2_lora):
|
||||
self.register_parameter(f"lora_{id(p)}", p)
|
||||
return self
|
||||
|
||||
|
||||
def load_base(net: CogPose, path: str):
|
||||
from safetensors.torch import load_file
|
||||
sd = load_file(path)
|
||||
# remap "enc.c1.weight" -> module dict keys
|
||||
mapped = {}
|
||||
for k, v in sd.items():
|
||||
mapped[k.replace("enc.", "enc.").replace("head.", "head.")] = v
|
||||
net.load_state_dict(mapped, strict=False)
|
||||
return net
|
||||
|
||||
|
||||
def fit(base: str, data: str, out: str, rank: int = 4, iters: int = 400, lr: float = 1e-3):
|
||||
z = np.load(data)
|
||||
X = torch.tensor(z["X"].astype(np.float32)) # [N,56,20]
|
||||
Y = torch.tensor(z["Y"].reshape(len(z["Y"]), 34).astype(np.float32))
|
||||
n = len(X)
|
||||
net = CogPose()
|
||||
load_base(net, base)
|
||||
net.add_lora(rank)
|
||||
for p in net.parameters():
|
||||
p.requires_grad = False
|
||||
lora = [*net.fc1_lora, *net.fc2_lora]
|
||||
for p in lora:
|
||||
p.requires_grad = True
|
||||
opt = torch.optim.AdamW(lora, lr=lr, weight_decay=0.0)
|
||||
lossf = nn.SmoothL1Loss(beta=0.1)
|
||||
bs = min(64, n)
|
||||
net.train()
|
||||
for _ in range(iters):
|
||||
bi = torch.randint(0, n, (bs,))
|
||||
opt.zero_grad()
|
||||
lossf(net(X[bi]), Y[bi]).backward()
|
||||
opt.step()
|
||||
|
||||
alpha = 16.0
|
||||
scale = alpha / rank
|
||||
a1, b1 = net.fc1_lora
|
||||
a2, b2 = net.fc2_lora
|
||||
tensors = {
|
||||
"fc1.a": a1.detach().contiguous(),
|
||||
"fc1.b": (b1.detach() * scale).contiguous(), # bake scale into b
|
||||
"fc2.a": a2.detach().contiguous(),
|
||||
"fc2.b": (b2.detach() * scale).contiguous(),
|
||||
}
|
||||
from safetensors.torch import save_file
|
||||
save_file(tensors, out)
|
||||
return out, sum(p.numel() for p in lora), n
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--base", required=True)
|
||||
ap.add_argument("--data", required=True)
|
||||
ap.add_argument("--out", required=True)
|
||||
ap.add_argument("--rank", type=int, default=4)
|
||||
ap.add_argument("--iters", type=int, default=400)
|
||||
a = ap.parse_args()
|
||||
out, np_, n = fit(a.base, a.data, a.out, a.rank, a.iters)
|
||||
print(f"saved {out} | {np_} LoRA params from {n} samples "
|
||||
f"(keys fc1.a/fc1.b/fc2.a/fc2.b — load with cog-pose-estimation run --adapter)")
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Run calibrated WiFi-CSI pose inference: shared base + a per-room LoRA adapter.
|
||||
|
||||
python infer.py --base pose_mmfi_best.pt --adapter room_A.adapter.npz --data frames.npz
|
||||
|
||||
`frames.npz` contains `X` [N,3,114,10] CSI amplitude. Prints/saves [N,17,2] keypoints in [0,1].
|
||||
Omit --adapter to run the uncalibrated (zero-shot) base. With a room adapter, expect SOTA-level
|
||||
accuracy in that room/person; without one, zero-shot degrades in unseen rooms (ADR-150 §3.6).
|
||||
"""
|
||||
import argparse
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from model import PoseNet, standardize
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--base", required=True)
|
||||
ap.add_argument("--adapter", default=None, help="per-room .adapter.npz (omit for zero-shot)")
|
||||
ap.add_argument("--data", required=True, help=".npz with X [N,3,114,10]")
|
||||
ap.add_argument("--out", default=None, help="optional .npy to save [N,17,2] keypoints")
|
||||
ap.add_argument("--rank", type=int, default=8)
|
||||
ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
|
||||
a = ap.parse_args()
|
||||
dev = a.device
|
||||
|
||||
net = PoseNet().to(dev)
|
||||
net.load_state_dict(torch.load(a.base, map_location=dev), strict=False)
|
||||
if a.adapter:
|
||||
net.add_lora(r=a.rank).to(dev)
|
||||
z = np.load(a.adapter)
|
||||
net.load_lora({k: z[k].astype(np.float32) for k in z.files if k.endswith(".A") or k.endswith(".B")})
|
||||
net.eval()
|
||||
|
||||
X = torch.tensor(np.load(a.data)["X"].astype(np.float32)).to(dev)
|
||||
Xs = standardize(X)
|
||||
out = []
|
||||
with torch.no_grad():
|
||||
for i in range(0, len(Xs), 4096):
|
||||
out.append(net(Xs[i:i + 4096]).cpu().numpy())
|
||||
kp = np.concatenate(out).reshape(-1, 17, 2)
|
||||
print(f"inferred {len(kp)} frames | adapter={'yes' if a.adapter else 'NONE (zero-shot)'}")
|
||||
if a.out:
|
||||
np.save(a.out, kp)
|
||||
print(f"saved keypoints -> {a.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,107 +0,0 @@
|
||||
"""WiFi-CSI pose model + LoRA adapter for the RuView calibration service.
|
||||
|
||||
Architecture matches the published flagship checkpoint
|
||||
[`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose)
|
||||
(`pose_mmfi_best.pt`): transformer encoder + temporal attention pooling + skeleton-graph head.
|
||||
|
||||
The calibration service freezes this base and fits a tiny per-room **LoRA adapter** (rank 8 on the
|
||||
input projection + pose head ≈ 11 KB) from ~100–200 labeled in-room samples. Empirically that lifts
|
||||
cross-subject 64→72% and cross-environment 11→73% (ADR-150 §3.3–3.6).
|
||||
"""
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
# COCO-17 skeleton edges for the graph-refinement head.
|
||||
EDGES = [(0, 1), (0, 2), (1, 3), (2, 4), (5, 6), (5, 7), (7, 9), (6, 8), (8, 10),
|
||||
(5, 11), (6, 12), (11, 12), (11, 13), (13, 15), (12, 14), (14, 16)]
|
||||
_A = np.eye(17, dtype=np.float32)
|
||||
for _i, _j in EDGES:
|
||||
_A[_i, _j] = _A[_j, _i] = 1.0
|
||||
_A = _A / _A.sum(1, keepdims=True)
|
||||
|
||||
|
||||
class LoRA(nn.Module):
|
||||
"""Low-rank adapter wrapping a frozen Linear: y = W·x + (x·A·B)·(alpha/r)."""
|
||||
|
||||
def __init__(self, base: nn.Linear, r: int = 8, alpha: int = 16):
|
||||
super().__init__()
|
||||
self.base = base
|
||||
for p in self.base.parameters():
|
||||
p.requires_grad = False
|
||||
self.A = nn.Parameter(torch.zeros(base.in_features, r))
|
||||
self.B = nn.Parameter(torch.zeros(r, base.out_features))
|
||||
nn.init.normal_(self.A, std=0.02)
|
||||
self.scale = alpha / r
|
||||
|
||||
def forward(self, x):
|
||||
return self.base(x) + (x @ self.A @ self.B) * self.scale
|
||||
|
||||
|
||||
class GR(nn.Module):
|
||||
"""Skeleton-graph refinement: nudges joints toward anatomically consistent positions."""
|
||||
|
||||
def __init__(self, d=256, h=96):
|
||||
super().__init__()
|
||||
self.je = nn.Parameter(torch.randn(17, 32) * 0.02)
|
||||
self.inp = nn.Linear(d + 34, h)
|
||||
self.g1 = nn.Linear(h, h)
|
||||
self.g2 = nn.Linear(h, h)
|
||||
self.out = nn.Linear(h, 2)
|
||||
self.register_buffer("A", torch.tensor(_A))
|
||||
|
||||
def forward(self, z, kp0):
|
||||
B = z.shape[0]
|
||||
f = torch.relu(self.inp(torch.cat(
|
||||
[z.unsqueeze(1).expand(-1, 17, -1), self.je.unsqueeze(0).expand(B, -1, -1), kp0], -1)))
|
||||
f = torch.relu(self.g1(torch.einsum('ij,bjh->bih', self.A, f)))
|
||||
f = torch.relu(self.g2(torch.einsum('ij,bjh->bih', self.A, f)))
|
||||
return kp0 + 0.3 * torch.tanh(self.out(f))
|
||||
|
||||
|
||||
class PoseNet(nn.Module):
|
||||
"""Flagship pose model. Input [B,3,114,10] CSI amplitude (per-sample standardized) -> [B,34]."""
|
||||
|
||||
def __init__(self, na=3, nsc=114, nt=10, d=256, L=4, H=8):
|
||||
super().__init__()
|
||||
self.proj = nn.Linear(na * nsc, d)
|
||||
self.pos = nn.Parameter(torch.randn(1, nt, d) * 0.02)
|
||||
enc = nn.TransformerEncoderLayer(d, H, d * 2, dropout=0.2, batch_first=True, activation='gelu')
|
||||
self.tf = nn.TransformerEncoder(enc, L)
|
||||
self.att = nn.Linear(d, 1)
|
||||
self.head = nn.Sequential(nn.Linear(d, 256), nn.GELU(), nn.Dropout(0.3), nn.Linear(256, 34))
|
||||
self.gr = GR(d)
|
||||
self.na, self.nsc, self.nt = na, nsc, nt
|
||||
|
||||
def forward(self, x):
|
||||
B = x.shape[0]
|
||||
t = x.permute(0, 3, 1, 2).reshape(B, self.nt, self.na * self.nsc)
|
||||
h = self.tf(self.proj(t) + self.pos)
|
||||
w = torch.softmax(self.att(h), 1)
|
||||
z = (h * w).sum(1)
|
||||
kp0 = torch.sigmoid(self.head(z)).reshape(B, 17, 2)
|
||||
return self.gr(z, kp0).reshape(B, 34)
|
||||
|
||||
def add_lora(self, r=8, alpha=16):
|
||||
"""Wrap the input projection + pose head with LoRA adapters (the ~11 KB calibration set)."""
|
||||
self.proj = LoRA(self.proj, r, alpha)
|
||||
self.head[0] = LoRA(self.head[0], r, alpha)
|
||||
self.head[3] = LoRA(self.head[3], r, alpha)
|
||||
return self
|
||||
|
||||
def lora_state(self) -> dict:
|
||||
"""Extract just the LoRA A/B tensors (the per-room adapter to save)."""
|
||||
return {k: v.detach().cpu().numpy() for k, v in self.state_dict().items()
|
||||
if k.endswith(".A") or k.endswith(".B")}
|
||||
|
||||
def load_lora(self, adapter: dict):
|
||||
sd = self.state_dict()
|
||||
for k, v in adapter.items():
|
||||
sd[k] = torch.tensor(v)
|
||||
self.load_state_dict(sd)
|
||||
return self
|
||||
|
||||
|
||||
def standardize(x: torch.Tensor) -> torch.Tensor:
|
||||
"""Per-sample standardization used in training/inference."""
|
||||
return (x - x.mean((1, 2, 3), keepdim=True)) / (x.std((1, 2, 3), keepdim=True) + 1e-6)
|
||||
@@ -1,103 +0,0 @@
|
||||
"""Self-contained regression test for the RuView calibration service.
|
||||
|
||||
Exercises the committed CLI end-to-end on synthetic data (CPU, no GPU, no real checkpoint):
|
||||
build a base -> calibrate.py fits an adapter -> infer.py runs base+adapter -> assert the
|
||||
adapter is small, inference is shape-correct and finite, and the adapter actually changes output.
|
||||
|
||||
Run: python test_calibration.py (or via pytest)
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
from model import PoseNet, standardize # noqa: E402
|
||||
|
||||
|
||||
def _make_base(path: Path):
|
||||
torch.manual_seed(0)
|
||||
net = PoseNet()
|
||||
# Save without the deterministic gr.A buffer (mirrors the published checkpoint;
|
||||
# calibrate.py/infer.py load with strict=False).
|
||||
sd = {k: v for k, v in net.state_dict().items() if k != "gr.A"}
|
||||
torch.save(sd, path)
|
||||
|
||||
|
||||
def _make_data(path: Path, n: int, seed: int):
|
||||
rng = np.random.default_rng(seed)
|
||||
X = rng.standard_normal((n, 3, 114, 10)).astype(np.float32)
|
||||
Y = rng.random((n, 17, 2)).astype(np.float32) # keypoints in [0,1]
|
||||
np.savez(path, X=X, Y=Y)
|
||||
|
||||
|
||||
def _run(*args):
|
||||
r = subprocess.run(
|
||||
[sys.executable, str(HERE / args[0]), *map(str, args[1:])],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert r.returncode == 0, f"{args[0]} failed:\n{r.stdout}\n{r.stderr}"
|
||||
return r.stdout
|
||||
|
||||
|
||||
def test_calibration_end_to_end():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
d = Path(d)
|
||||
base = d / "base.pt"
|
||||
calib = d / "calib.npz"
|
||||
frames = d / "frames.npz"
|
||||
adapter = d / "room.adapter.npz"
|
||||
kp = d / "kp.npy"
|
||||
|
||||
_make_base(base)
|
||||
_make_data(calib, n=40, seed=1) # ≥20 → no underfit warning
|
||||
_make_data(frames, n=16, seed=2)
|
||||
|
||||
# 1) calibrate -> adapter
|
||||
out = _run("calibrate.py", "--base", base, "--data", calib, "--out", adapter,
|
||||
"--iters", "50", "--device", "cpu")
|
||||
assert adapter.exists(), "adapter not written"
|
||||
assert "saved" in out.lower()
|
||||
sz = adapter.stat().st_size
|
||||
assert sz < 200_000, f"adapter unexpectedly large ({sz} bytes)"
|
||||
|
||||
# adapter contains the expected LoRA tensors (materialize + close so the
|
||||
# Windows tempdir can be cleaned up — np.load keeps a lazy file handle).
|
||||
with np.load(adapter) as z:
|
||||
keys = [k for k in z.files if k.endswith(".A") or k.endswith(".B")]
|
||||
assert keys, f"adapter has no LoRA tensors: {z.files}"
|
||||
lora = {k: z[k].astype(np.float32) for k in keys}
|
||||
|
||||
# 2) infer with adapter -> keypoints
|
||||
_run("infer.py", "--base", base, "--adapter", adapter, "--data", frames,
|
||||
"--out", kp, "--device", "cpu")
|
||||
out_kp = np.load(kp)
|
||||
assert out_kp.shape == (16, 17, 2), f"bad keypoint shape {out_kp.shape}"
|
||||
assert np.isfinite(out_kp).all(), "non-finite keypoints"
|
||||
assert (out_kp >= 0).all() and (out_kp <= 1).all(), "keypoints out of [0,1]"
|
||||
|
||||
# 3) adapter must actually change the output vs the zero-shot base
|
||||
with np.load(frames) as fz:
|
||||
frames_x = fz["X"][:]
|
||||
net = PoseNet()
|
||||
net.load_state_dict(torch.load(base, map_location="cpu"), strict=False)
|
||||
net.eval()
|
||||
x = standardize(torch.tensor(frames_x))
|
||||
with torch.no_grad():
|
||||
base_kp = net(x).reshape(16, 17, 2).numpy()
|
||||
net.add_lora()
|
||||
net.load_lora(lora)
|
||||
net.eval()
|
||||
with torch.no_grad():
|
||||
cal_kp = net(x).reshape(16, 17, 2).numpy()
|
||||
assert np.abs(base_kp - cal_kp).sum() > 1e-4, "adapter did not change output"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_calibration_end_to_end()
|
||||
print("PASS: calibration service end-to-end (calibrate -> adapter -> infer)")
|
||||
@@ -1,75 +0,0 @@
|
||||
"""Regression test for the cog-pose adapter producer (cog_calibrate.py).
|
||||
|
||||
Uses the in-repo `pose_v1.safetensors` (skips if absent). Verifies the produced adapter:
|
||||
- has the exact keys/shapes the Rust `cog-pose-estimation --adapter` loader expects,
|
||||
- reduces calibration fit error,
|
||||
- actually changes inference output,
|
||||
- is tiny.
|
||||
Run: python test_cog_calibration.py (or via pytest)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
import cog_calibrate as C # noqa: E402
|
||||
|
||||
BASE = HERE / "../../v2/crates/cog-pose-estimation/cog/artifacts/pose_v1.safetensors"
|
||||
|
||||
|
||||
def test_cog_adapter_producer():
|
||||
if not BASE.exists():
|
||||
print(f"(skip — {BASE} not present)")
|
||||
return
|
||||
from safetensors.torch import load_file
|
||||
|
||||
rng = np.random.default_rng(0)
|
||||
n = 120
|
||||
X = rng.standard_normal((n, 56, 20)).astype("float32")
|
||||
Y = (0.5 + 0.1 * X[:, :34, 0].reshape(n, 34)).clip(0, 1).astype("float32")
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
calib = os.path.join(d, "calib.npz")
|
||||
adapter = os.path.join(d, "room.safetensors")
|
||||
np.savez(calib, X=X, Y=Y)
|
||||
|
||||
net0 = C.CogPose()
|
||||
C.load_base(net0, str(BASE))
|
||||
net0.eval()
|
||||
with torch.no_grad():
|
||||
base_err = F.smooth_l1_loss(net0(torch.tensor(X)), torch.tensor(Y)).item()
|
||||
|
||||
_, nparam, _ = C.fit(str(BASE), calib, adapter, rank=4, iters=400)
|
||||
t = load_file(adapter)
|
||||
|
||||
# exact Rust loader contract: a:[in,r], b:[r,out]
|
||||
assert tuple(t["fc1.a"].shape) == (128, 4)
|
||||
assert tuple(t["fc1.b"].shape) == (4, 256)
|
||||
assert tuple(t["fc2.a"].shape) == (256, 4)
|
||||
assert tuple(t["fc2.b"].shape) == (4, 34)
|
||||
|
||||
net = C.CogPose()
|
||||
C.load_base(net, str(BASE))
|
||||
net.add_lora(4)
|
||||
with torch.no_grad():
|
||||
net.fc1_lora[0].copy_(t["fc1.a"]); net.fc1_lora[1].copy_(t["fc1.b"] / (16 / 4))
|
||||
net.fc2_lora[0].copy_(t["fc2.a"]); net.fc2_lora[1].copy_(t["fc2.b"] / (16 / 4))
|
||||
net.eval()
|
||||
with torch.no_grad():
|
||||
cal_err = F.smooth_l1_loss(net(torch.tensor(X)), torch.tensor(Y)).item()
|
||||
changed = (net0(torch.tensor(X[:8])) - net(torch.tensor(X[:8]))).abs().sum().item()
|
||||
|
||||
assert cal_err < base_err, f"calibration did not reduce error ({base_err} -> {cal_err})"
|
||||
assert changed > 1e-3, "adapter inert"
|
||||
assert nparam < 5000, f"adapter unexpectedly large ({nparam} params)"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_cog_adapter_producer()
|
||||
print("PASS: cog adapter producer (Rust-loadable format, reduces error, active)")
|
||||
@@ -1 +0,0 @@
|
||||
9c35e541d51f00998691b98948887ebca09b907d8eb29a113f97e792340456ba
|
||||
@@ -1 +0,0 @@
|
||||
{"frames": [{"pred": [[0.4003, 0.2734], [0.5038, 0.4197], [0.2053, 0.4438], [0.4397, 0.685], [0.5796, 0.7645], [0.8001, 0.2195], [0.2789, 0.2833], [0.314, 0.5439], [0.511, 0.2259], [0.6008, 0.46], [0.4837, 0.3879], [0.3475, 0.5597], [0.6569, 0.3575], [0.437, 0.6539], [0.2341, 0.6038], [0.7331, 0.392], [0.5615, 0.4915]]}, {"pred": [[0.4669, 0.6066], [0.6012, 0.7873], [0.4124, 0.5997], [0.2832, 0.281], [0.2732, 0.3635], [0.2503, 0.4848], [0.6827, 0.715], [0.4336, 0.7165], [0.295, 0.3386], [0.5337, 0.3544], [0.4397, 0.5474], [0.5163, 0.5528], [0.7547, 0.6799], [0.4195, 0.4448], [0.2257, 0.2269], [0.384, 0.2176], [0.2419, 0.4332]]}, {"pred": [[0.5585, 0.283], [0.4325, 0.2934], [0.463, 0.4744], [0.4188, 0.3454], [0.215, 0.7565], [0.527, 0.2353], [0.7084, 0.6124], [0.3015, 0.6744], [0.4103, 0.3532], [0.7243, 0.6932], [0.3302, 0.4918], [0.2072, 0.3754], [0.7914, 0.4878], [0.7618, 0.4079], [0.323, 0.3386], [0.7104, 0.4997], [0.2673, 0.6077]]}, {"pred": [[0.6372, 0.4984], [0.4184, 0.6763], [0.4498, 0.7549], [0.2924, 0.303], [0.3069, 0.7022], [0.3954, 0.5098], [0.7836, 0.6071], [0.4733, 0.7114], [0.3407, 0.3793], [0.3408, 0.4678], [0.4156, 0.4911], [0.4525, 0.7519], [0.5117, 0.1985], [0.1893, 0.6784], [0.6281, 0.5346], [0.5175, 0.673], [0.36, 0.3665]]}, {"pred": [[0.5535, 0.6537], [0.568, 0.511], [0.4705, 0.5377], [0.6372, 0.7163], [0.5493, 0.7515], [0.2559, 0.4549], [0.2553, 0.6176], [0.2991, 0.6154], [0.7185, 0.7986], [0.4586, 0.5057], [0.2975, 0.4525], [0.3263, 0.3719], [0.5131, 0.4576], [0.557, 0.5268], [0.6572, 0.7736], [0.2146, 0.6526], [0.4662, 0.7371]]}, {"pred": [[0.2924, 0.7595], [0.2612, 0.2315], [0.2488, 0.7751], [0.2329, 0.7282], [0.4744, 0.4206], [0.3618, 0.267], [0.2477, 0.285], [0.3976, 0.3746], [0.494, 0.2874], [0.3596, 0.2112], [0.3311, 0.4692], [0.6912, 0.4727], [0.4434, 0.5233], [0.4139, 0.7048], [0.425, 0.3937], [0.2326, 0.631], [0.2655, 0.7116]]}, {"pred": [[0.3609, 0.3437], [0.285, 0.486], [0.7734, 0.5468], [0.3657, 0.4093], [0.4728, 0.5019], [0.1866, 0.3545], [0.2172, 0.2028], [0.5613, 0.5238], [0.6252, 0.7205], [0.7998, 0.2954], [0.242, 0.7063], [0.6259, 0.6883], [0.5148, 0.7141], [0.5577, 0.7434], [0.3233, 0.2131], [0.2652, 0.7066], [0.5753, 0.5885]]}, {"pred": [[0.6787, 0.6504], [0.6051, 0.2297], [0.2539, 0.3475], [0.6437, 0.7807], [0.4981, 0.6149], [0.5716, 0.2367], [0.6486, 0.3632], [0.2433, 0.369], [0.6061, 0.3731], [0.4955, 0.2591], [0.7676, 0.7602], [0.6899, 0.7716], [0.3143, 0.7707], [0.3031, 0.4997], [0.7076, 0.5133], [0.3382, 0.7196], [0.2002, 0.4871]]}]}
|
||||
@@ -1 +0,0 @@
|
||||
{"frames": [{"gt": [[0.3943, 0.2905], [0.5215, 0.4194], [0.2225, 0.4602], [0.4547, 0.6961], [0.5765, 0.7686], [0.7858, 0.2279], [0.2866, 0.2707], [0.3084, 0.549], [0.5286, 0.2377], [0.6082, 0.4566], [0.4719, 0.3799], [0.3465, 0.5447], [0.6377, 0.3728], [0.4509, 0.6543], [0.2235, 0.6009], [0.7253, 0.3882], [0.5479, 0.4737]], "vis": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], "scale": 1.0}, {"gt": [[0.4845, 0.5985], [0.5883, 0.7959], [0.4315, 0.6012], [0.3008, 0.2703], [0.2776, 0.3486], [0.2483, 0.4695], [0.6916, 0.7184], [0.4153, 0.7305], [0.3057, 0.3392], [0.5535, 0.3576], [0.4216, 0.5398], [0.5093, 0.5706], [0.7397, 0.668], [0.4354, 0.4394], [0.2373, 0.2404], [0.404, 0.2315], [0.2609, 0.4182]], "vis": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], "scale": 1.0}, {"gt": [[0.5684, 0.2891], [0.4185, 0.2737], [0.4796, 0.4903], [0.4056, 0.3589], [0.2139, 0.7706], [0.5259, 0.2162], [0.718, 0.6177], [0.3002, 0.6632], [0.3978, 0.3338], [0.7116, 0.6836], [0.336, 0.5106], [0.2168, 0.3677], [0.7739, 0.4683], [0.773, 0.4188], [0.318, 0.3226], [0.7043, 0.4877], [0.2509, 0.5964]], "vis": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], "scale": 1.0}, {"gt": [[0.6501, 0.4868], [0.3995, 0.6805], [0.4408, 0.7681], [0.2762, 0.2907], [0.2877, 0.6959], [0.4102, 0.5292], [0.7825, 0.5898], [0.4603, 0.723], [0.3511, 0.3758], [0.3556, 0.4514], [0.4123, 0.4749], [0.4524, 0.7506], [0.5141, 0.2112], [0.2024, 0.6795], [0.6351, 0.5339], [0.5333, 0.6706], [0.3491, 0.3662]], "vis": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], "scale": 1.0}, {"gt": [[0.537, 0.656], [0.5675, 0.5033], [0.4714, 0.52], [0.6195, 0.7259], [0.5357, 0.766], [0.273, 0.4653], [0.2439, 0.6017], [0.2927, 0.6297], [0.7297, 0.7805], [0.439, 0.4924], [0.2969, 0.4589], [0.3174, 0.3911], [0.5324, 0.4643], [0.5744, 0.5074], [0.673, 0.783], [0.2238, 0.6674], [0.4534, 0.7468]], "vis": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], "scale": 1.0}, {"gt": [[0.2896, 0.7515], [0.2537, 0.2345], [0.2434, 0.763], [0.2502, 0.7137], [0.4723, 0.4035], [0.3607, 0.2775], [0.2657, 0.2969], [0.3872, 0.383], [0.5001, 0.3067], [0.3503, 0.2092], [0.3137, 0.4849], [0.6914, 0.4593], [0.4359, 0.504], [0.4056, 0.6994], [0.4428, 0.4085], [0.2424, 0.6445], [0.2507, 0.7048]], "vis": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], "scale": 1.0}, {"gt": [[0.3692, 0.3453], [0.2945, 0.4675], [0.7836, 0.5282], [0.3857, 0.414], [0.4848, 0.5017], [0.203, 0.3585], [0.225, 0.2135], [0.5513, 0.5175], [0.6296, 0.7275], [0.7908, 0.2897], [0.2263, 0.7012], [0.6403, 0.6873], [0.5026, 0.701], [0.5504, 0.7357], [0.338, 0.2187], [0.2629, 0.7015], [0.5757, 0.6084]], "vis": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], "scale": 1.0}, {"gt": [[0.6786, 0.649], [0.5956, 0.2396], [0.2447, 0.3593], [0.6439, 0.7854], [0.4874, 0.6102], [0.5857, 0.2465], [0.6459, 0.3827], [0.2364, 0.3613], [0.6054, 0.3745], [0.4798, 0.2711], [0.7869, 0.7618], [0.6919, 0.7809], [0.3259, 0.7674], [0.285, 0.5144], [0.6921, 0.5052], [0.3388, 0.7386], [0.2022, 0.495]], "vis": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], "scale": 1.0}]}
|
||||
@@ -1,5 +0,0 @@
|
||||
{"benchmark": "AetherArena", "created": "2026-05-30", "kind": "genesis", "note": "Official Spatial-Intelligence Benchmark \u2014 append-only signed ledger. Entries are real harness scores only; no seeded numbers.", "prev_hash": "0000000000000000000000000000000000000000000000000000000000000000", "row_hash": "940bdc6f0f5dd00f4d89e13a8fa843bab3c9ddf1b8051f426a1701e730249231", "seq": 0, "spec": "ADR-149"}
|
||||
{"abs_gain": "+9.38", "benchmark": "MM-Fi", "category": "pose", "caveat": "Protocol-matched MM-Fi random_split result; NOT solved real-world generalization. Random split has temporal/subject-adjacency effects common to this benchmark family. Leakage-free cross-subject is far lower (~11-27%) and is the real deployment frontier.", "harness_version": 1, "kind": "result", "metric": "torso-PCK@20 (||right_shoulder-left_hip|| norm, 17 COCO kpts)", "modality": "wifi-csi", "model_ref": "RuView CSI-Transformer (4L/8H ~2M params, temporal-attention)", "prev_hash": "940bdc6f0f5dd00f4d89e13a8fa843bab3c9ddf1b8051f426a1701e730249231", "protocol": "random_split (ratio=0.8, seed=0)", "rel_gain": "+13.0%", "reproduce": "download MM-Fi -> parse_mmfi_zips.py -> train_tf_torso.py X.npy Y.npy split_random.npy (seed 0)", "row_hash": "76598d8e1320d5248f8cd854a8ffa22a99bd2a2f0e0e7f2d2b1df79af16001d5", "score_pct": 81.63, "scored_at": "2026-05-30", "seq": 1, "sota_ref": "MultiFormer 72.25 (CSI2Pose 68.41)", "submitter": "ruvnet", "tier": "Gold"}
|
||||
{"abs_gain": "+11.34", "benchmark": "MM-Fi", "category": "pose", "harness_version": 1, "kind": "result", "metric": "torso-PCK@20", "modality": "wifi-csi", "model_ref": "RuView CSI-Transformer + skeleton-graph head + 3-ensemble + TTA", "note": "Best in-domain. Stacks attention-pooling + transformer + skeleton-graph refine + warmup + TTA + 3-model ensemble. Supersedes the 81.63 single-model entry.", "prev_hash": "76598d8e1320d5248f8cd854a8ffa22a99bd2a2f0e0e7f2d2b1df79af16001d5", "protocol": "random_split (0.8, seed 0)", "row_hash": "5780a4bc3e98eb0e30c1ecfa9091e57b280444fa1f21cd5146797e408580e4ab", "score_pct": 83.59, "scored_at": "2026-05-30", "seq": 2, "sota_ref": "MultiFormer 72.25 (CSI2Pose 68.41)", "submitter": "ruvnet", "tier": "Gold"}
|
||||
{"benchmark": "MM-Fi", "category": "pose", "harness_version": 1, "kind": "result", "metric": "torso-PCK@20", "modality": "wifi-csi", "model_ref": "RuView CSI-Transformer", "note": "Leakage-free generalization to unseen people, shared rooms. Honest deployment-relevant number.", "prev_hash": "5780a4bc3e98eb0e30c1ecfa9091e57b280444fa1f21cd5146797e408580e4ab", "protocol": "cross_subject (official, val=S05,S10,..,S40)", "row_hash": "d989e4e1dbc0182610305fdfbde8b094413b87c913283a46bf41f4afba7a06fd", "score_pct": 64.04, "scored_at": "2026-05-30", "seq": 3, "sota_ref": "(no matched public ref)", "submitter": "ruvnet", "tier": "Silver"}
|
||||
{"benchmark": "MM-Fi", "category": "pose", "harness_version": 1, "kind": "result", "metric": "torso-PCK@20", "modality": "wifi-csi", "model_ref": "RuView CSI-Transformer + CORAL domain alignment", "note": "The real deployment frontier (new room). CORAL transductive DG (+30% rel over control). Data-bound: MM-Fi has only 3 source rooms.", "prev_hash": "d989e4e1dbc0182610305fdfbde8b094413b87c913283a46bf41f4afba7a06fd", "protocol": "cross_environment (train E01-03 -> test E04, new room)", "row_hash": "bf370487bde88e198c13877956dab3c83766a6a24afef0b78b6ac7aa130bb207", "score_pct": 17.51, "scored_at": "2026-05-30", "seq": 4, "sota_ref": "(hard frontier; control 13.52)", "submitter": "ruvnet", "tier": "Bronze"}
|
||||
@@ -1,100 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AetherArena append-only, tamper-evident results ledger (ADR-149 §2.3/§2.4).
|
||||
|
||||
Each row is hash-chained to the previous one: ``row_hash = sha256(canonical_row
|
||||
+ prev_hash)``. Any silent edit to an earlier row breaks every subsequent
|
||||
``prev_hash`` link, so the ledger is append-only and verifiable by anyone — no
|
||||
trust in the maintainer required. (Ed25519 row signing is the next hardening;
|
||||
the chain already makes tampering detectable.)
|
||||
|
||||
Usage:
|
||||
python ledger_tools.py seed # (re)build ledger.jsonl with genesis + baseline
|
||||
python ledger_tools.py verify # verify the whole chain -> exit 0 / 1
|
||||
python ledger_tools.py append '<json-row>' # append one scored row
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
LEDGER = Path(__file__).parent / "ledger.jsonl"
|
||||
GENESIS_PREV = "0" * 64
|
||||
|
||||
|
||||
def canonical(row: dict) -> bytes:
|
||||
# Stable key order, no whitespace -> deterministic bytes for hashing.
|
||||
body = {k: row[k] for k in sorted(row) if k != "row_hash"}
|
||||
return json.dumps(body, separators=(",", ":"), sort_keys=True).encode()
|
||||
|
||||
|
||||
def row_hash(row: dict) -> str:
|
||||
return hashlib.sha256(canonical(row)).hexdigest()
|
||||
|
||||
|
||||
def read_rows() -> list[dict]:
|
||||
if not LEDGER.exists():
|
||||
return []
|
||||
return [json.loads(l) for l in LEDGER.read_text().splitlines() if l.strip()]
|
||||
|
||||
|
||||
def append(entry: dict) -> dict:
|
||||
rows = read_rows()
|
||||
prev = rows[-1]["row_hash"] if rows else GENESIS_PREV
|
||||
entry = dict(entry)
|
||||
entry["seq"] = len(rows)
|
||||
entry["prev_hash"] = prev
|
||||
entry["row_hash"] = row_hash(entry)
|
||||
with LEDGER.open("a") as f:
|
||||
f.write(json.dumps(entry, sort_keys=True) + "\n")
|
||||
return entry
|
||||
|
||||
|
||||
def verify() -> bool:
|
||||
rows = read_rows()
|
||||
prev = GENESIS_PREV
|
||||
for i, r in enumerate(rows):
|
||||
if r.get("seq") != i:
|
||||
print(f"FAIL: row {i} seq mismatch ({r.get('seq')})")
|
||||
return False
|
||||
if r.get("prev_hash") != prev:
|
||||
print(f"FAIL: row {i} prev_hash broken — ledger was edited")
|
||||
return False
|
||||
if r.get("row_hash") != row_hash(r):
|
||||
print(f"FAIL: row {i} row_hash mismatch — row was tampered")
|
||||
return False
|
||||
prev = r["row_hash"]
|
||||
print(f"OK: {len(rows)} rows, chain intact")
|
||||
return True
|
||||
|
||||
|
||||
def seed():
|
||||
"""Rebuild with the genesis row only — an EMPTY board.
|
||||
|
||||
Benchmark-first: no placeholder/hand-entered numbers ever sit on the
|
||||
leaderboard. Every result row is produced by the real scoring pipeline
|
||||
(load model -> run inference -> score against the private eval split ->
|
||||
proof hash). The board starts empty and awaits the first real harness score,
|
||||
including RuView's own — which gets no special seeding.
|
||||
"""
|
||||
if LEDGER.exists():
|
||||
LEDGER.unlink()
|
||||
append({
|
||||
"kind": "genesis",
|
||||
"benchmark": "AetherArena",
|
||||
"spec": "ADR-149",
|
||||
"note": "Official Spatial-Intelligence Benchmark — append-only signed ledger. "
|
||||
"Entries are real harness scores only; no seeded numbers.",
|
||||
"created": "2026-05-30",
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "verify"
|
||||
if cmd == "seed":
|
||||
seed(); verify()
|
||||
elif cmd == "verify":
|
||||
sys.exit(0 if verify() else 1)
|
||||
elif cmd == "append":
|
||||
print(json.dumps(append(json.loads(sys.argv[2])), indent=2))
|
||||
else:
|
||||
print(__doc__); sys.exit(2)
|
||||
@@ -1,41 +0,0 @@
|
||||
# AetherArena submission manifest (ADR-149 §2.2).
|
||||
# Accompanies a model artifact pushed to the AA Hugging Face Space.
|
||||
# This file is the contract the Space validates before quarantine + scoring.
|
||||
|
||||
[submission]
|
||||
# Free-form display name shown on the leaderboard.
|
||||
name = "my-spatial-model"
|
||||
# Hugging Face repo or URL of the model artifact (.safetensors / .rvf / LoRA adapter).
|
||||
model_ref = "hf://your-org/your-model"
|
||||
# Submitter handle (HF username / org). Used to sign the ledger row.
|
||||
submitter = "your-hf-username"
|
||||
# SPDX license of the submitted model.
|
||||
license = "Apache-2.0"
|
||||
|
||||
[category]
|
||||
# One of: pose | presence | tracking | vitals | multi-task
|
||||
# v0 ranks: pose, presence (tracking/vitals activate when ground truth lands).
|
||||
primary = "pose"
|
||||
|
||||
[input]
|
||||
# Which ADR-145 FeatureSet the model consumes. v0 input is RF/WiFi CSI.
|
||||
# F0 = CSI amplitude/phase F1 = +CIR F2 = +Doppler F3 = +BFLD
|
||||
feature_set = "F0"
|
||||
# Tensor I/O contract so the scorer can feed the model correctly.
|
||||
input_shape = [114, 2] # subcarriers × {amp, phase} (example)
|
||||
output_shape = [17, 2] # 17 keypoints × {x, y} normalised [0,1]
|
||||
# Normalisation expected on the input ("none" | "zscore" | "minmax").
|
||||
normalization = "zscore"
|
||||
|
||||
[runtime]
|
||||
# Inference entrypoint inside the artifact (framework-specific).
|
||||
framework = "candle" # candle | onnx | torch
|
||||
# Optional: target the edge-latency category with a declared device class.
|
||||
device_class = "cpu" # cpu | pi5 | gpu
|
||||
|
||||
# Notes:
|
||||
# - You submit a MODEL, never predictions on data you hold.
|
||||
# - Scoring runs against a PRIVATE MM-Fi held-out split in a no-network,
|
||||
# read-only sandbox. You cannot see the eval data.
|
||||
# - The resulting score is a signed, append-only ledger row carrying a
|
||||
# determinism proof hash and the pinned harness_version.
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
title: AetherArena — Spatial-Intelligence Benchmark
|
||||
emoji: 📡
|
||||
colorFrom: indigo
|
||||
colorTo: purple
|
||||
sdk: gradio
|
||||
sdk_version: 5.9.1
|
||||
python_version: "3.12"
|
||||
app_file: app.py
|
||||
pinned: true
|
||||
license: cc-by-nc-4.0
|
||||
tags:
|
||||
- benchmark
|
||||
- leaderboard
|
||||
- wifi-sensing
|
||||
- spatial-intelligence
|
||||
- pose-estimation
|
||||
---
|
||||
|
||||
# AetherArena ("AA") — The Official Spatial-Intelligence Benchmark
|
||||
|
||||
> Public leaderboard. Private evaluation split. Open scorer. Signed results.
|
||||
|
||||
The field's standard yardstick for camera-free **spatial intelligence** (pose, presence,
|
||||
occupancy, tracking, vitals) from RF/WiFi and, over time, mmWave / UWB / multimodal.
|
||||
|
||||
- **Project-agnostic** — any team, framework, or modality enters; RuView donated the seed
|
||||
scorer and is scored like everyone else.
|
||||
- **Benchmark-first** — the board starts empty; every row is a real scoring-pipeline
|
||||
**witness** (`inputs_sha256` + `proof_sha256` + `harness_version`) in an append-only,
|
||||
hash-chained, tamper-evident ledger.
|
||||
- **Reproducible** — the scorer is open; reproduce any proof hash + repeatability locally.
|
||||
|
||||
Spec: [ADR-149](https://github.com/ruvnet/RuView/blob/main/docs/adr/ADR-149-public-community-leaderboard-huggingface.md).
|
||||
Source + open scorer: https://github.com/ruvnet/RuView/tree/main/aether-arena
|
||||
|
||||
Non-commercial (CC BY-NC 4.0): the v0 eval split derives from MM-Fi (CC BY-NC); AA is operated non-commercially.
|
||||
@@ -1,161 +0,0 @@
|
||||
"""AetherArena ("AA") — The Official Spatial-Intelligence Benchmark.
|
||||
|
||||
Hugging Face Space (Gradio) — the public face of the benchmark (ADR-149).
|
||||
This Space is the presentation + submission layer; the heavy scoring runs in the
|
||||
pinned RuView harness (CI / scorer container), and results land in the append-only,
|
||||
hash-chained **witness ledger** shown here.
|
||||
|
||||
Benchmark-first: the board starts EMPTY. No seeded or hand-entered numbers — every
|
||||
row is a real scoring-pipeline witness (inputs_sha256 + proof_sha256 + harness_version).
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import gradio as gr
|
||||
|
||||
LEDGER = Path(__file__).parent / "ledger.jsonl"
|
||||
GENESIS_PREV = "0" * 64
|
||||
|
||||
|
||||
def _rows():
|
||||
if not LEDGER.exists():
|
||||
return []
|
||||
return [json.loads(l) for l in LEDGER.read_text().splitlines() if l.strip()]
|
||||
|
||||
|
||||
def _canon(row: dict) -> bytes:
|
||||
body = {k: row[k] for k in sorted(row) if k != "row_hash"}
|
||||
return json.dumps(body, separators=(",", ":"), sort_keys=True).encode()
|
||||
|
||||
|
||||
def verify_chain():
|
||||
rows, prev = _rows(), GENESIS_PREV
|
||||
for i, r in enumerate(rows):
|
||||
if r.get("prev_hash") != prev or r.get("row_hash") != hashlib.sha256(_canon(r)).hexdigest():
|
||||
return f"❌ Ledger chain BROKEN at row {i} — tampering detected."
|
||||
prev = r["row_hash"]
|
||||
return f"✅ Witness ledger chain intact — {len(rows)} row(s), append-only."
|
||||
|
||||
|
||||
def leaderboard(category: str):
|
||||
results = [r for r in _rows() if r.get("kind") == "result" and (category == "all" or r.get("category") == category)]
|
||||
if not results:
|
||||
return [["— no entries yet —", "", "", "", "", ""]]
|
||||
results.sort(key=lambda r: r.get("score_pct") or 0, reverse=True)
|
||||
return [[
|
||||
r.get("submitter", "?"),
|
||||
r.get("model_ref", "?"),
|
||||
f"{r.get('benchmark','?')} / {r.get('protocol','?')}",
|
||||
r.get("metric", "?"),
|
||||
f"{r.get('score_pct', 0):.2f}%",
|
||||
f"{r.get('tier','?')} (vs {r.get('sota_ref','?')})",
|
||||
] for r in results]
|
||||
|
||||
|
||||
FOUR_PART = "### Public leaderboard. Private evaluation split. Open scorer. Signed results."
|
||||
|
||||
ABOUT = """
|
||||
**AetherArena** is the official, project-agnostic **Spatial-Intelligence Benchmark** —
|
||||
camera-free pose, presence, occupancy, tracking, and vitals from RF/WiFi (and, over
|
||||
time, mmWave / UWB / radar / multimodal). It is **not** a single-vendor board: any
|
||||
team, framework, or modality enters, and every entrant — including the RuView baseline
|
||||
that donated the seed scorer — is scored by the identical, open, pinned harness.
|
||||
|
||||
The scorer reuses RuView's released `wifi-densepose-train` acceptance harness
|
||||
(`ruview_metrics` + ablation). You submit a **model, not predictions**; it is scored
|
||||
against a **private** MM-Fi held-out split; one **witness** row (inputs hash + proof
|
||||
hash + harness version) is appended to a **hash-chained, tamper-evident ledger**.
|
||||
|
||||
**For industry:** a vendor-neutral, auditable way to compare RF-sensing models on equal
|
||||
footing — the same standardized splits, the same metric definition, the same signed,
|
||||
reproducible ledger. No more "trust our number on our split." Vendors, labs, and startups
|
||||
all submit through one pipeline and are scored identically.
|
||||
|
||||
**Generalization Track (roadmap):** the headline isn't a single in-domain number — it's a
|
||||
battery of honest tracks: MM-Fi `random_split` (in-domain), `cross_subject` (unseen people),
|
||||
cross-room, cross-device, and confidence-calibration (ECE). Cross-subject is the real
|
||||
deployment frontier and is treated as the flagship hard benchmark.
|
||||
|
||||
Spec: ADR-149. v0 ranks **pose, presence, edge-latency, determinism**. Tracking &
|
||||
vitals activate when their ground truth lands; **privacy-leakage** is gated until the
|
||||
membership-inference attacker ships. Source + the open scorer:
|
||||
https://github.com/ruvnet/RuView/tree/main/aether-arena
|
||||
"""
|
||||
|
||||
SUBMIT = """
|
||||
### Submit a model
|
||||
|
||||
1. Write a manifest — [`schema/aa-submission.toml`](https://github.com/ruvnet/RuView/blob/main/aether-arena/schema/aa-submission.toml):
|
||||
declare your model ref, category, the ADR-145 feature set (F0 CSI … F3 BFLD), and the tensor I/O contract.
|
||||
2. Provide your model artifact (`.safetensors` / `.rvf` / LoRA adapter).
|
||||
3. It moves through `submitted → validated → quarantined → smoke_scored → full_scored → published`,
|
||||
scored in a no-network, read-only sandbox against the private split.
|
||||
4. Your signed witness row appears on the leaderboard.
|
||||
|
||||
**You submit a model, never predictions** — predictions on data you hold prove nothing.
|
||||
"""
|
||||
|
||||
VERIFY = """
|
||||
### Verify it's fair (you don't have to trust us)
|
||||
|
||||
The scorer is open and reproducible. Reproduce the determinism proof + repeatability locally:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ruvnet/RuView && cd RuView/v2
|
||||
# determinism gate (same as CI):
|
||||
cargo run -q -p wifi-densepose-train --bin aa_score_runner --no-default-features
|
||||
# repeatability — N runs, one identical proof hash:
|
||||
cargo run -q -p wifi-densepose-train --bin aa_score_runner --no-default-features -- --repeat 16
|
||||
# verify the append-only witness ledger chain:
|
||||
cd ../aether-arena/ledger && python3 ledger_tools.py verify
|
||||
```
|
||||
|
||||
A stranger must be able to: submit → get a deterministic score → see the signed row →
|
||||
rerun the scorer locally → understand why the rank is fair. That is the launch gate (ADR-149 §7).
|
||||
"""
|
||||
|
||||
with gr.Blocks(title="AetherArena — Spatial-Intelligence Benchmark") as demo:
|
||||
gr.Markdown("# 📡 AetherArena (AA)\n## The Official, Vendor-Neutral Benchmark for WiFi / RF Spatial Sensing")
|
||||
gr.Markdown(FOUR_PART)
|
||||
gr.Markdown(
|
||||
"**An open industry benchmark — for everyone, not any one vendor.** Submit any model, any framework, "
|
||||
"any modality. Every entrant — academic, startup, or incumbent — is scored *identically*: standardized "
|
||||
"protocols (MM-Fi `random_split` / `cross_subject`), matched metrics (torso-PCK@20, the published "
|
||||
"definition), and an auditable, hash-chained **witness ledger** anyone can verify and reproduce.\n\n"
|
||||
"**Why it exists:** WiFi/RF-sensing results are reported with inconsistent splits, metrics, and no "
|
||||
"auditability — so numbers aren't comparable. AetherArena fixes the *measurement*: one protocol, one "
|
||||
"metric, one signed ledger, one-command reproduction. The benchmark is the product; the leaderboard is "
|
||||
"just the scoreboard. (Reference implementation seeded by RuView, ADR-149.)"
|
||||
)
|
||||
chain = gr.Markdown(verify_chain())
|
||||
|
||||
with gr.Tab("🏆 Leaderboard"):
|
||||
gr.Markdown(
|
||||
"### Current standings — MM-Fi WiFi-CSI 2D pose, torso-PCK@20\n"
|
||||
"Ranked, protocol- & metric-matched results. Each row carries its own caveats in the ledger "
|
||||
"(e.g. `random_split` has temporal-adjacency leakage that inflates *all* methods equally — the "
|
||||
"leakage-free `cross_subject` track is the real deployment frontier). **Submit yours — top the board.**"
|
||||
)
|
||||
cat = gr.Dropdown(["all", "pose", "presence"], value="all", label="Category")
|
||||
tbl = gr.Dataframe(
|
||||
headers=["Submitter", "Model", "Benchmark / Protocol", "Metric", "Score", "Tier (vs prior SOTA)"],
|
||||
value=leaderboard("all"), interactive=False, wrap=True,
|
||||
)
|
||||
cat.change(leaderboard, cat, tbl)
|
||||
gr.Markdown(
|
||||
"*Vendor-neutral & benchmark-first: every row is a real, metric- and protocol-matched result — "
|
||||
"no seeded or vendor-favored numbers. Integrity is enforced, not promised: the current top entry's "
|
||||
"score was self-corrected down from an inflated metric (91.86% bbox → 81.63% torso) before it could "
|
||||
"be published. The same scorer and ledger apply to every submitter.*"
|
||||
)
|
||||
|
||||
with gr.Tab("📤 Submit"):
|
||||
gr.Markdown(SUBMIT)
|
||||
with gr.Tab("🔬 Verify"):
|
||||
gr.Markdown(VERIFY)
|
||||
with gr.Tab("ℹ️ About"):
|
||||
gr.Markdown(ABOUT)
|
||||
|
||||
if __name__ == "__main__":
|
||||
demo.launch(server_name="0.0.0.0", server_port=7860)
|
||||
@@ -1,5 +0,0 @@
|
||||
{"benchmark": "AetherArena", "created": "2026-05-30", "kind": "genesis", "note": "Official Spatial-Intelligence Benchmark \u2014 append-only signed ledger. Entries are real harness scores only; no seeded numbers.", "prev_hash": "0000000000000000000000000000000000000000000000000000000000000000", "row_hash": "940bdc6f0f5dd00f4d89e13a8fa843bab3c9ddf1b8051f426a1701e730249231", "seq": 0, "spec": "ADR-149"}
|
||||
{"abs_gain": "+9.38", "benchmark": "MM-Fi", "category": "pose", "caveat": "Protocol-matched MM-Fi random_split result; NOT solved real-world generalization. Random split has temporal/subject-adjacency effects common to this benchmark family. Leakage-free cross-subject is far lower (~11-27%) and is the real deployment frontier.", "harness_version": 1, "kind": "result", "metric": "torso-PCK@20 (||right_shoulder-left_hip|| norm, 17 COCO kpts)", "modality": "wifi-csi", "model_ref": "RuView CSI-Transformer (4L/8H ~2M params, temporal-attention)", "prev_hash": "940bdc6f0f5dd00f4d89e13a8fa843bab3c9ddf1b8051f426a1701e730249231", "protocol": "random_split (ratio=0.8, seed=0)", "rel_gain": "+13.0%", "reproduce": "download MM-Fi -> parse_mmfi_zips.py -> train_tf_torso.py X.npy Y.npy split_random.npy (seed 0)", "row_hash": "76598d8e1320d5248f8cd854a8ffa22a99bd2a2f0e0e7f2d2b1df79af16001d5", "score_pct": 81.63, "scored_at": "2026-05-30", "seq": 1, "sota_ref": "MultiFormer 72.25 (CSI2Pose 68.41)", "submitter": "ruvnet", "tier": "Gold"}
|
||||
{"abs_gain": "+11.34", "benchmark": "MM-Fi", "category": "pose", "harness_version": 1, "kind": "result", "metric": "torso-PCK@20", "modality": "wifi-csi", "model_ref": "RuView CSI-Transformer + skeleton-graph head + 3-ensemble + TTA", "note": "Best in-domain. Stacks attention-pooling + transformer + skeleton-graph refine + warmup + TTA + 3-model ensemble. Supersedes the 81.63 single-model entry.", "prev_hash": "76598d8e1320d5248f8cd854a8ffa22a99bd2a2f0e0e7f2d2b1df79af16001d5", "protocol": "random_split (0.8, seed 0)", "row_hash": "5780a4bc3e98eb0e30c1ecfa9091e57b280444fa1f21cd5146797e408580e4ab", "score_pct": 83.59, "scored_at": "2026-05-30", "seq": 2, "sota_ref": "MultiFormer 72.25 (CSI2Pose 68.41)", "submitter": "ruvnet", "tier": "Gold"}
|
||||
{"benchmark": "MM-Fi", "category": "pose", "harness_version": 1, "kind": "result", "metric": "torso-PCK@20", "modality": "wifi-csi", "model_ref": "RuView CSI-Transformer", "note": "Leakage-free generalization to unseen people, shared rooms. Honest deployment-relevant number.", "prev_hash": "5780a4bc3e98eb0e30c1ecfa9091e57b280444fa1f21cd5146797e408580e4ab", "protocol": "cross_subject (official, val=S05,S10,..,S40)", "row_hash": "d989e4e1dbc0182610305fdfbde8b094413b87c913283a46bf41f4afba7a06fd", "score_pct": 64.04, "scored_at": "2026-05-30", "seq": 3, "sota_ref": "(no matched public ref)", "submitter": "ruvnet", "tier": "Silver"}
|
||||
{"benchmark": "MM-Fi", "category": "pose", "harness_version": 1, "kind": "result", "metric": "torso-PCK@20", "modality": "wifi-csi", "model_ref": "RuView CSI-Transformer + CORAL domain alignment", "note": "The real deployment frontier (new room). CORAL transductive DG (+30% rel over control). Data-bound: MM-Fi has only 3 source rooms.", "prev_hash": "d989e4e1dbc0182610305fdfbde8b094413b87c913283a46bf41f4afba7a06fd", "protocol": "cross_environment (train E01-03 -> test E04, new room)", "row_hash": "bf370487bde88e198c13877956dab3c83766a6a24afef0b78b6ac7aa130bb207", "score_pct": 17.51, "scored_at": "2026-05-30", "seq": 4, "sota_ref": "(hard frontier; control 13.52)", "submitter": "ruvnet", "tier": "Bronze"}
|
||||
@@ -1 +0,0 @@
|
||||
gradio==5.9.1
|
||||
@@ -1,74 +0,0 @@
|
||||
# Archive
|
||||
|
||||
Frozen, no-longer-active components of RuView preserved for historical
|
||||
reference, reproducibility, and load-bearing legacy paths the active
|
||||
codebase still depends on.
|
||||
|
||||
## What lives here
|
||||
|
||||
| Path | What it is | Why it's archived | Still load-bearing? |
|
||||
|------|------------|-------------------|---------------------|
|
||||
| `v1/` | Original Python implementation of RuView (CSI processing, hardware adapters, services, FastAPI) | Superseded by the Rust workspace at `v2/`; ~810× slower in benchmarks. Kept rather than deleted because the deterministic proof bundle (`v1/data/proof/`) is part of the pre-merge witness verification process per ADR-011 / ADR-028. | **Yes — for the proof bundle only.** Active code lives in `v2/`. |
|
||||
|
||||
## What "archived" means
|
||||
|
||||
- **Do not add new features here.** New work goes in `v2/`.
|
||||
- **Do not refactor or modernize the archived code beyond what is
|
||||
strictly necessary** to keep the load-bearing paths working. The
|
||||
Python proof bundle is intentionally frozen so that its SHA-256
|
||||
reproducibility holds across releases (per ADR-028's witness
|
||||
verification requirement).
|
||||
- **Bug fixes inside archived code are allowed** when the bug affects a
|
||||
still-load-bearing path (currently: only the Python proof). All
|
||||
other "bugs" in archived code are out-of-scope — they are part of
|
||||
the historical record and any fix would unnecessarily churn the
|
||||
witness hashes.
|
||||
- **CI continues to verify the load-bearing paths.**
|
||||
`.github/workflows/verify-pipeline.yml` runs the Python proof on
|
||||
every push and PR; if you change anything inside `archive/v1/src/`
|
||||
or `archive/v1/data/proof/`, expect the determinism check to flag
|
||||
it.
|
||||
|
||||
## Quick reference for the load-bearing paths
|
||||
|
||||
```bash
|
||||
# Run the deterministic Python proof (must print VERDICT: PASS)
|
||||
python archive/v1/data/proof/verify.py
|
||||
|
||||
# Regenerate the expected hash (only if numpy/scipy version legitimately changed)
|
||||
python archive/v1/data/proof/verify.py --generate-hash
|
||||
|
||||
# Run the full Python test suite (legacy, still maintained)
|
||||
cd archive/v1&& python -m pytest tests/ -x -q
|
||||
```
|
||||
|
||||
## Why we keep `v1/` rather than delete it
|
||||
|
||||
1. **Trust kill-switch.** The proof at `v1/data/proof/verify.py` feeds
|
||||
a known reference signal through the full pipeline and hashes the
|
||||
output. If the active code's behavior drifts, the hash changes and
|
||||
CI fails. This is what stops accidental regression in the science
|
||||
layer of the codebase.
|
||||
|
||||
2. **Witness verification.** ADR-028's witness-bundle process bundles
|
||||
the proof, the rust workspace test results, and firmware hashes
|
||||
into a tarball recipients can self-verify. Removing v1 would break
|
||||
that chain.
|
||||
|
||||
3. **Historical reference.** ADR-011 documents the "no mocks in
|
||||
production code" decision; the original violations and their fixes
|
||||
live in this Python codebase. The ADRs reference these paths.
|
||||
|
||||
If the time comes to retire the proof bundle (e.g., a Rust port of
|
||||
the proof exists and the Python version is no longer canonical), the
|
||||
right move is a single follow-up that simultaneously: ports the
|
||||
witness-bundle process, updates `verify-pipeline.yml`, and either
|
||||
deletes `archive/v1/` or moves it to a separate read-only repository.
|
||||
That decision belongs in its own ADR.
|
||||
|
||||
## See also
|
||||
|
||||
- `docs/adr/ADR-011-python-proof-of-reality-mock-elimination.md`
|
||||
- `docs/adr/ADR-028-esp32-capability-audit.md`
|
||||
- `archive/v1/data/proof/README.md` (if present)
|
||||
- `docs/WITNESS-LOG-028.md`
|
||||
@@ -1,54 +0,0 @@
|
||||
# WiFi-DensePose v1 (Python Implementation)
|
||||
|
||||
This directory contains the original Python implementation of WiFi-DensePose.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
v1/
|
||||
├── src/ # Python source code
|
||||
│ ├── api/ # REST API endpoints
|
||||
│ ├── config/ # Configuration management
|
||||
│ ├── core/ # Core processing logic
|
||||
│ ├── database/ # Database models and migrations
|
||||
│ ├── hardware/ # Hardware interfaces
|
||||
│ ├── middleware/ # API middleware
|
||||
│ ├── models/ # Neural network models
|
||||
│ ├── services/ # Business logic services
|
||||
│ └── tasks/ # Background tasks
|
||||
├── tests/ # Test suite
|
||||
├── docs/ # Documentation
|
||||
├── scripts/ # Utility scripts
|
||||
├── data/ # Data files
|
||||
├── setup.py # Package setup
|
||||
├── test_application.py # Application tests
|
||||
└── test_auth_rate_limit.py # Auth/rate limit tests
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- PyTorch 2.0+
|
||||
- FastAPI
|
||||
- PostgreSQL/SQLite
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd v1
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Start API server
|
||||
python -m src.main
|
||||
|
||||
# Run tests
|
||||
pytest tests/
|
||||
```
|
||||
|
||||
## Note
|
||||
|
||||
This is the legacy Python implementation. For the new Rust implementation with improved performance, see `/v2/`.
|
||||
@@ -1,130 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CIR Verification Helper (ADR-134)
|
||||
|
||||
Optional Python comparator — invokes the Rust cir_proof_runner binary and
|
||||
checks its output against expected_cir_features.sha256.
|
||||
|
||||
Usage:
|
||||
python cir_verify_helper.py # verify against stored hash
|
||||
python cir_verify_helper.py --generate # regenerate hash via Rust binary
|
||||
|
||||
This script is a thin wrapper; all cryptographic work is done in the Rust
|
||||
binary. It exists to integrate the CIR proof step into the Python verify.py
|
||||
flow if needed.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..", "..", "..", ".."))
|
||||
|
||||
|
||||
def find_binary() -> str:
|
||||
"""Locate the cir_proof_runner binary."""
|
||||
candidates = [
|
||||
os.path.join(REPO_ROOT, "v2", "target", "release", "cir_proof_runner"),
|
||||
os.path.join(REPO_ROOT, "v2", "target", "release", "cir_proof_runner.exe"),
|
||||
os.path.join(REPO_ROOT, "v2", "target", "debug", "cir_proof_runner"),
|
||||
os.path.join(REPO_ROOT, "v2", "target", "debug", "cir_proof_runner.exe"),
|
||||
]
|
||||
for path in candidates:
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
return ""
|
||||
|
||||
|
||||
def build_binary() -> bool:
|
||||
"""Build the release binary via cargo."""
|
||||
print("Building cir_proof_runner (release)...")
|
||||
result = subprocess.run(
|
||||
[
|
||||
"cargo", "build",
|
||||
"-p", "wifi-densepose-signal",
|
||||
"--bin", "cir_proof_runner",
|
||||
"--release",
|
||||
"--no-default-features",
|
||||
],
|
||||
cwd=os.path.join(REPO_ROOT, "v2"),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print("Build failed:", result.stderr[-2000:])
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def run_generate(binary: str) -> str:
|
||||
"""Run the binary with --generate-hash; return the hex hash."""
|
||||
result = subprocess.run(
|
||||
[binary, "--generate-hash"],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print("Error running binary:", result.stderr)
|
||||
return ""
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def run_verify(binary: str) -> bool:
|
||||
"""Run the binary in verify mode; return True on PASS."""
|
||||
result = subprocess.run(
|
||||
[binary],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
print(result.stdout.strip())
|
||||
if result.stderr.strip():
|
||||
print(result.stderr.strip(), file=sys.stderr)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="CIR verification helper (ADR-134)")
|
||||
parser.add_argument(
|
||||
"--generate",
|
||||
action="store_true",
|
||||
help="Regenerate expected_cir_features.sha256 via Rust binary",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--build",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Build the binary before running (default: use cached binary)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
binary = find_binary()
|
||||
|
||||
if args.build or not binary:
|
||||
if not build_binary():
|
||||
sys.exit(1)
|
||||
binary = find_binary()
|
||||
|
||||
if not binary:
|
||||
print("ERROR: cir_proof_runner binary not found. Run with --build.")
|
||||
sys.exit(1)
|
||||
|
||||
if args.generate:
|
||||
hash_val = run_generate(binary)
|
||||
if not hash_val:
|
||||
sys.exit(1)
|
||||
hash_file = os.path.join(SCRIPT_DIR, "expected_cir_features.sha256")
|
||||
with open(hash_file, "w") as f:
|
||||
f.write(hash_val + "\n")
|
||||
print(f"Wrote CIR hash to {hash_file}")
|
||||
print(f"Hash: {hash_val}")
|
||||
else:
|
||||
ok = run_verify(binary)
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1 +0,0 @@
|
||||
d6bce07ecb1648e6936561df44bf4a3bfc17bb0ba5f692646b2301d105b52f67
|
||||
@@ -1 +0,0 @@
|
||||
304d54690af468dc6cbf0f2a1332f109cf187d5e2eab454efd8554cebc45bdeb
|
||||
@@ -1 +0,0 @@
|
||||
f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a
|
||||
Binary file not shown.
@@ -1,695 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Proof-of-Reality Verification Script for WiFi-DensePose Pipeline.
|
||||
|
||||
TRUST KILL SWITCH: A one-command proof replay that makes "it is mocked"
|
||||
a falsifiable, measurable claim that fails against evidence.
|
||||
|
||||
This script verifies that the signal processing pipeline produces
|
||||
DETERMINISTIC, REPRODUCIBLE output from a known reference signal.
|
||||
|
||||
Steps:
|
||||
1. Load the published reference CSI signal from sample_csi_data.json
|
||||
2. Feed each frame through the ACTUAL CSI processor feature extraction
|
||||
3. Collect all feature outputs into a canonical byte representation
|
||||
4. Compute SHA-256 hash of the full feature output
|
||||
5. Compare against the published expected hash in expected_features.sha256
|
||||
6. Print PASS or FAIL
|
||||
|
||||
The reference signal is SYNTHETIC (generated by generate_reference_signal.py)
|
||||
and is used purely for pipeline determinism verification. The point is not
|
||||
that the signal is real -- the point is that the PIPELINE CODE is real.
|
||||
The same code that processes this reference also processes live captures.
|
||||
|
||||
If someone claims "it is mocked":
|
||||
1. Run: ./verify
|
||||
2. If PASS: the pipeline code is the same code that produced the published hash
|
||||
3. If FAIL: something changed -- investigate
|
||||
|
||||
Usage:
|
||||
python verify.py # Run verification against stored hash
|
||||
python verify.py --verbose # Show detailed feature statistics
|
||||
python verify.py --audit # Scan codebase for mock/random patterns
|
||||
python verify.py --generate-hash # Generate and print the expected hash
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import argparse
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Add the v1 directory to sys.path so we can import the actual modules
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
V1_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, "..", "..")) # v1/data/proof -> v1/
|
||||
if V1_DIR not in sys.path:
|
||||
sys.path.insert(0, V1_DIR)
|
||||
|
||||
# Import the actual pipeline modules -- these are the PRODUCTION modules,
|
||||
# not test doubles. The source paths are printed below for verification.
|
||||
from src.hardware.csi_extractor import CSIData
|
||||
from src.core.csi_processor import CSIProcessor, CSIFeatures
|
||||
|
||||
|
||||
# -- Configuration for the CSI processor (matches production defaults) --
|
||||
PROCESSOR_CONFIG = {
|
||||
"sampling_rate": 100,
|
||||
"window_size": 56,
|
||||
"overlap": 0.5,
|
||||
"noise_threshold": -60,
|
||||
"human_detection_threshold": 0.8,
|
||||
"smoothing_factor": 0.9,
|
||||
"max_history_size": 500,
|
||||
"enable_preprocessing": True,
|
||||
"enable_feature_extraction": True,
|
||||
"enable_human_detection": True,
|
||||
}
|
||||
|
||||
# Number of frames to process for the feature hash.
|
||||
# We process a representative subset to keep verification fast while
|
||||
# still covering temporal dynamics (Doppler requires history).
|
||||
VERIFICATION_FRAME_COUNT = 100 # First 100 frames = 1 second
|
||||
|
||||
|
||||
def print_banner():
|
||||
"""Print the verification banner."""
|
||||
print("=" * 72)
|
||||
print(" WiFi-DensePose: Trust Kill Switch -- Pipeline Proof Replay")
|
||||
print("=" * 72)
|
||||
print()
|
||||
print(' "If the public demo is a one-command replay that produces a matching')
|
||||
print(' hash from a published real capture, \'it is mocked\' becomes a')
|
||||
print(' measurable claim that fails."')
|
||||
print()
|
||||
|
||||
|
||||
def print_source_provenance():
|
||||
"""Print the actual source file paths used by this verification.
|
||||
|
||||
This lets anyone confirm that the imported modules are the production
|
||||
code, not test doubles or mocks.
|
||||
"""
|
||||
csi_processor_file = inspect.getfile(CSIProcessor)
|
||||
csi_data_file = inspect.getfile(CSIData)
|
||||
csi_features_file = inspect.getfile(CSIFeatures)
|
||||
|
||||
print(" SOURCE PROVENANCE (verify these are production modules):")
|
||||
print(f" CSIProcessor : {os.path.abspath(csi_processor_file)}")
|
||||
print(f" CSIData : {os.path.abspath(csi_data_file)}")
|
||||
print(f" CSIFeatures : {os.path.abspath(csi_features_file)}")
|
||||
print(f" numpy : {np.__file__}")
|
||||
print(f" numpy version: {np.__version__}")
|
||||
|
||||
try:
|
||||
import scipy
|
||||
print(f" scipy : {scipy.__file__}")
|
||||
print(f" scipy version: {scipy.__version__}")
|
||||
except ImportError:
|
||||
print(" scipy : NOT AVAILABLE")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def load_reference_signal(data_path):
|
||||
"""Load the reference CSI signal from JSON.
|
||||
|
||||
Args:
|
||||
data_path: Path to sample_csi_data.json.
|
||||
|
||||
Returns:
|
||||
dict: Parsed JSON data.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the data file doesn't exist.
|
||||
json.JSONDecodeError: If the data is malformed.
|
||||
"""
|
||||
with open(data_path, "r") as f:
|
||||
data = json.load(f)
|
||||
return data
|
||||
|
||||
|
||||
def frame_to_csi_data(frame, signal_meta):
|
||||
"""Convert a JSON frame dict into a CSIData dataclass instance.
|
||||
|
||||
Args:
|
||||
frame: Dict with 'amplitude', 'phase', 'timestamp_s', 'frame_index'.
|
||||
signal_meta: Top-level signal metadata (num_antennas, frequency, etc).
|
||||
|
||||
Returns:
|
||||
CSIData instance.
|
||||
"""
|
||||
amplitude = np.array(frame["amplitude"], dtype=np.float64)
|
||||
phase = np.array(frame["phase"], dtype=np.float64)
|
||||
timestamp = datetime.fromtimestamp(frame["timestamp_s"], tz=timezone.utc)
|
||||
|
||||
return CSIData(
|
||||
timestamp=timestamp,
|
||||
amplitude=amplitude,
|
||||
phase=phase,
|
||||
frequency=signal_meta["frequency_hz"],
|
||||
bandwidth=signal_meta["bandwidth_hz"],
|
||||
num_subcarriers=signal_meta["num_subcarriers"],
|
||||
num_antennas=signal_meta["num_antennas"],
|
||||
snr=15.0, # Fixed SNR for synthetic signal
|
||||
metadata={
|
||||
"source": "synthetic_reference",
|
||||
"frame_index": frame["frame_index"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Quantization precision for cross-platform hash stability (issue #560).
|
||||
#
|
||||
# The bytes packed below feed SHA-256. Without quantization, the hash diverges
|
||||
# across SIMD backends (Intel AVX2/AVX-512 vs ARM NEON vs different x86 micro-
|
||||
# architectures in the same CI pool) because scipy.fft's pocketfft kernels
|
||||
# reorder vectorized FP operations differently per build. IEEE 754 guarantees
|
||||
# per-operation determinism, not associativity under reordering.
|
||||
#
|
||||
# Empirically: 9 decimals was NOT enough to collapse the divergence — two
|
||||
# back-to-back Ubuntu 24.04 / Python 3.11 / scipy 1.17 CI runs landed on
|
||||
# different Azure VM microarchitectures (likely Skylake vs Cascade Lake)
|
||||
# and produced two different SHA-256s even after np.round(.., 9). The DSP
|
||||
# pipeline (preprocess → biquad bandpass → FFT → PSD → variance accumulation)
|
||||
# amplifies the ~1e-14 raw FFT divergence by several orders of magnitude
|
||||
# downstream — the actual drift at features_to_bytes() input can reach 1e-7
|
||||
# or worse.
|
||||
#
|
||||
# 6 decimals (parts per million) gives ~6 orders of magnitude headroom over
|
||||
# observed pipeline-amplified ULP drift and is still far below any meaningful
|
||||
# signal change (CSI phase precision is ~1e-3 rad; PSD bins differ by orders
|
||||
# of magnitude). Round to this precision, then hash.
|
||||
#
|
||||
# NOTE: 6 decimals collapses the divergence *across Linux microarchitectures*
|
||||
# but NOT Windows-vs-Linux, where the pocketfft/BLAS difference exceeds 1e-6 on
|
||||
# a few elements that then straddle the 6th-decimal rounding boundary. The
|
||||
# precision is overridable via PROOF_HASH_DECIMALS so it can be coarsened to a
|
||||
# value that is boundary-stable across *all* platforms (Windows + Linux + macOS)
|
||||
# while staying far below any signal-meaningful change.
|
||||
HASH_QUANTIZATION_DECIMALS = int(os.environ.get("PROOF_HASH_DECIMALS", "6"))
|
||||
|
||||
|
||||
def features_to_bytes(features):
|
||||
"""Convert CSIFeatures to a deterministic byte representation.
|
||||
|
||||
Each feature array is quantized to ``HASH_QUANTIZATION_DECIMALS`` decimal
|
||||
places before being packed as little-endian float64. The quantization is
|
||||
what makes the resulting SHA-256 hash actually platform-independent — the
|
||||
raw float values diverge at ULP precision across scipy.fft SIMD backends
|
||||
(issue #560), even though all platforms compute the "correct" answer.
|
||||
|
||||
Args:
|
||||
features: CSIFeatures instance.
|
||||
|
||||
Returns:
|
||||
bytes: Canonical, quantized byte representation.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
# Serialize each feature array in declaration order.
|
||||
# doppler_shift is INTENTIONALLY excluded: it is peak-normalized
|
||||
# (`spectrum / max(spectrum)` in csi_processor._extract_doppler_features),
|
||||
# and when the raw spectrum has near-tied peaks the argmax flips under
|
||||
# cross-microarchitecture FP reordering, renormalizing the whole array
|
||||
# (O(1) divergence — not absorbable by any tolerance). The remaining five
|
||||
# features, including the FFT-based PSD, reproduce deterministically and
|
||||
# provide the proof. (The underlying doppler instability is a production
|
||||
# reproducibility bug tracked separately.)
|
||||
for array in [
|
||||
features.amplitude_mean,
|
||||
features.amplitude_variance,
|
||||
features.phase_difference,
|
||||
features.correlation_matrix,
|
||||
features.power_spectral_density,
|
||||
]:
|
||||
flat = np.asarray(array, dtype=np.float64).ravel()
|
||||
# Quantize before packing so SIMD-level FP reordering across
|
||||
# Intel AVX vs Apple Silicon NEON pocketfft kernels does not
|
||||
# leak into the SHA-256 input.
|
||||
flat = np.round(flat, HASH_QUANTIZATION_DECIMALS)
|
||||
# Pack as little-endian double (8 bytes each)
|
||||
parts.append(struct.pack(f"<{len(flat)}d", *flat))
|
||||
|
||||
return b"".join(parts)
|
||||
|
||||
|
||||
# ── Cross-platform tolerance gate (issue #560 follow-up) ─────────────────────
|
||||
# The SHA-256 of fixed-decimal-rounded features is bit-exact only WITHIN one
|
||||
# CPU microarchitecture. The pocketfft / BLAS kernels in the manylinux
|
||||
# numpy/scipy wheels reorder floating-point reductions differently across
|
||||
# microarchs (e.g. a GitHub Azure runner vs a developer box vs another Linux
|
||||
# host), and the resulting ~1e-6 *relative* drift lands on large-magnitude PSD
|
||||
# bins as an absolute difference too large for ANY fixed-decimal grid to absorb
|
||||
# (empirically the hash diverges across microarchs even at 2 decimals). So:
|
||||
# • the hash is the strong, bit-exact, SAME-platform proof, and
|
||||
# • a relative tolerance against a committed reference vector is the
|
||||
# platform-INDEPENDENT proof.
|
||||
# A run PASSES if either matches. Tolerances sit ~100x over the observed
|
||||
# microarch drift and ~10x under any signal-meaningful change (CSI phase
|
||||
# precision ~1e-3 rad), so real pipeline regressions still fail.
|
||||
TOLERANCE_RTOL = 1e-4
|
||||
TOLERANCE_ATOL = 1e-6
|
||||
REFERENCE_VECTOR_FILENAME = "expected_features_reference.npz"
|
||||
|
||||
|
||||
def features_to_vector(features):
|
||||
"""Concatenate a frame's feature arrays as raw float64 (no rounding).
|
||||
|
||||
Mirrors ``features_to_bytes`` ordering but keeps full precision, for the
|
||||
tolerance-based cross-platform comparison.
|
||||
"""
|
||||
# doppler_shift excluded — see features_to_bytes for the rationale
|
||||
# (peak-normalization argmax instability across CPU microarchitectures).
|
||||
arrays = [
|
||||
features.amplitude_mean,
|
||||
features.amplitude_variance,
|
||||
features.phase_difference,
|
||||
features.correlation_matrix,
|
||||
features.power_spectral_density,
|
||||
]
|
||||
return np.concatenate(
|
||||
[np.asarray(a, dtype=np.float64).ravel() for a in arrays]
|
||||
)
|
||||
|
||||
|
||||
def compute_pipeline_hash(data_path, verbose=False):
|
||||
"""Run the full pipeline and compute the SHA-256 hash of all features.
|
||||
|
||||
Args:
|
||||
data_path: Path to sample_csi_data.json.
|
||||
verbose: If True, print detailed feature statistics.
|
||||
|
||||
Returns:
|
||||
tuple: (hex_hash, stats_dict) where stats_dict contains metrics.
|
||||
"""
|
||||
# Load reference signal
|
||||
signal_data = load_reference_signal(data_path)
|
||||
frames = signal_data["frames"][:VERIFICATION_FRAME_COUNT]
|
||||
|
||||
print(f" Reference signal: {os.path.basename(data_path)}")
|
||||
print(f" Signal description: {signal_data.get('description', 'N/A')}")
|
||||
print(f" Generator: {signal_data.get('generator', 'N/A')} v{signal_data.get('generator_version', '?')}")
|
||||
print(f" Numpy seed used: {signal_data.get('numpy_seed', 'N/A')}")
|
||||
print(f" Total frames in file: {signal_data.get('num_frames', len(signal_data['frames']))}")
|
||||
print(f" Frames to process: {len(frames)}")
|
||||
print(f" Subcarriers: {signal_data.get('num_subcarriers', 'N/A')}")
|
||||
print(f" Antennas: {signal_data.get('num_antennas', 'N/A')}")
|
||||
print(f" Frequency: {signal_data.get('frequency_hz', 0) / 1e9:.3f} GHz")
|
||||
print(f" Bandwidth: {signal_data.get('bandwidth_hz', 0) / 1e6:.1f} MHz")
|
||||
print(f" Sampling rate: {signal_data.get('sampling_rate_hz', 'N/A')} Hz")
|
||||
print()
|
||||
|
||||
# Create processor with production config
|
||||
print(" Configuring CSIProcessor with production parameters...")
|
||||
processor = CSIProcessor(PROCESSOR_CONFIG)
|
||||
print(f" Window size: {processor.window_size}")
|
||||
print(f" Overlap: {processor.overlap}")
|
||||
print(f" Noise threshold: {processor.noise_threshold} dB")
|
||||
print(f" Preprocessing: {'ENABLED' if processor.enable_preprocessing else 'DISABLED'}")
|
||||
print(f" Feature extraction: {'ENABLED' if processor.enable_feature_extraction else 'DISABLED'}")
|
||||
print()
|
||||
|
||||
# Process all frames and accumulate feature bytes
|
||||
hasher = hashlib.sha256()
|
||||
features_count = 0
|
||||
total_feature_bytes = 0
|
||||
last_features = None
|
||||
feature_vectors = []
|
||||
doppler_nonzero_count = 0
|
||||
doppler_shape = None
|
||||
psd_shape = None
|
||||
|
||||
t_start = time.perf_counter()
|
||||
|
||||
for i, frame in enumerate(frames):
|
||||
csi_data = frame_to_csi_data(frame, signal_data)
|
||||
|
||||
# Run through the actual pipeline: preprocess -> extract features
|
||||
preprocessed = processor.preprocess_csi_data(csi_data)
|
||||
features = processor.extract_features(preprocessed)
|
||||
|
||||
if features is not None:
|
||||
feature_bytes = features_to_bytes(features)
|
||||
hasher.update(feature_bytes)
|
||||
feature_vectors.append(features_to_vector(features))
|
||||
features_count += 1
|
||||
total_feature_bytes += len(feature_bytes)
|
||||
last_features = features
|
||||
|
||||
# Track Doppler statistics
|
||||
doppler_shape = features.doppler_shift.shape
|
||||
doppler_nonzero_count = int(np.count_nonzero(features.doppler_shift))
|
||||
psd_shape = features.power_spectral_density.shape
|
||||
|
||||
# Add to history for Doppler computation in subsequent frames
|
||||
processor.add_to_history(csi_data)
|
||||
|
||||
if verbose and (i + 1) % 25 == 0:
|
||||
print(f" ... processed frame {i + 1}/{len(frames)}")
|
||||
|
||||
t_elapsed = time.perf_counter() - t_start
|
||||
|
||||
print(f" Processing complete.")
|
||||
print(f" Frames processed: {len(frames)}")
|
||||
print(f" Feature vectors extracted: {features_count}")
|
||||
print(f" Total feature bytes hashed: {total_feature_bytes:,}")
|
||||
print(f" Processing time: {t_elapsed:.4f}s ({len(frames) / t_elapsed:.0f} frames/sec)")
|
||||
print()
|
||||
|
||||
# Print feature vector details
|
||||
if last_features is not None:
|
||||
print(" FEATURE VECTOR DETAILS (from last frame):")
|
||||
print(f" amplitude_mean : shape={last_features.amplitude_mean.shape}, "
|
||||
f"min={np.min(last_features.amplitude_mean):.6f}, "
|
||||
f"max={np.max(last_features.amplitude_mean):.6f}, "
|
||||
f"mean={np.mean(last_features.amplitude_mean):.6f}")
|
||||
print(f" amplitude_variance : shape={last_features.amplitude_variance.shape}, "
|
||||
f"min={np.min(last_features.amplitude_variance):.6f}, "
|
||||
f"max={np.max(last_features.amplitude_variance):.6f}")
|
||||
print(f" phase_difference : shape={last_features.phase_difference.shape}, "
|
||||
f"mean={np.mean(last_features.phase_difference):.6f}")
|
||||
print(f" correlation_matrix : shape={last_features.correlation_matrix.shape}")
|
||||
print(f" doppler_shift : shape={doppler_shape}, "
|
||||
f"non-zero bins={doppler_nonzero_count}/{doppler_shape[0] if doppler_shape else 0}")
|
||||
print(f" power_spectral_density: shape={psd_shape}")
|
||||
print()
|
||||
|
||||
if verbose:
|
||||
print(" DOPPLER SPECTRUM (proves real FFT, not random):")
|
||||
ds = last_features.doppler_shift
|
||||
print(f" First 8 bins: {ds[:8]}")
|
||||
print(f" Sum: {np.sum(ds):.6f}")
|
||||
print(f" Max bin index: {np.argmax(ds)}")
|
||||
print(f" Spectral entropy: {-np.sum(ds[ds > 0] * np.log2(ds[ds > 0] + 1e-15)):.4f}")
|
||||
print()
|
||||
|
||||
print(" PSD DETAILS (proves scipy.fft, not random):")
|
||||
psd = last_features.power_spectral_density
|
||||
print(f" First 8 bins: {psd[:8]}")
|
||||
print(f" Total power: {np.sum(psd):.4f}")
|
||||
print(f" Peak frequency bin: {np.argmax(psd)}")
|
||||
print()
|
||||
|
||||
stats = {
|
||||
"frames_processed": len(frames),
|
||||
"features_extracted": features_count,
|
||||
"total_bytes_hashed": total_feature_bytes,
|
||||
"elapsed_seconds": t_elapsed,
|
||||
"doppler_shape": doppler_shape,
|
||||
"doppler_nonzero": doppler_nonzero_count,
|
||||
"psd_shape": psd_shape,
|
||||
}
|
||||
|
||||
reference_vector = (
|
||||
np.concatenate(feature_vectors) if feature_vectors else np.array([], dtype=np.float64)
|
||||
)
|
||||
|
||||
return hasher.hexdigest(), reference_vector, stats
|
||||
|
||||
|
||||
def audit_codebase(base_dir=None):
|
||||
"""Scan the production codebase for mock/random patterns.
|
||||
|
||||
Looks for:
|
||||
- np.random.rand / np.random.randn calls (outside testing/)
|
||||
- mock/Mock imports (outside testing/)
|
||||
- random.random() calls (outside testing/)
|
||||
|
||||
Args:
|
||||
base_dir: Root directory to scan. Defaults to v1/src/.
|
||||
|
||||
Returns:
|
||||
list of (filepath, line_number, line_text, pattern_type) tuples.
|
||||
"""
|
||||
if base_dir is None:
|
||||
base_dir = os.path.join(V1_DIR, "src")
|
||||
|
||||
suspicious_patterns = [
|
||||
("np.random.rand", "RANDOM_GENERATOR"),
|
||||
("np.random.randn", "RANDOM_GENERATOR"),
|
||||
("np.random.random", "RANDOM_GENERATOR"),
|
||||
("np.random.uniform", "RANDOM_GENERATOR"),
|
||||
("np.random.normal", "RANDOM_GENERATOR"),
|
||||
("np.random.choice", "RANDOM_GENERATOR"),
|
||||
("random.random(", "RANDOM_GENERATOR"),
|
||||
("random.randint(", "RANDOM_GENERATOR"),
|
||||
("from unittest.mock import", "MOCK_IMPORT"),
|
||||
("from unittest import mock", "MOCK_IMPORT"),
|
||||
("import mock", "MOCK_IMPORT"),
|
||||
("MagicMock", "MOCK_USAGE"),
|
||||
("@patch(", "MOCK_USAGE"),
|
||||
("@mock.patch", "MOCK_USAGE"),
|
||||
]
|
||||
|
||||
# Directories to exclude from the audit
|
||||
excluded_dirs = {"testing", "tests", "test", "__pycache__", ".git"}
|
||||
|
||||
findings = []
|
||||
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
# Skip excluded directories
|
||||
dirs[:] = [d for d in dirs if d not in excluded_dirs]
|
||||
|
||||
for fname in files:
|
||||
if not fname.endswith(".py"):
|
||||
continue
|
||||
|
||||
fpath = os.path.join(root, fname)
|
||||
try:
|
||||
with open(fpath, "r", encoding="utf-8", errors="replace") as f:
|
||||
for line_num, line in enumerate(f, 1):
|
||||
for pattern, ptype in suspicious_patterns:
|
||||
if pattern in line:
|
||||
findings.append((fpath, line_num, line.rstrip(), ptype))
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def main():
|
||||
"""Main verification entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="WiFi-DensePose Trust Kill Switch -- Pipeline Proof Replay"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--generate-hash",
|
||||
action="store_true",
|
||||
help="Generate and print the expected hash (do not verify)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="Show detailed feature statistics and Doppler spectrum",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--audit",
|
||||
action="store_true",
|
||||
help="Scan production codebase for mock/random patterns",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print_banner()
|
||||
|
||||
# Locate data file
|
||||
data_path = os.path.join(SCRIPT_DIR, "sample_csi_data.json")
|
||||
hash_path = os.path.join(SCRIPT_DIR, "expected_features.sha256")
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Step 0: Print source provenance
|
||||
# ---------------------------------------------------------------
|
||||
print("[0/4] SOURCE PROVENANCE")
|
||||
print_source_provenance()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Step 1: Load and describe reference signal
|
||||
# ---------------------------------------------------------------
|
||||
print("[1/4] LOADING REFERENCE SIGNAL")
|
||||
if not os.path.exists(data_path):
|
||||
print(f" FAIL: Reference data not found at {data_path}")
|
||||
print(" Run generate_reference_signal.py first.")
|
||||
sys.exit(1)
|
||||
print(f" Path: {data_path}")
|
||||
print(f" Size: {os.path.getsize(data_path):,} bytes")
|
||||
print()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Step 2: Process through the real pipeline
|
||||
# ---------------------------------------------------------------
|
||||
print("[2/4] PROCESSING THROUGH PRODUCTION PIPELINE")
|
||||
print(" This runs the SAME CSIProcessor.preprocess_csi_data() and")
|
||||
print(" CSIProcessor.extract_features() used in production.")
|
||||
print()
|
||||
computed_hash, computed_vector, stats = compute_pipeline_hash(data_path, verbose=args.verbose)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Step 3: Hash comparison
|
||||
# ---------------------------------------------------------------
|
||||
print("[3/4] SHA-256 HASH COMPARISON")
|
||||
print(f" Computed: {computed_hash}")
|
||||
|
||||
if args.generate_hash:
|
||||
with open(hash_path, "w") as f:
|
||||
f.write(computed_hash + "\n")
|
||||
print(f" Wrote expected hash to {hash_path}")
|
||||
ref_path = os.path.join(SCRIPT_DIR, REFERENCE_VECTOR_FILENAME)
|
||||
np.savez_compressed(ref_path, features=computed_vector)
|
||||
print(f" Wrote reference vector ({computed_vector.size} values) to {ref_path}")
|
||||
print()
|
||||
print(" HASH + REFERENCE GENERATED -- run without --generate-hash to verify.")
|
||||
print("=" * 72)
|
||||
return
|
||||
|
||||
if not os.path.exists(hash_path):
|
||||
print(f" WARNING: No expected hash file at {hash_path}")
|
||||
print(f" Computed hash: {computed_hash}")
|
||||
print()
|
||||
print(" Run with --generate-hash to create the expected hash file.")
|
||||
print()
|
||||
print(" SKIP (no expected hash to compare against)")
|
||||
print("=" * 72)
|
||||
sys.exit(2)
|
||||
|
||||
with open(hash_path, "r") as f:
|
||||
expected_hash = f.read().strip()
|
||||
|
||||
print(f" Expected: {expected_hash}")
|
||||
|
||||
hash_match = computed_hash == expected_hash
|
||||
|
||||
# Cross-platform fallback: if the bit-exact hash differs (different CPU
|
||||
# microarchitecture reorders the pocketfft/BLAS reductions), accept the run
|
||||
# when the raw feature vector matches the committed reference within a
|
||||
# relative tolerance — platform-independent where the hash is not (#560).
|
||||
tolerance_match = False
|
||||
max_abs_dev = None
|
||||
max_rel_dev = None
|
||||
ref_path = os.path.join(SCRIPT_DIR, REFERENCE_VECTOR_FILENAME)
|
||||
if not hash_match and os.path.exists(ref_path):
|
||||
ref_vec = np.load(ref_path)["features"]
|
||||
if ref_vec.shape == computed_vector.shape:
|
||||
tolerance_match = bool(
|
||||
np.allclose(
|
||||
computed_vector, ref_vec, rtol=TOLERANCE_RTOL, atol=TOLERANCE_ATOL
|
||||
)
|
||||
)
|
||||
diff = np.abs(computed_vector - ref_vec)
|
||||
max_abs_dev = float(np.max(diff)) if diff.size else 0.0
|
||||
max_rel_dev = (
|
||||
float(np.max(diff / np.maximum(np.abs(ref_vec), 1e-12)))
|
||||
if diff.size
|
||||
else 0.0
|
||||
)
|
||||
|
||||
if hash_match:
|
||||
match_status = "MATCH (bit-exact)"
|
||||
elif tolerance_match:
|
||||
match_status = f"TOLERANCE MATCH (max rel dev {max_rel_dev:.2e})"
|
||||
else:
|
||||
match_status = "MISMATCH"
|
||||
print(f" Status: {match_status}")
|
||||
print()
|
||||
|
||||
if not hash_match and max_abs_dev is not None:
|
||||
block_sizes = [56, 56, 55, 9, 128] # per-frame feature layout (doppler excluded)
|
||||
block_names = ["amp_mean", "amp_var", "phase_diff", "corr", "psd"]
|
||||
frame_len = sum(block_sizes)
|
||||
tol = TOLERANCE_ATOL + TOLERANCE_RTOL * np.abs(ref_vec)
|
||||
outside = diff > tol
|
||||
n_out = int(outside.sum())
|
||||
print(
|
||||
f" DIVERGENCE: {n_out}/{computed_vector.size} outside tol "
|
||||
f"({100.0 * n_out / computed_vector.size:.4f}%) "
|
||||
f"max|d|={max_abs_dev:.3e} maxrel={max_rel_dev:.3e}"
|
||||
)
|
||||
if n_out:
|
||||
wf = np.where(outside)[0] % frame_len
|
||||
bounds = np.cumsum([0] + block_sizes)
|
||||
parts = []
|
||||
for bi, name in enumerate(block_names):
|
||||
c = int(((wf >= bounds[bi]) & (wf < bounds[bi + 1])).sum())
|
||||
if c:
|
||||
parts.append(f"{name}={c}")
|
||||
print(f" by feature: {', '.join(parts)}")
|
||||
for w in np.argsort(diff)[::-1][:4]:
|
||||
b = int(np.searchsorted(bounds, int(w) % frame_len, side="right")) - 1
|
||||
print(
|
||||
f" worst idx {int(w)} ({block_names[b]}): "
|
||||
f"ref={ref_vec[int(w)]:.6g} got={computed_vector[int(w)]:.6g}"
|
||||
)
|
||||
print()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Step 4: Audit (if requested or always in full mode)
|
||||
# ---------------------------------------------------------------
|
||||
if args.audit:
|
||||
print("[4/4] CODEBASE AUDIT -- scanning for mock/random patterns")
|
||||
findings = audit_codebase()
|
||||
if findings:
|
||||
print(f" Found {len(findings)} suspicious pattern(s) in production code:")
|
||||
for fpath, line_num, line, ptype in findings:
|
||||
relpath = os.path.relpath(fpath, V1_DIR)
|
||||
print(f" [{ptype}] {relpath}:{line_num}: {line.strip()}")
|
||||
else:
|
||||
print(" CLEAN -- no mock/random patterns found in production code.")
|
||||
print()
|
||||
else:
|
||||
print("[4/4] CODEBASE AUDIT (skipped -- use --audit to enable)")
|
||||
print()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Final verdict
|
||||
# ---------------------------------------------------------------
|
||||
print("=" * 72)
|
||||
if hash_match or tolerance_match:
|
||||
print(" VERDICT: PASS")
|
||||
print()
|
||||
if hash_match:
|
||||
print(" The pipeline produced a SHA-256 hash that matches the published")
|
||||
print(" expected hash (bit-exact). This proves:")
|
||||
else:
|
||||
print(" The bit-exact hash differs (CPU-microarchitecture FP reordering),")
|
||||
print(" but the raw feature vector matches the published reference within")
|
||||
print(
|
||||
f" rtol={TOLERANCE_RTOL:g} / atol={TOLERANCE_ATOL:g} "
|
||||
f"(max rel dev {max_rel_dev:.2e}). This proves:"
|
||||
)
|
||||
print(" 1. The SAME signal processing code ran on the reference signal")
|
||||
print(" 2. The output is DETERMINISTIC (same input -> same output)")
|
||||
print(" 3. No randomness was introduced")
|
||||
print(" 4. The code path includes: noise removal, Hamming windowing,")
|
||||
print(" amplitude normalization, FFT-based Doppler extraction,")
|
||||
print(" and power spectral density computation")
|
||||
print()
|
||||
print(f" Pipeline hash: {computed_hash}")
|
||||
print("=" * 72)
|
||||
sys.exit(0)
|
||||
else:
|
||||
print(" VERDICT: FAIL")
|
||||
print()
|
||||
print(" The pipeline output does NOT match the expected hash OR the")
|
||||
print(" reference feature vector within tolerance.")
|
||||
if max_rel_dev is not None:
|
||||
print(
|
||||
f" max abs dev: {max_abs_dev:.3e} max rel dev: {max_rel_dev:.3e}"
|
||||
f" (rtol={TOLERANCE_RTOL:g}, atol={TOLERANCE_ATOL:g})"
|
||||
)
|
||||
print()
|
||||
print(" Possible causes:")
|
||||
print(" - Code change in CSI processor that alters numerical output")
|
||||
print(" - A real (non-microarch) numerical regression")
|
||||
print()
|
||||
print(" To update after an intentional change:")
|
||||
print(" python verify.py --generate-hash")
|
||||
print("=" * 72)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,19 +0,0 @@
|
||||
# WiFi-DensePose Pipeline Verification - Pinned Dependencies
|
||||
# These versions are locked to ensure deterministic pipeline output.
|
||||
# The proof bundle (v1/data/proof/) depends on exact numerical behavior
|
||||
# from these libraries. Changing versions may alter floating-point results
|
||||
# and require regenerating the expected hash.
|
||||
#
|
||||
# To update: change versions, run `python v1/data/proof/verify.py --generate-hash`,
|
||||
# then commit the new expected_features.sha256.
|
||||
#
|
||||
# numpy/scipy track the versions the *published* expected hash
|
||||
# (expected_features.sha256 = ca58956c…) was generated with — modern numpy 2.x,
|
||||
# i.e. what a fresh `pip install numpy` and the proof-of-capabilities.md skeptic
|
||||
# path produce today. The old 1.26.4 pin no longer matched that hash and made
|
||||
# the determinism gate fail against its own published proof.
|
||||
|
||||
numpy==2.4.2
|
||||
scipy==1.17.1
|
||||
pydantic==2.10.4
|
||||
pydantic-settings==2.7.1
|
||||
@@ -1,307 +0,0 @@
|
||||
"""
|
||||
JWT Authentication middleware for WiFi-DensePose API
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from src.config.settings import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
"""JWT Authentication middleware."""
|
||||
|
||||
def __init__(self, app):
|
||||
super().__init__(app)
|
||||
self.settings = get_settings()
|
||||
|
||||
# Paths that don't require authentication
|
||||
self.public_paths = {
|
||||
"/",
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
"/health",
|
||||
"/ready",
|
||||
"/live",
|
||||
"/version",
|
||||
"/metrics"
|
||||
}
|
||||
|
||||
# Paths that require authentication
|
||||
self.protected_paths = {
|
||||
"/api/v1/pose/analyze",
|
||||
"/api/v1/pose/calibrate",
|
||||
"/api/v1/pose/historical",
|
||||
"/api/v1/stream/start",
|
||||
"/api/v1/stream/stop",
|
||||
"/api/v1/stream/clients",
|
||||
"/api/v1/stream/broadcast"
|
||||
}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
"""Process request through authentication middleware."""
|
||||
|
||||
# Skip authentication for public paths
|
||||
if self._is_public_path(request.url.path):
|
||||
return await call_next(request)
|
||||
|
||||
# Extract and validate token
|
||||
token = self._extract_token(request)
|
||||
|
||||
if token:
|
||||
try:
|
||||
# Verify token and add user info to request state
|
||||
user_data = await self._verify_token(token)
|
||||
request.state.user = user_data
|
||||
request.state.authenticated = True
|
||||
|
||||
logger.debug(f"Authenticated user: {user_data.get('id')}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Token validation failed: {e}")
|
||||
|
||||
# For protected paths, return 401
|
||||
if self._is_protected_path(request.url.path):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": {
|
||||
"code": 401,
|
||||
"message": "Invalid or expired token",
|
||||
"type": "authentication_error"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# For other paths, continue without authentication
|
||||
request.state.user = None
|
||||
request.state.authenticated = False
|
||||
else:
|
||||
# No token provided
|
||||
if self._is_protected_path(request.url.path):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": {
|
||||
"code": 401,
|
||||
"message": "Authentication required",
|
||||
"type": "authentication_error"
|
||||
}
|
||||
},
|
||||
headers={"WWW-Authenticate": "Bearer"}
|
||||
)
|
||||
|
||||
request.state.user = None
|
||||
request.state.authenticated = False
|
||||
|
||||
# Continue with request processing
|
||||
response = await call_next(request)
|
||||
|
||||
# Add authentication headers to response
|
||||
if hasattr(request.state, 'user') and request.state.user:
|
||||
response.headers["X-User-ID"] = request.state.user.get("id", "")
|
||||
response.headers["X-Authenticated"] = "true"
|
||||
else:
|
||||
response.headers["X-Authenticated"] = "false"
|
||||
|
||||
return response
|
||||
|
||||
def _is_public_path(self, path: str) -> bool:
|
||||
"""Check if path is public (doesn't require authentication)."""
|
||||
# Exact match
|
||||
if path in self.public_paths:
|
||||
return True
|
||||
|
||||
# Pattern matching for public paths
|
||||
public_patterns = [
|
||||
"/health",
|
||||
"/metrics",
|
||||
"/api/v1/pose/current", # Allow anonymous access to current pose data
|
||||
"/api/v1/pose/zones/", # Allow anonymous access to zone data
|
||||
"/api/v1/pose/activities", # Allow anonymous access to activities
|
||||
"/api/v1/pose/stats", # Allow anonymous access to stats
|
||||
"/api/v1/stream/status" # Allow anonymous access to stream status
|
||||
]
|
||||
|
||||
for pattern in public_patterns:
|
||||
if path.startswith(pattern):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _is_protected_path(self, path: str) -> bool:
|
||||
"""Check if path requires authentication."""
|
||||
# Exact match
|
||||
if path in self.protected_paths:
|
||||
return True
|
||||
|
||||
# Pattern matching for protected paths
|
||||
protected_patterns = [
|
||||
"/api/v1/pose/analyze",
|
||||
"/api/v1/pose/calibrate",
|
||||
"/api/v1/pose/historical",
|
||||
"/api/v1/stream/start",
|
||||
"/api/v1/stream/stop",
|
||||
"/api/v1/stream/clients",
|
||||
"/api/v1/stream/broadcast"
|
||||
]
|
||||
|
||||
for pattern in protected_patterns:
|
||||
if path.startswith(pattern):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _extract_token(self, request: Request) -> Optional[str]:
|
||||
"""Extract JWT token from request."""
|
||||
# Check Authorization header
|
||||
auth_header = request.headers.get("authorization")
|
||||
if auth_header and auth_header.startswith("Bearer "):
|
||||
return auth_header.split(" ")[1]
|
||||
|
||||
# Check query parameter (for WebSocket connections)
|
||||
token = request.query_params.get("token")
|
||||
if token:
|
||||
return token
|
||||
|
||||
# Check cookie
|
||||
token = request.cookies.get("access_token")
|
||||
if token:
|
||||
return token
|
||||
|
||||
return None
|
||||
|
||||
async def _verify_token(self, token: str) -> Dict[str, Any]:
|
||||
"""Verify JWT token and return user data."""
|
||||
try:
|
||||
# Decode JWT token
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
self.settings.secret_key,
|
||||
algorithms=[self.settings.jwt_algorithm]
|
||||
)
|
||||
|
||||
# Check token blacklist (logout invalidation)
|
||||
if token_blacklist.is_blacklisted(token):
|
||||
raise ValueError("Token has been revoked")
|
||||
|
||||
# Extract user information
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise ValueError("Token missing user ID")
|
||||
|
||||
# Check token expiration
|
||||
exp = payload.get("exp")
|
||||
if exp and datetime.utcnow() > datetime.fromtimestamp(exp):
|
||||
raise ValueError("Token expired")
|
||||
|
||||
# Build user object
|
||||
user_data = {
|
||||
"id": user_id,
|
||||
"username": payload.get("username"),
|
||||
"email": payload.get("email"),
|
||||
"is_admin": payload.get("is_admin", False),
|
||||
"permissions": payload.get("permissions", []),
|
||||
"accessible_zones": payload.get("accessible_zones", []),
|
||||
"token_issued_at": payload.get("iat"),
|
||||
"token_expires_at": payload.get("exp"),
|
||||
"session_id": payload.get("session_id")
|
||||
}
|
||||
|
||||
return user_data
|
||||
|
||||
except JWTError as e:
|
||||
raise ValueError(f"JWT validation failed: {e}")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Token verification error: {e}")
|
||||
|
||||
# TODO: Wire up authentication event logging in dispatch() for
|
||||
# security monitoring (login failures, token expiry, etc.).
|
||||
|
||||
|
||||
class TokenBlacklist:
|
||||
"""Simple in-memory token blacklist for logout functionality."""
|
||||
|
||||
def __init__(self):
|
||||
self._blacklisted_tokens = set()
|
||||
self._cleanup_interval = 3600 # 1 hour
|
||||
self._last_cleanup = datetime.utcnow()
|
||||
|
||||
def add_token(self, token: str):
|
||||
"""Add token to blacklist."""
|
||||
self._blacklisted_tokens.add(token)
|
||||
self._cleanup_if_needed()
|
||||
|
||||
def is_blacklisted(self, token: str) -> bool:
|
||||
"""Check if token is blacklisted."""
|
||||
self._cleanup_if_needed()
|
||||
return token in self._blacklisted_tokens
|
||||
|
||||
def _cleanup_if_needed(self):
|
||||
"""Clean up expired tokens from blacklist."""
|
||||
now = datetime.utcnow()
|
||||
if (now - self._last_cleanup).total_seconds() > self._cleanup_interval:
|
||||
# In a real implementation, you would check token expiration
|
||||
# For now, we'll just clear old tokens periodically
|
||||
self._blacklisted_tokens.clear()
|
||||
self._last_cleanup = now
|
||||
|
||||
|
||||
# Global token blacklist instance
|
||||
token_blacklist = TokenBlacklist()
|
||||
|
||||
|
||||
class SecurityHeaders:
|
||||
"""Security headers for API responses."""
|
||||
|
||||
@staticmethod
|
||||
def add_security_headers(response: Response) -> Response:
|
||||
"""Add security headers to response."""
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; "
|
||||
"connect-src 'self' ws: wss:;"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class APIKeyAuth:
|
||||
"""Alternative API key authentication for service-to-service communication."""
|
||||
|
||||
def __init__(self, api_keys: Dict[str, Dict[str, Any]] = None):
|
||||
self.api_keys = api_keys or {}
|
||||
|
||||
def verify_api_key(self, api_key: str) -> Optional[Dict[str, Any]]:
|
||||
"""Verify API key and return associated service info."""
|
||||
if api_key in self.api_keys:
|
||||
return self.api_keys[api_key]
|
||||
return None
|
||||
|
||||
def add_api_key(self, api_key: str, service_info: Dict[str, Any]):
|
||||
"""Add new API key."""
|
||||
self.api_keys[api_key] = service_info
|
||||
|
||||
def revoke_api_key(self, api_key: str):
|
||||
"""Revoke API key."""
|
||||
if api_key in self.api_keys:
|
||||
del self.api_keys[api_key]
|
||||
|
||||
|
||||
# Global API key auth instance
|
||||
api_key_auth = APIKeyAuth()
|
||||
@@ -1,7 +0,0 @@
|
||||
"""
|
||||
API routers package
|
||||
"""
|
||||
|
||||
from . import pose, stream, health, auth
|
||||
|
||||
__all__ = ["pose", "stream", "health", "auth"]
|
||||
@@ -1,32 +0,0 @@
|
||||
"""
|
||||
Authentication router for WiFi-DensePose API.
|
||||
Provides logout (token blacklisting) endpoint.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, status
|
||||
|
||||
from src.api.middleware.auth import token_blacklist
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request):
|
||||
"""Logout by blacklisting the current Bearer token."""
|
||||
auth_header = request.headers.get("authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing or invalid Authorization header",
|
||||
)
|
||||
|
||||
token = auth_header.split(" ", 1)[1]
|
||||
token_blacklist.add_token(token)
|
||||
logger.info("Token blacklisted via /auth/logout")
|
||||
|
||||
return {"success": True, "message": "Token revoked"}
|
||||
@@ -1,656 +0,0 @@
|
||||
"""CSI data extraction from WiFi hardware using Test-Driven Development approach."""
|
||||
|
||||
import asyncio
|
||||
import struct
|
||||
import numpy as np
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, Optional, Callable, Protocol
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
|
||||
class CSIParseError(Exception):
|
||||
"""Exception raised for CSI parsing errors."""
|
||||
pass
|
||||
|
||||
|
||||
class CSIValidationError(Exception):
|
||||
"""Exception raised for CSI validation errors."""
|
||||
pass
|
||||
|
||||
|
||||
class CSIExtractionError(Exception):
|
||||
"""Exception raised when CSI data extraction fails.
|
||||
|
||||
This error is raised instead of silently returning random/placeholder data.
|
||||
Callers should handle this to inform users that real hardware data is required.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class CSIData:
|
||||
"""Data structure for CSI measurements."""
|
||||
timestamp: datetime
|
||||
amplitude: np.ndarray
|
||||
phase: np.ndarray
|
||||
frequency: float
|
||||
bandwidth: float
|
||||
num_subcarriers: int
|
||||
num_antennas: int
|
||||
snr: float
|
||||
metadata: Dict[str, Any]
|
||||
|
||||
|
||||
class CSIParser(Protocol):
|
||||
"""Protocol for CSI data parsers."""
|
||||
|
||||
def parse(self, raw_data: bytes) -> CSIData:
|
||||
"""Parse raw CSI data into structured format."""
|
||||
...
|
||||
|
||||
|
||||
class ESP32CSIParser:
|
||||
"""Parser for ESP32 CSI data format."""
|
||||
|
||||
def parse(self, raw_data: bytes) -> CSIData:
|
||||
"""Parse ESP32 CSI data format.
|
||||
|
||||
Args:
|
||||
raw_data: Raw bytes from ESP32
|
||||
|
||||
Returns:
|
||||
Parsed CSI data
|
||||
|
||||
Raises:
|
||||
CSIParseError: If data format is invalid
|
||||
"""
|
||||
if not raw_data:
|
||||
raise CSIParseError("Empty data received")
|
||||
|
||||
try:
|
||||
data_str = raw_data.decode('utf-8')
|
||||
if not data_str.startswith('CSI_DATA:'):
|
||||
raise CSIParseError("Invalid ESP32 CSI data format")
|
||||
|
||||
# Parse ESP32 format: CSI_DATA:timestamp,antennas,subcarriers,freq,bw,snr,[amp],[phase]
|
||||
parts = data_str[9:].split(',') # Remove 'CSI_DATA:' prefix
|
||||
|
||||
timestamp_ms = int(parts[0])
|
||||
num_antennas = int(parts[1])
|
||||
num_subcarriers = int(parts[2])
|
||||
frequency_mhz = float(parts[3])
|
||||
bandwidth_mhz = float(parts[4])
|
||||
snr = float(parts[5])
|
||||
|
||||
# Convert to proper units
|
||||
frequency = frequency_mhz * 1e6 # MHz to Hz
|
||||
bandwidth = bandwidth_mhz * 1e6 # MHz to Hz
|
||||
|
||||
# Parse amplitude and phase arrays from the remaining CSV fields.
|
||||
# Expected format after the header fields: comma-separated float values
|
||||
# representing interleaved amplitude and phase per antenna per subcarrier.
|
||||
data_values = parts[6:]
|
||||
expected_values = num_antennas * num_subcarriers * 2 # amplitude + phase
|
||||
|
||||
if len(data_values) < expected_values:
|
||||
raise CSIExtractionError(
|
||||
f"ESP32 CSI data incomplete: expected {expected_values} values "
|
||||
f"(amplitude + phase for {num_antennas} antennas x {num_subcarriers} subcarriers), "
|
||||
f"but received {len(data_values)} values. "
|
||||
"Ensure the ESP32 firmware is configured to output full CSI matrix data. "
|
||||
"See docs/hardware-setup.md for ESP32 CSI configuration."
|
||||
)
|
||||
|
||||
try:
|
||||
float_values = [float(v) for v in data_values[:expected_values]]
|
||||
except ValueError as ve:
|
||||
raise CSIExtractionError(
|
||||
f"ESP32 CSI data contains non-numeric values: {ve}. "
|
||||
"Raw CSI fields must be numeric float values."
|
||||
)
|
||||
|
||||
all_values = np.array(float_values)
|
||||
amplitude = all_values[:num_antennas * num_subcarriers].reshape(num_antennas, num_subcarriers)
|
||||
phase = all_values[num_antennas * num_subcarriers:].reshape(num_antennas, num_subcarriers)
|
||||
|
||||
return CSIData(
|
||||
timestamp=datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc),
|
||||
amplitude=amplitude,
|
||||
phase=phase,
|
||||
frequency=frequency,
|
||||
bandwidth=bandwidth,
|
||||
num_subcarriers=num_subcarriers,
|
||||
num_antennas=num_antennas,
|
||||
snr=snr,
|
||||
metadata={'source': 'esp32', 'raw_length': len(raw_data)}
|
||||
)
|
||||
|
||||
except (ValueError, IndexError) as e:
|
||||
raise CSIParseError(f"Failed to parse ESP32 data: {e}")
|
||||
|
||||
|
||||
class ESP32BinaryParser:
|
||||
"""Parser for ADR-018 binary CSI frames from ESP32 nodes.
|
||||
|
||||
Binary frame format:
|
||||
Offset Size Field
|
||||
0 4 Magic: 0xC5110001 (LE)
|
||||
4 1 Node ID
|
||||
5 1 Number of antennas
|
||||
6 2 Number of subcarriers (LE u16)
|
||||
8 4 Frequency MHz (LE u32)
|
||||
12 4 Sequence number (LE u32)
|
||||
16 1 RSSI (i8)
|
||||
17 1 Noise floor (i8)
|
||||
18 1 PPDU type (ADR-110): 0=HT/legacy, 1=HE-SU, 2=HE-MU,
|
||||
3=HE-TB, 0xFF=unknown. Pre-ADR-110 firmware sends 0.
|
||||
19 1 Flags (ADR-110): bit 0 = bw40, bit 2 = STBC,
|
||||
bit 3 = LDPC, bit 4 = cross-node sync valid
|
||||
(set by either c6_timesync OR c6_sync_espnow
|
||||
since v0.7.0 — ADR-110 §A0.13).
|
||||
20 N*2 I/Q pairs (n_antennas * n_subcarriers * 2 bytes, signed i8)
|
||||
|
||||
Sibling packet (ADR-110 §A0.12, firmware v0.6.9+): the node also
|
||||
emits a 32-byte UDP sync packet (magic 0xC511A110) every
|
||||
CONFIG_C6_SYNC_EVERY_N_FRAMES frames on the same UDP socket.
|
||||
See parse_sync_packet() / SyncPacket below.
|
||||
"""
|
||||
|
||||
MAGIC = 0xC5110001
|
||||
HEADER_SIZE = 20
|
||||
# ADR-110: previously '<IBBHIIBB2x' (last 2 bytes skipped as reserved).
|
||||
# Now read those 2 bytes as PPDU type + flags. Pre-ADR-110 firmware
|
||||
# sends zeros, which decode as 'HT/legacy' + 'no flags' — fully
|
||||
# backwards compatible.
|
||||
HEADER_FMT = '<IBBHIIBBBB' # +2 bytes: ppdu_type, flags
|
||||
|
||||
# ADR-110 PPDU type byte values
|
||||
PPDU_HT_LEGACY = 0
|
||||
PPDU_HE_SU = 1
|
||||
PPDU_HE_MU = 2
|
||||
PPDU_HE_TB = 3
|
||||
PPDU_UNKNOWN = 0xFF
|
||||
_PPDU_NAMES = {0: 'ht_legacy', 1: 'he_su', 2: 'he_mu', 3: 'he_tb', 0xFF: 'unknown'}
|
||||
|
||||
def parse(self, raw_data: bytes) -> CSIData:
|
||||
"""Parse an ADR-018 binary frame into CSIData.
|
||||
|
||||
Args:
|
||||
raw_data: Raw binary frame bytes.
|
||||
|
||||
Returns:
|
||||
Parsed CSI data with amplitude/phase arrays shaped (n_antennas, n_subcarriers).
|
||||
|
||||
Raises:
|
||||
CSIParseError: If frame is too short, has invalid magic, or malformed I/Q data.
|
||||
"""
|
||||
if len(raw_data) < self.HEADER_SIZE:
|
||||
raise CSIParseError(
|
||||
f"Frame too short: need {self.HEADER_SIZE} bytes, got {len(raw_data)}"
|
||||
)
|
||||
|
||||
magic, node_id, n_antennas, n_subcarriers, freq_mhz, sequence, rssi_u8, noise_u8, \
|
||||
ppdu_byte, flags_byte = struct.unpack_from(self.HEADER_FMT, raw_data, 0)
|
||||
|
||||
if magic != self.MAGIC:
|
||||
raise CSIParseError(
|
||||
f"Invalid magic: expected 0x{self.MAGIC:08X}, got 0x{magic:08X}"
|
||||
)
|
||||
|
||||
# Convert unsigned bytes to signed i8
|
||||
rssi = rssi_u8 if rssi_u8 < 128 else rssi_u8 - 256
|
||||
noise_floor = noise_u8 if noise_u8 < 128 else noise_u8 - 256
|
||||
|
||||
iq_count = n_antennas * n_subcarriers
|
||||
iq_bytes = iq_count * 2
|
||||
expected_len = self.HEADER_SIZE + iq_bytes
|
||||
|
||||
if len(raw_data) < expected_len:
|
||||
raise CSIParseError(
|
||||
f"Frame too short for I/Q data: need {expected_len} bytes, got {len(raw_data)}"
|
||||
)
|
||||
|
||||
# Parse I/Q pairs as signed bytes
|
||||
iq_raw = struct.unpack_from(f'<{iq_count * 2}b', raw_data, self.HEADER_SIZE)
|
||||
i_vals = np.array(iq_raw[0::2], dtype=np.float64).reshape(n_antennas, n_subcarriers)
|
||||
q_vals = np.array(iq_raw[1::2], dtype=np.float64).reshape(n_antennas, n_subcarriers)
|
||||
|
||||
amplitude = np.sqrt(i_vals ** 2 + q_vals ** 2)
|
||||
phase = np.arctan2(q_vals, i_vals)
|
||||
|
||||
snr = float(rssi - noise_floor)
|
||||
frequency = float(freq_mhz) * 1e6
|
||||
bandwidth = 20e6 # default; could infer from n_subcarriers
|
||||
|
||||
if n_subcarriers <= 56:
|
||||
bandwidth = 20e6
|
||||
elif n_subcarriers <= 114:
|
||||
bandwidth = 40e6
|
||||
elif n_subcarriers <= 242:
|
||||
bandwidth = 80e6
|
||||
else:
|
||||
bandwidth = 160e6
|
||||
|
||||
return CSIData(
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
amplitude=amplitude,
|
||||
phase=phase,
|
||||
frequency=frequency,
|
||||
bandwidth=bandwidth,
|
||||
num_subcarriers=n_subcarriers,
|
||||
num_antennas=n_antennas,
|
||||
snr=snr,
|
||||
metadata={
|
||||
'source': 'esp32_binary',
|
||||
'node_id': node_id,
|
||||
'sequence': sequence,
|
||||
'rssi_dbm': rssi,
|
||||
'noise_floor_dbm': noise_floor,
|
||||
'channel_freq_mhz': freq_mhz,
|
||||
# ADR-110 extension — zeros from pre-ADR-110 firmware land here as
|
||||
# 'ht_legacy' + all-flags-false. New consumers can branch on
|
||||
# ppdu_type / he_capable for HE-LTF-aware DSP.
|
||||
'ppdu_type': self._PPDU_NAMES.get(ppdu_byte, 'unknown'),
|
||||
'ppdu_type_raw': ppdu_byte,
|
||||
'he_capable': ppdu_byte in (1, 2, 3),
|
||||
'bw40': bool(flags_byte & 0x01),
|
||||
'stbc': bool(flags_byte & 0x04),
|
||||
'ldpc': bool(flags_byte & 0x08),
|
||||
'ieee802154_sync_valid': bool(flags_byte & 0x10),
|
||||
'adr018_flags_raw': flags_byte,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncPacket:
|
||||
"""ADR-110 §A0.12 sync packet (firmware v0.6.9+, magic 0xC511A110).
|
||||
|
||||
Emitted on the same UDP socket as CSI frames every
|
||||
CONFIG_C6_SYNC_EVERY_N_FRAMES frames. Carries the mesh-aligned
|
||||
epoch for the node alongside the high-water CSI sequence number,
|
||||
so the host aggregator can pair (node_id, sequence) across the two
|
||||
packet streams and recover a mesh-aligned timestamp for every CSI
|
||||
frame. See WITNESS-LOG-110 §A0.12 for the live verification.
|
||||
"""
|
||||
node_id: int
|
||||
proto_ver: int
|
||||
is_leader: bool
|
||||
is_valid: bool
|
||||
smoothed_used: bool
|
||||
local_us: int # u64 — node's local esp_timer_get_time()
|
||||
epoch_us: int # u64 — local + EMA-smoothed offset (mesh time)
|
||||
sequence: int # u32 — high-water CSI sequence at emit time
|
||||
flags_raw: int
|
||||
|
||||
def local_minus_epoch_us(self) -> int:
|
||||
"""Signed local-vs-mesh clock offset in µs.
|
||||
|
||||
Negative when this node's clock is behind the leader's (typical
|
||||
for followers). Equal to ≈0 on the leader (modulo call-stack µs).
|
||||
Matches Rust's `SyncPacket::local_minus_epoch_us` byte-for-byte.
|
||||
"""
|
||||
return self.local_us - self.epoch_us
|
||||
|
||||
def apply_to_local(self, local_at_frame_us: int) -> int:
|
||||
"""Recover a mesh-aligned timestamp for any node-local µs snapshot.
|
||||
|
||||
Math (see WITNESS-LOG-110 §A0.10 / §A0.12):
|
||||
offset = epoch_us - local_us (signed; this packet)
|
||||
mesh = local_at_frame_us + offset
|
||||
|
||||
Identical contract to Rust's `SyncPacket::apply_to_local`.
|
||||
Identity at `local_at_frame_us == self.local_us` returns `epoch_us`.
|
||||
"""
|
||||
offset = self.epoch_us - self.local_us
|
||||
return local_at_frame_us + offset
|
||||
|
||||
def mesh_aligned_us_for_sequence(self, frame_seq: int, fps_hz: float) -> int:
|
||||
"""ADR-110 §A0.12 — recover the mesh-aligned timestamp for an
|
||||
in-flight CSI frame by its sequence number.
|
||||
|
||||
Pairs the frame's sequence number against this sync packet's
|
||||
sequence high-water + an assumed/measured CSI rate. Matches the
|
||||
Rust implementation byte-for-byte at the integer level (Python
|
||||
rounds via `int()` truncation; for the canonical bench values
|
||||
this is exact).
|
||||
"""
|
||||
if fps_hz <= 0:
|
||||
raise ValueError(f"fps_hz must be positive, got {fps_hz}")
|
||||
# Wrap to handle u32 sequence overflow the same way Rust does.
|
||||
dframes = (frame_seq - self.sequence) & 0xFFFFFFFF
|
||||
if dframes >= 0x80000000:
|
||||
dframes -= 0x1_0000_0000
|
||||
dus = int(dframes * 1_000_000 / fps_hz)
|
||||
local_at = self.local_us + dus
|
||||
return self.apply_to_local(local_at)
|
||||
|
||||
|
||||
class SyncPacketParser:
|
||||
"""Parser for ADR-110 §A0.12 32-byte sync packets.
|
||||
|
||||
Distinguished from CSI frames by the leading magic. Callers should
|
||||
dispatch incoming UDP datagrams based on the first 4 bytes:
|
||||
|
||||
magic = struct.unpack_from('<I', data, 0)[0]
|
||||
if magic == ESP32BinaryParser.MAGIC: # 0xC5110001 — CSI frame
|
||||
...
|
||||
elif magic == SyncPacketParser.MAGIC: # 0xC511A110 — sync packet
|
||||
...
|
||||
"""
|
||||
|
||||
MAGIC = 0xC511A110
|
||||
SIZE = 32
|
||||
# <IBBBB QQ IB3x>
|
||||
# I=magic, B=node_id, B=proto_ver, B=flags, B=reserved,
|
||||
# Q=local_us, Q=epoch_us, I=sequence, B+3x=reserved
|
||||
HEADER_FMT = '<IBBBBQQI4x'
|
||||
|
||||
@classmethod
|
||||
def parse(cls, raw_data: bytes) -> SyncPacket:
|
||||
if len(raw_data) < cls.SIZE:
|
||||
raise CSIParseError(
|
||||
f"Sync packet too short: {len(raw_data)} bytes, need {cls.SIZE}"
|
||||
)
|
||||
magic, node_id, proto_ver, flags_byte, _, local_us, epoch_us, seq = \
|
||||
struct.unpack_from(cls.HEADER_FMT, raw_data, 0)
|
||||
if magic != cls.MAGIC:
|
||||
raise CSIParseError(f"Sync magic mismatch: got 0x{magic:08x}")
|
||||
return SyncPacket(
|
||||
node_id=node_id,
|
||||
proto_ver=proto_ver,
|
||||
is_leader=bool(flags_byte & 0x01),
|
||||
is_valid=bool(flags_byte & 0x02),
|
||||
smoothed_used=bool(flags_byte & 0x04),
|
||||
local_us=local_us,
|
||||
epoch_us=epoch_us,
|
||||
sequence=seq,
|
||||
flags_raw=flags_byte,
|
||||
)
|
||||
|
||||
|
||||
class RouterCSIParser:
|
||||
"""Parser for router CSI data format."""
|
||||
|
||||
def parse(self, raw_data: bytes) -> CSIData:
|
||||
"""Parse router CSI data format.
|
||||
|
||||
Args:
|
||||
raw_data: Raw bytes from router
|
||||
|
||||
Returns:
|
||||
Parsed CSI data
|
||||
|
||||
Raises:
|
||||
CSIParseError: If data format is invalid
|
||||
"""
|
||||
if not raw_data:
|
||||
raise CSIParseError("Empty data received")
|
||||
|
||||
# Handle different router formats
|
||||
data_str = raw_data.decode('utf-8')
|
||||
|
||||
if data_str.startswith('ATHEROS_CSI:'):
|
||||
return self._parse_atheros_format(raw_data)
|
||||
else:
|
||||
raise CSIParseError("Unknown router CSI format")
|
||||
|
||||
def _parse_atheros_format(self, raw_data: bytes) -> CSIData:
|
||||
"""Parse Atheros CSI format.
|
||||
|
||||
Raises:
|
||||
CSIExtractionError: Always, because Atheros CSI parsing requires
|
||||
the Atheros CSI Tool binary format parser which has not been
|
||||
implemented yet. Use the ESP32 parser or contribute an
|
||||
Atheros implementation.
|
||||
"""
|
||||
raise CSIExtractionError(
|
||||
"Atheros CSI format parsing is not yet implemented. "
|
||||
"The Atheros CSI Tool outputs a binary format that requires a dedicated parser. "
|
||||
"To collect real CSI data from Atheros-based routers, you must implement "
|
||||
"the binary format parser following the Atheros CSI Tool specification. "
|
||||
"See docs/hardware-setup.md for supported hardware and data formats."
|
||||
)
|
||||
|
||||
|
||||
class CSIExtractor:
|
||||
"""Main CSI data extractor supporting multiple hardware types."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any], logger: Optional[logging.Logger] = None):
|
||||
"""Initialize CSI extractor.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary
|
||||
logger: Optional logger instance
|
||||
|
||||
Raises:
|
||||
ValueError: If configuration is invalid
|
||||
"""
|
||||
self._validate_config(config)
|
||||
|
||||
self.config = config
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.hardware_type = config['hardware_type']
|
||||
self.sampling_rate = config['sampling_rate']
|
||||
self.buffer_size = config['buffer_size']
|
||||
self.timeout = config['timeout']
|
||||
self.validation_enabled = config.get('validation_enabled', True)
|
||||
self.retry_attempts = config.get('retry_attempts', 3)
|
||||
|
||||
# State management
|
||||
self.is_connected = False
|
||||
self.is_streaming = False
|
||||
|
||||
# Create appropriate parser
|
||||
if self.hardware_type == 'esp32':
|
||||
if config.get('parser_format') == 'binary':
|
||||
self.parser = ESP32BinaryParser()
|
||||
else:
|
||||
self.parser = ESP32CSIParser()
|
||||
elif self.hardware_type == 'router':
|
||||
self.parser = RouterCSIParser()
|
||||
else:
|
||||
raise ValueError(f"Unsupported hardware type: {self.hardware_type}")
|
||||
|
||||
def _validate_config(self, config: Dict[str, Any]) -> None:
|
||||
"""Validate configuration parameters.
|
||||
|
||||
Args:
|
||||
config: Configuration to validate
|
||||
|
||||
Raises:
|
||||
ValueError: If configuration is invalid
|
||||
"""
|
||||
required_fields = ['hardware_type', 'sampling_rate', 'buffer_size', 'timeout']
|
||||
missing_fields = [field for field in required_fields if field not in config]
|
||||
|
||||
if missing_fields:
|
||||
raise ValueError(f"Missing required configuration: {missing_fields}")
|
||||
|
||||
if config['sampling_rate'] <= 0:
|
||||
raise ValueError("sampling_rate must be positive")
|
||||
|
||||
if config['buffer_size'] <= 0:
|
||||
raise ValueError("buffer_size must be positive")
|
||||
|
||||
if config['timeout'] <= 0:
|
||||
raise ValueError("timeout must be positive")
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Establish connection to CSI hardware.
|
||||
|
||||
Returns:
|
||||
True if connection successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
success = await self._establish_hardware_connection()
|
||||
self.is_connected = success
|
||||
return success
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to connect to hardware: {e}")
|
||||
self.is_connected = False
|
||||
return False
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from CSI hardware."""
|
||||
if self.is_connected:
|
||||
await self._close_hardware_connection()
|
||||
self.is_connected = False
|
||||
|
||||
async def extract_csi(self) -> CSIData:
|
||||
"""Extract CSI data from hardware.
|
||||
|
||||
Returns:
|
||||
Extracted CSI data
|
||||
|
||||
Raises:
|
||||
CSIParseError: If not connected or extraction fails
|
||||
"""
|
||||
if not self.is_connected:
|
||||
raise CSIParseError("Not connected to hardware")
|
||||
|
||||
# Retry mechanism for temporary failures
|
||||
for attempt in range(self.retry_attempts):
|
||||
try:
|
||||
raw_data = await self._read_raw_data()
|
||||
csi_data = self.parser.parse(raw_data)
|
||||
|
||||
if self.validation_enabled:
|
||||
self.validate_csi_data(csi_data)
|
||||
|
||||
return csi_data
|
||||
|
||||
except ConnectionError as e:
|
||||
if attempt < self.retry_attempts - 1:
|
||||
self.logger.warning(f"Extraction attempt {attempt + 1} failed, retrying: {e}")
|
||||
await asyncio.sleep(0.1) # Brief delay before retry
|
||||
else:
|
||||
raise CSIParseError(f"Extraction failed after {self.retry_attempts} attempts: {e}")
|
||||
|
||||
def validate_csi_data(self, csi_data: CSIData) -> bool:
|
||||
"""Validate CSI data structure and values.
|
||||
|
||||
Args:
|
||||
csi_data: CSI data to validate
|
||||
|
||||
Returns:
|
||||
True if valid
|
||||
|
||||
Raises:
|
||||
CSIValidationError: If data is invalid
|
||||
"""
|
||||
if csi_data.amplitude.size == 0:
|
||||
raise CSIValidationError("Empty amplitude data")
|
||||
|
||||
if csi_data.phase.size == 0:
|
||||
raise CSIValidationError("Empty phase data")
|
||||
|
||||
if csi_data.frequency <= 0:
|
||||
raise CSIValidationError("Invalid frequency")
|
||||
|
||||
if csi_data.bandwidth <= 0:
|
||||
raise CSIValidationError("Invalid bandwidth")
|
||||
|
||||
if csi_data.num_subcarriers <= 0:
|
||||
raise CSIValidationError("Invalid number of subcarriers")
|
||||
|
||||
if csi_data.num_antennas <= 0:
|
||||
raise CSIValidationError("Invalid number of antennas")
|
||||
|
||||
if csi_data.snr < -50 or csi_data.snr > 50: # Reasonable SNR range
|
||||
raise CSIValidationError("Invalid SNR value")
|
||||
|
||||
return True
|
||||
|
||||
async def start_streaming(self, callback: Callable[[CSIData], None]) -> None:
|
||||
"""Start streaming CSI data.
|
||||
|
||||
Args:
|
||||
callback: Function to call with each CSI sample
|
||||
"""
|
||||
self.is_streaming = True
|
||||
|
||||
try:
|
||||
while self.is_streaming:
|
||||
csi_data = await self.extract_csi()
|
||||
callback(csi_data)
|
||||
await asyncio.sleep(1.0 / self.sampling_rate)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Streaming error: {e}")
|
||||
finally:
|
||||
self.is_streaming = False
|
||||
|
||||
def stop_streaming(self) -> None:
|
||||
"""Stop streaming CSI data."""
|
||||
self.is_streaming = False
|
||||
|
||||
async def _establish_hardware_connection(self) -> bool:
|
||||
"""Establish connection to hardware (to be implemented by subclasses)."""
|
||||
# Placeholder implementation for testing
|
||||
return True
|
||||
|
||||
async def _close_hardware_connection(self) -> None:
|
||||
"""Close hardware connection (to be implemented by subclasses)."""
|
||||
# Placeholder implementation for testing
|
||||
pass
|
||||
|
||||
async def _read_raw_data(self) -> bytes:
|
||||
"""Read raw data from hardware.
|
||||
|
||||
When parser_format='binary', reads from the configured UDP socket.
|
||||
Otherwise returns placeholder text data for legacy compatibility.
|
||||
|
||||
Raises:
|
||||
CSIExtractionError: If UDP read times out or fails.
|
||||
"""
|
||||
if self.config.get('parser_format') == 'binary':
|
||||
return await self._read_udp_data()
|
||||
# Placeholder implementation for legacy text-mode testing
|
||||
return b"CSI_DATA:1234567890,3,56,2400,20,15.5,[1.0,2.0,3.0],[0.5,1.5,2.5]"
|
||||
|
||||
async def _read_udp_data(self) -> bytes:
|
||||
"""Read a single UDP packet from the aggregator.
|
||||
|
||||
Raises:
|
||||
CSIExtractionError: If read times out or connection fails.
|
||||
"""
|
||||
host = self.config.get('aggregator_host', '0.0.0.0')
|
||||
port = self.config.get('aggregator_port', 5005)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Create UDP endpoint if not already cached
|
||||
if not hasattr(self, '_udp_transport'):
|
||||
self._udp_future: asyncio.Future = loop.create_future()
|
||||
|
||||
class _UdpProtocol(asyncio.DatagramProtocol):
|
||||
def __init__(self, future):
|
||||
self._future = future
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
if not self._future.done():
|
||||
self._future.set_result(data)
|
||||
|
||||
def error_received(self, exc):
|
||||
if not self._future.done():
|
||||
self._future.set_exception(exc)
|
||||
|
||||
transport, protocol = await loop.create_datagram_endpoint(
|
||||
lambda: _UdpProtocol(self._udp_future),
|
||||
local_addr=(host, port),
|
||||
)
|
||||
self._udp_transport = transport
|
||||
self._udp_protocol = protocol
|
||||
|
||||
try:
|
||||
data = await asyncio.wait_for(self._udp_future, timeout=self.timeout)
|
||||
# Reset future for next read
|
||||
self._udp_future = loop.create_future()
|
||||
self._udp_protocol._future = self._udp_future
|
||||
return data
|
||||
except asyncio.TimeoutError:
|
||||
raise CSIExtractionError(
|
||||
f"UDP read timed out after {self.timeout}s. "
|
||||
f"Ensure the aggregator is running and sending to {host}:{port}."
|
||||
)
|
||||
@@ -1,457 +0,0 @@
|
||||
"""
|
||||
Authentication middleware for WiFi-DensePose API
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional, Dict, Any, Callable
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import Request, Response, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from src.config.settings import Settings
|
||||
from src.logger import set_request_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Password hashing
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# JWT token handler
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
"""Authentication error."""
|
||||
pass
|
||||
|
||||
|
||||
class AuthorizationError(Exception):
|
||||
"""Authorization error."""
|
||||
pass
|
||||
|
||||
|
||||
class TokenManager:
|
||||
"""JWT token management."""
|
||||
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
self.secret_key = settings.secret_key
|
||||
self.algorithm = settings.jwt_algorithm
|
||||
self.expire_hours = settings.jwt_expire_hours
|
||||
|
||||
def create_access_token(self, data: Dict[str, Any]) -> str:
|
||||
"""Create JWT access token."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(hours=self.expire_hours)
|
||||
to_encode.update({"exp": expire, "iat": datetime.utcnow()})
|
||||
|
||||
encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm)
|
||||
return encoded_jwt
|
||||
|
||||
def verify_token(self, token: str) -> Dict[str, Any]:
|
||||
"""Verify and decode JWT token."""
|
||||
try:
|
||||
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
|
||||
# Check token blacklist (logout invalidation)
|
||||
from src.api.middleware.auth import token_blacklist
|
||||
if token_blacklist.is_blacklisted(token):
|
||||
raise AuthenticationError("Token has been revoked")
|
||||
return payload
|
||||
except JWTError as e:
|
||||
logger.warning(f"JWT verification failed: {e}")
|
||||
raise AuthenticationError("Invalid token")
|
||||
|
||||
def decode_token_claims(self, token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Decode and verify token, returning its claims.
|
||||
|
||||
Unlike the previous implementation, this method always verifies
|
||||
the token signature. Use verify_token() for full validation
|
||||
including expiry checks; this helper is provided only for
|
||||
inspecting claims from an already-verified token.
|
||||
"""
|
||||
try:
|
||||
return jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
class UserManager:
|
||||
"""User management for authentication."""
|
||||
|
||||
def __init__(self):
|
||||
# In a real application, this would connect to a database.
|
||||
# No default users are created -- users must be provisioned
|
||||
# through the create_user() method or an external identity provider.
|
||||
self._users: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
@staticmethod
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
@staticmethod
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash."""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
def get_user(self, username: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get user by username."""
|
||||
return self._users.get(username)
|
||||
|
||||
def authenticate_user(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
"""Authenticate user with username and password."""
|
||||
user = self.get_user(username)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
if not self.verify_password(password, user["hashed_password"]):
|
||||
return None
|
||||
|
||||
if not user.get("is_active", False):
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
def create_user(self, username: str, email: str, password: str, roles: list = None) -> Dict[str, Any]:
|
||||
"""Create a new user."""
|
||||
if username in self._users:
|
||||
raise ValueError("User already exists")
|
||||
|
||||
user = {
|
||||
"username": username,
|
||||
"email": email,
|
||||
"hashed_password": self.hash_password(password),
|
||||
"roles": roles or ["user"],
|
||||
"is_active": True,
|
||||
"created_at": datetime.utcnow(),
|
||||
}
|
||||
|
||||
self._users[username] = user
|
||||
return user
|
||||
|
||||
def update_user(self, username: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Update user information."""
|
||||
user = self._users.get(username)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
# Don't allow updating certain fields
|
||||
protected_fields = {"username", "created_at", "hashed_password"}
|
||||
updates = {k: v for k, v in updates.items() if k not in protected_fields}
|
||||
|
||||
user.update(updates)
|
||||
return user
|
||||
|
||||
def deactivate_user(self, username: str) -> bool:
|
||||
"""Deactivate a user."""
|
||||
user = self._users.get(username)
|
||||
if user:
|
||||
user["is_active"] = False
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class AuthenticationMiddleware(BaseHTTPMiddleware):
|
||||
"""Authentication middleware for FastAPI."""
|
||||
|
||||
def __init__(self, app, settings: Settings):
|
||||
super().__init__(app)
|
||||
self.settings = settings
|
||||
self.token_manager = TokenManager(settings)
|
||||
self.user_manager = UserManager()
|
||||
self.enabled = settings.enable_authentication
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
"""Process request through authentication middleware."""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Skip authentication for certain paths
|
||||
if self._should_skip_auth(request):
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
# Skip if authentication is disabled
|
||||
if not self.enabled:
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
# Extract and verify token
|
||||
user_info = await self._authenticate_request(request)
|
||||
|
||||
# Set user context
|
||||
if user_info:
|
||||
request.state.user = user_info
|
||||
set_request_context(user_id=user_info.get("username"))
|
||||
|
||||
# Process request
|
||||
response = await call_next(request)
|
||||
|
||||
# Add authentication headers
|
||||
self._add_auth_headers(response, user_info)
|
||||
|
||||
return response
|
||||
|
||||
except AuthenticationError as e:
|
||||
logger.warning(f"Authentication failed: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e),
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
except AuthorizationError as e:
|
||||
logger.warning(f"Authorization failed: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Authentication middleware error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Authentication service error",
|
||||
)
|
||||
finally:
|
||||
# Log request processing time
|
||||
processing_time = time.time() - start_time
|
||||
logger.debug(f"Auth middleware processing time: {processing_time:.3f}s")
|
||||
|
||||
def _should_skip_auth(self, request: Request) -> bool:
|
||||
"""Check if authentication should be skipped for this request."""
|
||||
path = request.url.path
|
||||
|
||||
# Skip authentication for these paths
|
||||
skip_paths = [
|
||||
"/health",
|
||||
"/metrics",
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
"/auth/login",
|
||||
"/auth/register",
|
||||
"/static",
|
||||
]
|
||||
|
||||
return any(path.startswith(skip_path) for skip_path in skip_paths)
|
||||
|
||||
async def _authenticate_request(self, request: Request) -> Optional[Dict[str, Any]]:
|
||||
"""Authenticate the request and return user info."""
|
||||
# Try to get token from Authorization header
|
||||
authorization = request.headers.get("Authorization")
|
||||
|
||||
if not authorization:
|
||||
if self._requires_auth(request):
|
||||
raise AuthenticationError("Missing authorization header")
|
||||
return None
|
||||
|
||||
# Extract token
|
||||
try:
|
||||
scheme, token = authorization.split()
|
||||
if scheme.lower() != "bearer":
|
||||
raise AuthenticationError("Invalid authentication scheme")
|
||||
except ValueError:
|
||||
raise AuthenticationError("Invalid authorization header format")
|
||||
|
||||
# Verify token
|
||||
try:
|
||||
payload = self.token_manager.verify_token(token)
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
raise AuthenticationError("Invalid token payload")
|
||||
|
||||
# Get user info
|
||||
user = self.user_manager.get_user(username)
|
||||
if not user:
|
||||
raise AuthenticationError("User not found")
|
||||
|
||||
if not user.get("is_active", False):
|
||||
raise AuthenticationError("User account is disabled")
|
||||
|
||||
# Return user info without sensitive data
|
||||
return {
|
||||
"username": user["username"],
|
||||
"email": user["email"],
|
||||
"roles": user["roles"],
|
||||
"is_active": user["is_active"],
|
||||
}
|
||||
|
||||
except AuthenticationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Token verification error: {e}")
|
||||
raise AuthenticationError("Token verification failed")
|
||||
|
||||
def _requires_auth(self, request: Request) -> bool:
|
||||
"""Check if the request requires authentication."""
|
||||
# All API endpoints require authentication by default
|
||||
path = request.url.path
|
||||
return path.startswith("/api/") or path.startswith("/ws/")
|
||||
|
||||
def _add_auth_headers(self, response: Response, user_info: Optional[Dict[str, Any]]):
|
||||
"""Add authentication-related headers to response."""
|
||||
if user_info:
|
||||
response.headers["X-User"] = user_info["username"]
|
||||
response.headers["X-User-Roles"] = ",".join(user_info["roles"])
|
||||
|
||||
async def login(self, username: str, password: str) -> Dict[str, Any]:
|
||||
"""Authenticate user and return token."""
|
||||
user = self.user_manager.authenticate_user(username, password)
|
||||
if not user:
|
||||
raise AuthenticationError("Invalid username or password")
|
||||
|
||||
# Create token
|
||||
token_data = {
|
||||
"sub": user["username"],
|
||||
"email": user["email"],
|
||||
"roles": user["roles"],
|
||||
}
|
||||
|
||||
access_token = self.token_manager.create_access_token(token_data)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": self.settings.jwt_expire_hours * 3600,
|
||||
"user": {
|
||||
"username": user["username"],
|
||||
"email": user["email"],
|
||||
"roles": user["roles"],
|
||||
}
|
||||
}
|
||||
|
||||
async def register(self, username: str, email: str, password: str) -> Dict[str, Any]:
|
||||
"""Register a new user."""
|
||||
try:
|
||||
user = self.user_manager.create_user(username, email, password)
|
||||
|
||||
# Create token for new user
|
||||
token_data = {
|
||||
"sub": user["username"],
|
||||
"email": user["email"],
|
||||
"roles": user["roles"],
|
||||
}
|
||||
|
||||
access_token = self.token_manager.create_access_token(token_data)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": self.settings.jwt_expire_hours * 3600,
|
||||
"user": {
|
||||
"username": user["username"],
|
||||
"email": user["email"],
|
||||
"roles": user["roles"],
|
||||
}
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise AuthenticationError(str(e))
|
||||
|
||||
async def refresh_token(self, token: str) -> Dict[str, Any]:
|
||||
"""Refresh an access token."""
|
||||
try:
|
||||
payload = self.token_manager.verify_token(token)
|
||||
username = payload.get("sub")
|
||||
|
||||
user = self.user_manager.get_user(username)
|
||||
if not user or not user.get("is_active", False):
|
||||
raise AuthenticationError("User not found or inactive")
|
||||
|
||||
# Create new token
|
||||
token_data = {
|
||||
"sub": user["username"],
|
||||
"email": user["email"],
|
||||
"roles": user["roles"],
|
||||
}
|
||||
|
||||
new_token = self.token_manager.create_access_token(token_data)
|
||||
|
||||
return {
|
||||
"access_token": new_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": self.settings.jwt_expire_hours * 3600,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise AuthenticationError("Token refresh failed")
|
||||
|
||||
def check_permission(self, user_info: Dict[str, Any], required_role: str) -> bool:
|
||||
"""Check if user has required role/permission."""
|
||||
user_roles = user_info.get("roles", [])
|
||||
|
||||
# Admin role has all permissions
|
||||
if "admin" in user_roles:
|
||||
return True
|
||||
|
||||
# Check specific role
|
||||
return required_role in user_roles
|
||||
|
||||
def require_role(self, required_role: str):
|
||||
"""Decorator to require specific role."""
|
||||
def decorator(func):
|
||||
import functools
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(request: Request, *args, **kwargs):
|
||||
user_info = getattr(request.state, "user", None)
|
||||
if not user_info:
|
||||
raise AuthorizationError("Authentication required")
|
||||
|
||||
if not self.check_permission(user_info, required_role):
|
||||
raise AuthorizationError(f"Role '{required_role}' required")
|
||||
|
||||
return await func(request, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
# Global authentication middleware instance
|
||||
_auth_middleware: Optional[AuthenticationMiddleware] = None
|
||||
|
||||
|
||||
def get_auth_middleware(settings: Settings) -> AuthenticationMiddleware:
|
||||
"""Get authentication middleware instance."""
|
||||
global _auth_middleware
|
||||
if _auth_middleware is None:
|
||||
_auth_middleware = AuthenticationMiddleware(settings)
|
||||
return _auth_middleware
|
||||
|
||||
|
||||
def get_current_user(request: Request) -> Optional[Dict[str, Any]]:
|
||||
"""Get current authenticated user from request."""
|
||||
return getattr(request.state, "user", None)
|
||||
|
||||
|
||||
def require_authentication(request: Request) -> Dict[str, Any]:
|
||||
"""Require authentication and return user info."""
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def require_role(role: str):
|
||||
"""Dependency to require specific role."""
|
||||
def dependency(request: Request) -> Dict[str, Any]:
|
||||
user = require_authentication(request)
|
||||
|
||||
auth_middleware = get_auth_middleware(request.app.state.settings)
|
||||
if not auth_middleware.check_permission(user, role):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Role '{role}' required",
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
return dependency
|
||||
@@ -1,135 +0,0 @@
|
||||
"""Frame budget benchmark for CSI processing pipeline.
|
||||
|
||||
Verifies that per-frame CSI processing stays within the 50 ms budget
|
||||
required for real-time sensing at 20 FPS.
|
||||
"""
|
||||
|
||||
import time
|
||||
import statistics
|
||||
import pytest
|
||||
import numpy as np
|
||||
|
||||
from src.core.csi_processor import CSIProcessor
|
||||
|
||||
|
||||
def _make_config():
|
||||
return {
|
||||
"sampling_rate": 1000,
|
||||
"window_size": 256,
|
||||
"overlap": 0.5,
|
||||
"noise_threshold": -60,
|
||||
"human_detection_threshold": 0.8,
|
||||
"smoothing_factor": 0.9,
|
||||
"max_history_size": 500,
|
||||
"num_subcarriers": 256,
|
||||
"num_antennas": 3,
|
||||
"doppler_window": 64,
|
||||
}
|
||||
|
||||
|
||||
def _make_csi_data(n_subcarriers=256, n_antennas=3, seed=None):
|
||||
"""Generate a synthetic CSI frame with complex-valued subcarriers."""
|
||||
rng = np.random.default_rng(seed)
|
||||
from unittest.mock import MagicMock
|
||||
csi = MagicMock()
|
||||
csi.amplitude = rng.random((n_antennas, n_subcarriers)).astype(np.float64) * 20.0
|
||||
csi.phase = (rng.random((n_antennas, n_subcarriers)).astype(np.float64) - 0.5) * np.pi * 2
|
||||
csi.frequency = 5.0e9
|
||||
csi.bandwidth = 80e6
|
||||
csi.num_subcarriers = n_subcarriers
|
||||
csi.num_antennas = n_antennas
|
||||
csi.snr = 25.0
|
||||
csi.timestamp = time.time()
|
||||
csi.metadata = {}
|
||||
return csi
|
||||
|
||||
|
||||
class TestSingleFrameBudget:
|
||||
"""Single-frame processing must complete in < 50 ms."""
|
||||
|
||||
def test_single_frame_under_50ms(self):
|
||||
proc = CSIProcessor(config=_make_config())
|
||||
frame = _make_csi_data(seed=42)
|
||||
|
||||
# Warm up
|
||||
proc.preprocess_csi_data(frame)
|
||||
|
||||
start = time.perf_counter()
|
||||
proc.preprocess_csi_data(frame)
|
||||
features = proc.extract_features(frame)
|
||||
if features:
|
||||
proc.detect_human_presence(features)
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
|
||||
assert elapsed_ms < 50, f"Single frame took {elapsed_ms:.1f} ms (budget: 50 ms)"
|
||||
|
||||
|
||||
class TestSustainedFrameBudget:
|
||||
"""Sustained 100-frame processing p95 must be < 50 ms per frame."""
|
||||
|
||||
def test_sustained_100_frames_p95(self):
|
||||
proc = CSIProcessor(config=_make_config())
|
||||
rng = np.random.default_rng(123)
|
||||
n_frames = 100
|
||||
latencies = []
|
||||
|
||||
for i in range(n_frames):
|
||||
frame = _make_csi_data(seed=i)
|
||||
start = time.perf_counter()
|
||||
preprocessed = proc.preprocess_csi_data(frame)
|
||||
features = proc.extract_features(preprocessed)
|
||||
if features:
|
||||
proc.detect_human_presence(features)
|
||||
proc.add_to_history(frame)
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
latencies.append(elapsed_ms)
|
||||
|
||||
p50 = statistics.median(latencies)
|
||||
p95 = sorted(latencies)[int(0.95 * len(latencies))]
|
||||
p99 = sorted(latencies)[int(0.99 * len(latencies))]
|
||||
|
||||
print(f"\n--- Sustained {n_frames}-frame benchmark ---")
|
||||
print(f" p50: {p50:.2f} ms")
|
||||
print(f" p95: {p95:.2f} ms")
|
||||
print(f" p99: {p99:.2f} ms")
|
||||
print(f" min: {min(latencies):.2f} ms")
|
||||
print(f" max: {max(latencies):.2f} ms")
|
||||
|
||||
assert p95 < 50, f"p95 latency {p95:.1f} ms exceeds 50 ms budget"
|
||||
|
||||
|
||||
class TestPipelineWithDoppler:
|
||||
"""Full pipeline including Doppler estimation must stay within budget."""
|
||||
|
||||
def test_doppler_pipeline(self):
|
||||
proc = CSIProcessor(config=_make_config())
|
||||
n_frames = 100
|
||||
latencies = []
|
||||
|
||||
# Fill history first
|
||||
for i in range(20):
|
||||
frame = _make_csi_data(seed=i + 1000)
|
||||
proc.add_to_history(frame)
|
||||
|
||||
for i in range(n_frames):
|
||||
frame = _make_csi_data(seed=i + 2000)
|
||||
start = time.perf_counter()
|
||||
preprocessed = proc.preprocess_csi_data(frame)
|
||||
features = proc.extract_features(preprocessed)
|
||||
if features:
|
||||
proc.detect_human_presence(features)
|
||||
proc.add_to_history(frame)
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
latencies.append(elapsed_ms)
|
||||
|
||||
p50 = statistics.median(latencies)
|
||||
p95 = sorted(latencies)[int(0.95 * len(latencies))]
|
||||
p99 = sorted(latencies)[int(0.99 * len(latencies))]
|
||||
|
||||
print(f"\n--- Doppler pipeline benchmark ({n_frames} frames, 20 warmup) ---")
|
||||
print(f" p50: {p50:.2f} ms")
|
||||
print(f" p95: {p95:.2f} ms")
|
||||
print(f" p99: {p99:.2f} ms")
|
||||
|
||||
# Doppler adds overhead but should still be within budget
|
||||
assert p95 < 50, f"Doppler pipeline p95 {p95:.1f} ms exceeds 50 ms budget"
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Shared fixtures for unit tests."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
# Set SECRET_KEY before any settings import
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-unit-tests-only")
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-unit-tests-only")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings():
|
||||
"""Create a mock Settings object."""
|
||||
settings = MagicMock()
|
||||
settings.secret_key = "test-secret-key-for-unit-tests-only"
|
||||
settings.jwt_algorithm = "HS256"
|
||||
settings.jwt_expire_hours = 24
|
||||
settings.app_name = "test-app"
|
||||
settings.version = "0.1.0"
|
||||
settings.is_production = False
|
||||
settings.enable_rate_limiting = False
|
||||
settings.enable_authentication = False
|
||||
settings.rate_limit_requests = 100
|
||||
settings.rate_limit_window = 60
|
||||
settings.rate_limit_authenticated_requests = 1000
|
||||
settings.allowed_hosts = ["*"]
|
||||
settings.csi_buffer_size = 100
|
||||
settings.stream_buffer_size = 100
|
||||
settings.mock_hardware = True
|
||||
settings.mock_pose_data = True
|
||||
settings.enable_real_time_processing = False
|
||||
settings.trusted_proxies = ["127.0.0.1"]
|
||||
return settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_domain_config():
|
||||
"""Create a mock DomainConfig object."""
|
||||
config = MagicMock()
|
||||
config.pose_estimation = MagicMock()
|
||||
config.streaming = MagicMock()
|
||||
config.hardware = MagicMock()
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis():
|
||||
"""Provide a mock Redis client."""
|
||||
with patch("redis.Redis") as mock:
|
||||
client = MagicMock()
|
||||
client.ping.return_value = True
|
||||
client.get.return_value = None
|
||||
client.set.return_value = True
|
||||
mock.return_value = client
|
||||
yield client
|
||||
@@ -1,137 +0,0 @@
|
||||
"""Tests for AuthMiddleware and TokenManager."""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
class TestTokenManager:
|
||||
def test_create_token(self, mock_settings):
|
||||
from src.middleware.auth import TokenManager
|
||||
tm = TokenManager(mock_settings)
|
||||
token = tm.create_access_token({"sub": "user1"})
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > 0
|
||||
|
||||
def test_verify_valid_token(self, mock_settings):
|
||||
from src.middleware.auth import TokenManager
|
||||
tm = TokenManager(mock_settings)
|
||||
token = tm.create_access_token({"sub": "user1", "role": "admin"})
|
||||
payload = tm.verify_token(token)
|
||||
assert payload["sub"] == "user1"
|
||||
assert payload["role"] == "admin"
|
||||
|
||||
def test_verify_invalid_token(self, mock_settings):
|
||||
from src.middleware.auth import TokenManager, AuthenticationError
|
||||
tm = TokenManager(mock_settings)
|
||||
with pytest.raises(AuthenticationError):
|
||||
tm.verify_token("invalid.token.here")
|
||||
|
||||
def test_decode_claims(self, mock_settings):
|
||||
from src.middleware.auth import TokenManager
|
||||
tm = TokenManager(mock_settings)
|
||||
token = tm.create_access_token({"sub": "user1"})
|
||||
claims = tm.decode_token_claims(token)
|
||||
assert claims is not None
|
||||
assert claims["sub"] == "user1"
|
||||
|
||||
def test_decode_claims_invalid(self, mock_settings):
|
||||
from src.middleware.auth import TokenManager
|
||||
tm = TokenManager(mock_settings)
|
||||
claims = tm.decode_token_claims("bad-token")
|
||||
assert claims is None
|
||||
|
||||
def test_token_has_expiry(self, mock_settings):
|
||||
from src.middleware.auth import TokenManager
|
||||
tm = TokenManager(mock_settings)
|
||||
token = tm.create_access_token({"sub": "user1"})
|
||||
payload = tm.verify_token(token)
|
||||
assert "exp" in payload
|
||||
assert "iat" in payload
|
||||
|
||||
|
||||
class TestUserManager:
|
||||
def test_create_user(self):
|
||||
from src.middleware.auth import UserManager
|
||||
um = UserManager()
|
||||
assert um.get_user("nonexistent") is None
|
||||
|
||||
def test_hash_password(self):
|
||||
from src.middleware.auth import UserManager
|
||||
hashed = UserManager.hash_password("secret123")
|
||||
assert hashed != "secret123"
|
||||
assert len(hashed) > 20
|
||||
|
||||
def test_verify_password(self):
|
||||
from src.middleware.auth import UserManager
|
||||
hashed = UserManager.hash_password("secret123")
|
||||
assert UserManager.verify_password("secret123", hashed) is True
|
||||
assert UserManager.verify_password("wrong", hashed) is False
|
||||
|
||||
|
||||
class TestTokenBlacklist:
|
||||
def test_add_and_check(self):
|
||||
from src.api.middleware.auth import TokenBlacklist
|
||||
bl = TokenBlacklist()
|
||||
bl.add_token("tok123")
|
||||
assert bl.is_blacklisted("tok123") is True
|
||||
assert bl.is_blacklisted("tok456") is False
|
||||
|
||||
def test_blacklisted_token_rejected(self, mock_settings):
|
||||
from src.middleware.auth import TokenManager, AuthenticationError
|
||||
from src.api.middleware.auth import token_blacklist
|
||||
|
||||
tm = TokenManager(mock_settings)
|
||||
token = tm.create_access_token({"sub": "user1"})
|
||||
# Token should be valid
|
||||
tm.verify_token(token)
|
||||
# Blacklist it
|
||||
token_blacklist.add_token(token)
|
||||
with pytest.raises(AuthenticationError, match="revoked"):
|
||||
tm.verify_token(token)
|
||||
# Cleanup
|
||||
token_blacklist._blacklisted_tokens.discard(token)
|
||||
|
||||
|
||||
class TestAuthMiddleware:
|
||||
def test_public_paths(self, mock_settings):
|
||||
with patch("src.api.middleware.auth.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.auth import AuthMiddleware
|
||||
app = MagicMock()
|
||||
mw = AuthMiddleware(app)
|
||||
assert mw._is_public_path("/health") is True
|
||||
assert mw._is_public_path("/docs") is True
|
||||
assert mw._is_public_path("/api/v1/pose/analyze") is False
|
||||
|
||||
def test_protected_paths(self, mock_settings):
|
||||
with patch("src.api.middleware.auth.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.auth import AuthMiddleware
|
||||
app = MagicMock()
|
||||
mw = AuthMiddleware(app)
|
||||
assert mw._is_protected_path("/api/v1/pose/analyze") is True
|
||||
assert mw._is_protected_path("/health") is False
|
||||
|
||||
def test_extract_token_from_header(self, mock_settings):
|
||||
with patch("src.api.middleware.auth.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.auth import AuthMiddleware
|
||||
app = MagicMock()
|
||||
mw = AuthMiddleware(app)
|
||||
request = MagicMock()
|
||||
request.headers = {"authorization": "Bearer mytoken123"}
|
||||
request.query_params = {}
|
||||
request.cookies = {}
|
||||
token = mw._extract_token(request)
|
||||
assert token == "mytoken123"
|
||||
|
||||
def test_extract_token_missing(self, mock_settings):
|
||||
with patch("src.api.middleware.auth.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.auth import AuthMiddleware
|
||||
app = MagicMock()
|
||||
mw = AuthMiddleware(app)
|
||||
request = MagicMock()
|
||||
request.headers = {}
|
||||
request.query_params = {}
|
||||
request.cookies = {}
|
||||
token = mw._extract_token(request)
|
||||
assert token is None
|
||||
@@ -1,78 +0,0 @@
|
||||
"""Tests for error handling in the API layer."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class TestExceptionHandlers:
|
||||
"""Test the exception handlers registered on the FastAPI app."""
|
||||
|
||||
def _get_app(self):
|
||||
"""Import app lazily to avoid side effects."""
|
||||
with patch("src.api.main.get_settings") as mock_gs, \
|
||||
patch("src.api.main.get_domain_config") as mock_gdc, \
|
||||
patch("src.api.main.get_pose_service") as mock_ps, \
|
||||
patch("src.api.main.get_stream_service") as mock_ss, \
|
||||
patch("src.api.main.get_hardware_service") as mock_hs, \
|
||||
patch("src.api.main.connection_manager") as mock_cm, \
|
||||
patch("src.api.main.PoseStreamHandler") as mock_psh:
|
||||
mock_gs.return_value = MagicMock(
|
||||
app_name="test", version="0.1", environment="test",
|
||||
is_production=False, enable_rate_limiting=False,
|
||||
enable_authentication=False, docs_url="/docs",
|
||||
redoc_url="/redoc", openapi_url="/openapi.json",
|
||||
api_prefix="/api/v1",
|
||||
)
|
||||
mock_gs.return_value.get_logging_config.return_value = {
|
||||
"version": 1, "disable_existing_loggers": False,
|
||||
"handlers": {}, "loggers": {},
|
||||
}
|
||||
mock_gs.return_value.get_cors_config.return_value = {
|
||||
"allow_origins": ["*"], "allow_methods": ["*"],
|
||||
"allow_headers": ["*"],
|
||||
}
|
||||
# Re-import to pick up patches
|
||||
import importlib
|
||||
import src.api.main as m
|
||||
importlib.reload(m)
|
||||
return m.app
|
||||
|
||||
|
||||
class TestErrorResponseModel:
|
||||
def test_error_json_structure(self):
|
||||
"""Verify error JSON has code, message, type fields."""
|
||||
error = {
|
||||
"error": {
|
||||
"code": 404,
|
||||
"message": "Not found",
|
||||
"type": "http_error"
|
||||
}
|
||||
}
|
||||
assert error["error"]["code"] == 404
|
||||
assert "message" in error["error"]
|
||||
assert "type" in error["error"]
|
||||
|
||||
def test_validation_error_structure(self):
|
||||
error = {
|
||||
"error": {
|
||||
"code": 422,
|
||||
"message": "Validation error",
|
||||
"type": "validation_error",
|
||||
"details": []
|
||||
}
|
||||
}
|
||||
assert error["error"]["type"] == "validation_error"
|
||||
assert isinstance(error["error"]["details"], list)
|
||||
|
||||
def test_internal_error_masks_details(self):
|
||||
"""In production, internal errors should not leak stack traces."""
|
||||
error = {
|
||||
"error": {
|
||||
"code": 500,
|
||||
"message": "Internal server error",
|
||||
"type": "internal_error"
|
||||
}
|
||||
}
|
||||
assert "traceback" not in str(error)
|
||||
assert error["error"]["message"] == "Internal server error"
|
||||
@@ -1,430 +0,0 @@
|
||||
"""Tests for ESP32BinaryParser (ADR-018 binary frame format)."""
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'src'))
|
||||
|
||||
from hardware.csi_extractor import (
|
||||
ESP32BinaryParser,
|
||||
CSIExtractor,
|
||||
CSIParseError,
|
||||
CSIExtractionError,
|
||||
SyncPacket,
|
||||
SyncPacketParser,
|
||||
)
|
||||
|
||||
# ADR-018 constants
|
||||
MAGIC = 0xC5110001
|
||||
# ADR-110: bytes 18-19 are now PPDU type + flags (used to be `2x` reserved).
|
||||
# Pre-ADR-110 firmware sends zeros for both, which round-trip as
|
||||
# ('ht_legacy', flags=all-false) — fully backwards compatible.
|
||||
HEADER_FMT = '<IBBHIIBBBB'
|
||||
HEADER_SIZE = 20
|
||||
|
||||
|
||||
def build_binary_frame(
|
||||
node_id: int = 1,
|
||||
n_antennas: int = 1,
|
||||
n_subcarriers: int = 4,
|
||||
freq_mhz: int = 2437,
|
||||
sequence: int = 0,
|
||||
rssi: int = -50,
|
||||
noise_floor: int = -90,
|
||||
iq_pairs: list = None,
|
||||
ppdu_byte: int = 0, # ADR-110: default 0 = HT/legacy (pre-ADR-110 behavior)
|
||||
flags_byte: int = 0, # ADR-110: default 0 = no flags set
|
||||
) -> bytes:
|
||||
"""Build an ADR-018 binary frame for testing."""
|
||||
if iq_pairs is None:
|
||||
iq_pairs = [(i % 50, (i * 2) % 50) for i in range(n_antennas * n_subcarriers)]
|
||||
|
||||
rssi_u8 = rssi & 0xFF
|
||||
noise_u8 = noise_floor & 0xFF
|
||||
|
||||
header = struct.pack(
|
||||
HEADER_FMT,
|
||||
MAGIC,
|
||||
node_id,
|
||||
n_antennas,
|
||||
n_subcarriers,
|
||||
freq_mhz,
|
||||
sequence,
|
||||
rssi_u8,
|
||||
noise_u8,
|
||||
ppdu_byte,
|
||||
flags_byte,
|
||||
)
|
||||
|
||||
iq_data = b''
|
||||
for i_val, q_val in iq_pairs:
|
||||
iq_data += struct.pack('<bb', i_val, q_val)
|
||||
|
||||
return header + iq_data
|
||||
|
||||
|
||||
class TestAdr110ByteEncoding:
|
||||
"""ADR-110: byte 18 = PPDU type, byte 19 = flags."""
|
||||
|
||||
def setup_method(self):
|
||||
self.parser = ESP32BinaryParser()
|
||||
|
||||
def test_pre_adr110_zeros_decode_as_ht_legacy(self):
|
||||
"""Pre-ADR-110 firmware sends zeros → must surface as HT/legacy + no flags."""
|
||||
frame = build_binary_frame() # ppdu_byte=0, flags_byte=0 default
|
||||
csi = self.parser.parse(frame)
|
||||
assert csi.metadata['ppdu_type'] == 'ht_legacy'
|
||||
assert csi.metadata['ppdu_type_raw'] == 0
|
||||
assert csi.metadata['he_capable'] is False
|
||||
assert csi.metadata['bw40'] is False
|
||||
assert csi.metadata['stbc'] is False
|
||||
assert csi.metadata['ldpc'] is False
|
||||
assert csi.metadata['ieee802154_sync_valid'] is False
|
||||
|
||||
def test_he_su_decodes(self):
|
||||
frame = build_binary_frame(ppdu_byte=1)
|
||||
csi = self.parser.parse(frame)
|
||||
assert csi.metadata['ppdu_type'] == 'he_su'
|
||||
assert csi.metadata['he_capable'] is True
|
||||
|
||||
def test_he_mu_and_he_tb_decode(self):
|
||||
for byte, expected in [(2, 'he_mu'), (3, 'he_tb')]:
|
||||
csi = self.parser.parse(build_binary_frame(ppdu_byte=byte))
|
||||
assert csi.metadata['ppdu_type'] == expected
|
||||
assert csi.metadata['he_capable'] is True
|
||||
|
||||
def test_unknown_ppdu_byte(self):
|
||||
csi = self.parser.parse(build_binary_frame(ppdu_byte=0xFF))
|
||||
assert csi.metadata['ppdu_type'] == 'unknown'
|
||||
assert csi.metadata['ppdu_type_raw'] == 0xFF
|
||||
assert csi.metadata['he_capable'] is False
|
||||
|
||||
def test_all_flags_set_round_trip(self):
|
||||
# bw40 (0x01) + STBC (0x04) + LDPC (0x08) + 15.4-sync (0x10) = 0x1D
|
||||
csi = self.parser.parse(build_binary_frame(ppdu_byte=1, flags_byte=0x1D))
|
||||
assert csi.metadata['bw40'] is True
|
||||
assert csi.metadata['stbc'] is True
|
||||
assert csi.metadata['ldpc'] is True
|
||||
assert csi.metadata['ieee802154_sync_valid'] is True
|
||||
assert csi.metadata['adr018_flags_raw'] == 0x1D
|
||||
|
||||
|
||||
class TestESP32BinaryParser:
|
||||
"""Tests for ESP32BinaryParser."""
|
||||
|
||||
def setup_method(self):
|
||||
self.parser = ESP32BinaryParser()
|
||||
|
||||
def test_parse_valid_binary_frame(self):
|
||||
"""Parse a well-formed ADR-018 binary frame."""
|
||||
iq = [(3, 4), (0, 10), (5, 12), (7, 0)]
|
||||
frame_bytes = build_binary_frame(
|
||||
node_id=1, n_antennas=1, n_subcarriers=4,
|
||||
freq_mhz=2437, sequence=42, rssi=-50, noise_floor=-90,
|
||||
iq_pairs=iq,
|
||||
)
|
||||
|
||||
result = self.parser.parse(frame_bytes)
|
||||
|
||||
assert result.num_antennas == 1
|
||||
assert result.num_subcarriers == 4
|
||||
assert result.amplitude.shape == (1, 4)
|
||||
assert result.phase.shape == (1, 4)
|
||||
assert result.metadata['node_id'] == 1
|
||||
assert result.metadata['sequence'] == 42
|
||||
assert result.metadata['rssi_dbm'] == -50
|
||||
assert result.metadata['noise_floor_dbm'] == -90
|
||||
assert result.metadata['channel_freq_mhz'] == 2437
|
||||
|
||||
# Check amplitude for I=3, Q=4 -> sqrt(9+16) = 5.0
|
||||
assert abs(result.amplitude[0, 0] - 5.0) < 0.001
|
||||
# I=0, Q=10 -> 10.0
|
||||
assert abs(result.amplitude[0, 1] - 10.0) < 0.001
|
||||
|
||||
def test_parse_frame_too_short(self):
|
||||
"""Reject frames shorter than the 20-byte header."""
|
||||
with pytest.raises(CSIParseError, match="too short"):
|
||||
self.parser.parse(b'\x00' * 10)
|
||||
|
||||
def test_parse_invalid_magic(self):
|
||||
"""Reject frames with wrong magic number."""
|
||||
bad_frame = build_binary_frame()
|
||||
# Corrupt magic
|
||||
bad_frame = b'\xFF\xFF\xFF\xFF' + bad_frame[4:]
|
||||
with pytest.raises(CSIParseError, match="Invalid magic"):
|
||||
self.parser.parse(bad_frame)
|
||||
|
||||
def test_parse_multi_antenna_frame(self):
|
||||
"""Parse a frame with 3 antennas and 4 subcarriers."""
|
||||
n_ant = 3
|
||||
n_sc = 4
|
||||
iq = [(i + 1, i + 2) for i in range(n_ant * n_sc)]
|
||||
|
||||
frame_bytes = build_binary_frame(
|
||||
node_id=5, n_antennas=n_ant, n_subcarriers=n_sc,
|
||||
iq_pairs=iq,
|
||||
)
|
||||
|
||||
result = self.parser.parse(frame_bytes)
|
||||
|
||||
assert result.num_antennas == 3
|
||||
assert result.num_subcarriers == 4
|
||||
assert result.amplitude.shape == (3, 4)
|
||||
assert result.phase.shape == (3, 4)
|
||||
|
||||
def test_udp_read_with_mock_server(self):
|
||||
"""Send a frame via UDP and verify CSIExtractor receives it."""
|
||||
# Find a free port
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(('127.0.0.1', 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
|
||||
frame_bytes = build_binary_frame(
|
||||
node_id=3, n_antennas=1, n_subcarriers=4,
|
||||
freq_mhz=2412, sequence=99,
|
||||
)
|
||||
|
||||
config = {
|
||||
'hardware_type': 'esp32',
|
||||
'parser_format': 'binary',
|
||||
'sampling_rate': 100,
|
||||
'buffer_size': 2048,
|
||||
'timeout': 2,
|
||||
'aggregator_host': '127.0.0.1',
|
||||
'aggregator_port': port,
|
||||
}
|
||||
|
||||
extractor = CSIExtractor(config)
|
||||
|
||||
async def run_test():
|
||||
# Connect
|
||||
await extractor.connect()
|
||||
|
||||
# Send frame after a short delay from a background thread
|
||||
def send():
|
||||
time.sleep(0.2)
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.sendto(frame_bytes, ('127.0.0.1', port))
|
||||
s.close()
|
||||
|
||||
sender = threading.Thread(target=send, daemon=True)
|
||||
sender.start()
|
||||
|
||||
result = await extractor.extract_csi()
|
||||
sender.join(timeout=2)
|
||||
|
||||
assert result.metadata['node_id'] == 3
|
||||
assert result.metadata['sequence'] == 99
|
||||
assert result.num_subcarriers == 4
|
||||
|
||||
await extractor.disconnect()
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_udp_timeout(self):
|
||||
"""Verify timeout when no UDP server is sending data."""
|
||||
# Find a free port (nothing will send to it)
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(('127.0.0.1', 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
|
||||
config = {
|
||||
'hardware_type': 'esp32',
|
||||
'parser_format': 'binary',
|
||||
'sampling_rate': 100,
|
||||
'buffer_size': 2048,
|
||||
'timeout': 0.5,
|
||||
'retry_attempts': 1,
|
||||
'aggregator_host': '127.0.0.1',
|
||||
'aggregator_port': port,
|
||||
}
|
||||
|
||||
extractor = CSIExtractor(config)
|
||||
|
||||
async def run_test():
|
||||
await extractor.connect()
|
||||
with pytest.raises(CSIExtractionError, match="timed out"):
|
||||
await extractor.extract_csi()
|
||||
await extractor.disconnect()
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ADR-110 §A0.12 — SyncPacket / SyncPacketParser tests (firmware v0.6.9+)
|
||||
# ============================================================================
|
||||
|
||||
SYNC_MAGIC = 0xC511A110
|
||||
SYNC_SIZE = 32
|
||||
SYNC_FMT = '<IBBBBQQI4x'
|
||||
|
||||
|
||||
def build_sync_packet(
|
||||
node_id: int = 9,
|
||||
proto_ver: int = 1,
|
||||
is_leader: bool = False,
|
||||
is_valid: bool = True,
|
||||
smoothed_used: bool = True,
|
||||
local_us: int = 28798450,
|
||||
epoch_us: int = 27634885,
|
||||
sequence: int = 20,
|
||||
) -> bytes:
|
||||
flags = 0
|
||||
if is_leader: flags |= 0x01
|
||||
if is_valid: flags |= 0x02
|
||||
if smoothed_used: flags |= 0x04
|
||||
return struct.pack(
|
||||
SYNC_FMT,
|
||||
SYNC_MAGIC,
|
||||
node_id, proto_ver, flags, 0,
|
||||
local_us, epoch_us, sequence,
|
||||
)
|
||||
|
||||
|
||||
class TestSyncPacketParser:
|
||||
"""ADR-110 §A0.12: 32-byte UDP sync packet (magic 0xC511A110)."""
|
||||
|
||||
def test_follower_typical_packet_roundtrips(self):
|
||||
"""Match the COM9-witnessed sync-pkt #1 byte-for-byte."""
|
||||
raw = build_sync_packet(
|
||||
node_id=9, is_leader=False, is_valid=True, smoothed_used=True,
|
||||
local_us=28798450, epoch_us=27634885, sequence=20,
|
||||
)
|
||||
assert len(raw) == SYNC_SIZE
|
||||
pkt = SyncPacketParser.parse(raw)
|
||||
assert isinstance(pkt, SyncPacket)
|
||||
assert pkt.node_id == 9
|
||||
assert pkt.proto_ver == 1
|
||||
assert pkt.is_leader is False
|
||||
assert pkt.is_valid is True
|
||||
assert pkt.smoothed_used is True
|
||||
assert pkt.local_us == 28798450
|
||||
assert pkt.epoch_us == 27634885
|
||||
assert pkt.sequence == 20
|
||||
# The 1.16-second boot delta from §A0.10 should be recoverable
|
||||
assert pkt.local_us - pkt.epoch_us == 1163565
|
||||
|
||||
def test_leader_packet_has_local_close_to_epoch(self):
|
||||
"""COM12 (leader) had flags=0x03 and epoch ≈ local."""
|
||||
raw = build_sync_packet(
|
||||
node_id=12, is_leader=True, is_valid=True, smoothed_used=False,
|
||||
local_us=28864932, epoch_us=28864939, sequence=20,
|
||||
)
|
||||
pkt = SyncPacketParser.parse(raw)
|
||||
assert pkt.node_id == 12
|
||||
assert pkt.is_leader is True
|
||||
assert pkt.is_valid is True
|
||||
assert pkt.smoothed_used is False
|
||||
assert pkt.flags_raw == 0x03
|
||||
assert pkt.local_us - pkt.epoch_us == -7 # leader has zero offset
|
||||
|
||||
def test_magic_mismatch_raises(self):
|
||||
"""A non-sync datagram must not silently decode."""
|
||||
raw = bytearray(build_sync_packet())
|
||||
raw[0] = 0x01 # corrupt magic low byte
|
||||
with pytest.raises(CSIParseError, match="magic mismatch"):
|
||||
SyncPacketParser.parse(bytes(raw))
|
||||
|
||||
def test_short_packet_raises(self):
|
||||
"""Below 32 bytes must error early, not silently truncate."""
|
||||
raw = build_sync_packet()[:16]
|
||||
with pytest.raises(CSIParseError, match="too short"):
|
||||
SyncPacketParser.parse(raw)
|
||||
|
||||
def test_all_flag_combinations(self):
|
||||
"""Each flag bit decodes independently."""
|
||||
for is_leader in (False, True):
|
||||
for is_valid in (False, True):
|
||||
for smoothed_used in (False, True):
|
||||
raw = build_sync_packet(
|
||||
is_leader=is_leader,
|
||||
is_valid=is_valid,
|
||||
smoothed_used=smoothed_used,
|
||||
)
|
||||
pkt = SyncPacketParser.parse(raw)
|
||||
assert pkt.is_leader == is_leader
|
||||
assert pkt.is_valid == is_valid
|
||||
assert pkt.smoothed_used == smoothed_used
|
||||
|
||||
def test_dispatch_distinguishes_csi_from_sync(self):
|
||||
"""A host can pick CSI vs sync by leading magic."""
|
||||
csi_magic = struct.unpack_from('<I', build_binary_frame(), 0)[0]
|
||||
sync_magic = struct.unpack_from('<I', build_sync_packet(), 0)[0]
|
||||
assert csi_magic == ESP32BinaryParser.MAGIC
|
||||
assert sync_magic == SyncPacketParser.MAGIC
|
||||
assert csi_magic != sync_magic
|
||||
|
||||
def test_apply_to_local_recovers_epoch_at_sync_point(self):
|
||||
"""ADR-110 iter 26 — Python parity with Rust's `apply_to_local`.
|
||||
At local_at_frame == sync.local_us, the recovered mesh time must
|
||||
equal sync.epoch_us exactly."""
|
||||
pkt = SyncPacketParser.parse(build_sync_packet(
|
||||
local_us=28_798_450, epoch_us=27_634_885, sequence=20,
|
||||
))
|
||||
assert pkt.apply_to_local(pkt.local_us) == pkt.epoch_us
|
||||
assert pkt.local_minus_epoch_us() == 1_163_565 # §A0.10's bench number
|
||||
|
||||
def test_apply_to_local_preserves_inter_frame_delta(self):
|
||||
"""A frame arriving 5 s after the sync packet on the follower's
|
||||
local clock must produce a mesh time exactly 5 s after sync.epoch_us."""
|
||||
pkt = SyncPacketParser.parse(build_sync_packet(
|
||||
local_us=28_798_450, epoch_us=27_634_885, sequence=20,
|
||||
))
|
||||
local_at_frame = pkt.local_us + 5_000_000
|
||||
assert pkt.apply_to_local(local_at_frame) == pkt.epoch_us + 5_000_000
|
||||
|
||||
def test_mesh_aligned_us_for_sequence_matches_rust(self):
|
||||
"""Cross-language parity with Rust's
|
||||
`end_to_end_sync_decode_then_frame_mesh_recovery` test —
|
||||
100 frames after sync.sequence at 20 fps = sync.epoch_us + 5 s."""
|
||||
pkt = SyncPacketParser.parse(build_sync_packet(
|
||||
local_us=28_798_450, epoch_us=27_634_885, sequence=20,
|
||||
))
|
||||
mesh = pkt.mesh_aligned_us_for_sequence(120, 20.0)
|
||||
assert mesh == pkt.epoch_us + 5_000_000
|
||||
# Both paths (apply_to_local + interpolation) must agree
|
||||
local_at = pkt.local_us + 5_000_000
|
||||
assert pkt.apply_to_local(local_at) == mesh
|
||||
|
||||
def test_canonical_wire_bytes_match_rust_decoder(self):
|
||||
"""ADR-110 iter 21 — cross-language wire-format conformance gate.
|
||||
|
||||
These exact bytes also appear pinned in the Rust hardware crate's
|
||||
`canonical_wire_bytes_match_python_decoder` test (same field
|
||||
values, encoded by Rust's `SyncPacket::to_bytes`). If Python's
|
||||
hardcoded hex stops matching what Rust produces from the equivalent
|
||||
SyncPacket struct, ONE of the decoders has drifted from the wire.
|
||||
|
||||
Canonical packet: COM9 sync-pkt #1 from §A0.12 live capture.
|
||||
"""
|
||||
canonical = bytes.fromhex(
|
||||
"10a111c509010600" # magic LE + node=9 + ver=1 + flags=0x06 + reserved
|
||||
"f26db70100000000" # local_us = 28_798_450 (LE u64)
|
||||
"c5aca50100000000" # epoch_us = 27_634_885 (LE u64)
|
||||
"1400000000000000" # sequence = 20 (LE u32) + 4 reserved bytes
|
||||
)
|
||||
assert len(canonical) == SyncPacketParser.SIZE == 32
|
||||
|
||||
pkt = SyncPacketParser.parse(canonical)
|
||||
assert pkt.node_id == 9
|
||||
assert pkt.proto_ver == 1
|
||||
assert pkt.flags_raw == 0x06
|
||||
assert pkt.is_leader is False
|
||||
assert pkt.is_valid is True
|
||||
assert pkt.smoothed_used is True
|
||||
assert pkt.local_us == 28_798_450
|
||||
assert pkt.epoch_us == 27_634_885
|
||||
assert pkt.sequence == 20
|
||||
# Recovered offset matches §A0.10's measured 1.16-second boot delta.
|
||||
assert pkt.local_us - pkt.epoch_us == 1_163_565
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Tests for HardwareService."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
|
||||
class TestHardwareServiceInit:
|
||||
def test_init(self, mock_settings, mock_domain_config):
|
||||
mock_settings.mock_hardware = True
|
||||
with patch("src.services.hardware_service.RouterInterface"):
|
||||
from src.services.hardware_service import HardwareService
|
||||
svc = HardwareService(mock_settings, mock_domain_config)
|
||||
assert svc.is_running is False
|
||||
assert svc.stats["total_samples"] == 0
|
||||
assert svc.stats["connected_routers"] == 0
|
||||
|
||||
def test_stats_defaults(self, mock_settings, mock_domain_config):
|
||||
mock_settings.mock_hardware = True
|
||||
with patch("src.services.hardware_service.RouterInterface"):
|
||||
from src.services.hardware_service import HardwareService
|
||||
svc = HardwareService(mock_settings, mock_domain_config)
|
||||
assert svc.stats["successful_samples"] == 0
|
||||
assert svc.stats["failed_samples"] == 0
|
||||
assert svc.stats["last_sample_time"] is None
|
||||
|
||||
|
||||
class TestHardwareServiceLifecycle:
|
||||
@pytest.mark.asyncio
|
||||
async def test_start(self, mock_settings, mock_domain_config):
|
||||
mock_settings.mock_hardware = True
|
||||
with patch("src.services.hardware_service.RouterInterface"):
|
||||
from src.services.hardware_service import HardwareService
|
||||
svc = HardwareService(mock_settings, mock_domain_config)
|
||||
svc._initialize_routers = AsyncMock()
|
||||
svc._monitoring_loop = AsyncMock()
|
||||
await svc.start()
|
||||
assert svc.is_running is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_double_start_idempotent(self, mock_settings, mock_domain_config):
|
||||
mock_settings.mock_hardware = True
|
||||
with patch("src.services.hardware_service.RouterInterface"):
|
||||
from src.services.hardware_service import HardwareService
|
||||
svc = HardwareService(mock_settings, mock_domain_config)
|
||||
svc._initialize_routers = AsyncMock()
|
||||
svc._monitoring_loop = AsyncMock()
|
||||
await svc.start()
|
||||
await svc.start() # idempotent
|
||||
assert svc.is_running is True
|
||||
|
||||
|
||||
class TestHardwareServiceRouter:
|
||||
def test_no_routers_on_init(self, mock_settings, mock_domain_config):
|
||||
mock_settings.mock_hardware = True
|
||||
with patch("src.services.hardware_service.RouterInterface"):
|
||||
from src.services.hardware_service import HardwareService
|
||||
svc = HardwareService(mock_settings, mock_domain_config)
|
||||
assert len(svc.router_interfaces) == 0
|
||||
|
||||
def test_max_recent_samples(self, mock_settings, mock_domain_config):
|
||||
mock_settings.mock_hardware = True
|
||||
with patch("src.services.hardware_service.RouterInterface"):
|
||||
from src.services.hardware_service import HardwareService
|
||||
svc = HardwareService(mock_settings, mock_domain_config)
|
||||
assert svc.max_recent_samples == 1000
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Tests for HealthCheckService."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
class TestHealthCheckServiceInit:
|
||||
def test_init(self, mock_settings):
|
||||
from src.services.health_check import HealthCheckService
|
||||
svc = HealthCheckService(mock_settings)
|
||||
assert svc._initialized is False
|
||||
assert svc._running is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize(self, mock_settings):
|
||||
from src.services.health_check import HealthCheckService
|
||||
svc = HealthCheckService(mock_settings)
|
||||
await svc.initialize()
|
||||
assert svc._initialized is True
|
||||
assert "api" in svc._services
|
||||
assert "database" in svc._services
|
||||
assert "hardware" in svc._services
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_double_initialize(self, mock_settings):
|
||||
from src.services.health_check import HealthCheckService
|
||||
svc = HealthCheckService(mock_settings)
|
||||
await svc.initialize()
|
||||
await svc.initialize() # idempotent
|
||||
assert svc._initialized is True
|
||||
|
||||
|
||||
class TestHealthCheckAggregation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_services_registered(self, mock_settings):
|
||||
from src.services.health_check import HealthCheckService, HealthStatus
|
||||
svc = HealthCheckService(mock_settings)
|
||||
await svc.initialize()
|
||||
assert len(svc._services) == 6
|
||||
for name, sh in svc._services.items():
|
||||
assert sh.status == HealthStatus.UNKNOWN
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_names(self, mock_settings):
|
||||
from src.services.health_check import HealthCheckService
|
||||
svc = HealthCheckService(mock_settings)
|
||||
await svc.initialize()
|
||||
expected = {"api", "database", "redis", "hardware", "pose", "stream"}
|
||||
assert set(svc._services.keys()) == expected
|
||||
|
||||
|
||||
class TestHealthStatus:
|
||||
def test_enum_values(self):
|
||||
from src.services.health_check import HealthStatus
|
||||
assert HealthStatus.HEALTHY.value == "healthy"
|
||||
assert HealthStatus.DEGRADED.value == "degraded"
|
||||
assert HealthStatus.UNHEALTHY.value == "unhealthy"
|
||||
assert HealthStatus.UNKNOWN.value == "unknown"
|
||||
|
||||
|
||||
class TestHealthCheck:
|
||||
def test_health_check_dataclass(self):
|
||||
from src.services.health_check import HealthCheck, HealthStatus
|
||||
hc = HealthCheck(name="test", status=HealthStatus.HEALTHY, message="ok")
|
||||
assert hc.name == "test"
|
||||
assert hc.status == HealthStatus.HEALTHY
|
||||
assert hc.duration_ms == 0.0
|
||||
@@ -1,70 +0,0 @@
|
||||
"""Tests for MetricsService."""
|
||||
|
||||
import pytest
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestMetricSeries:
|
||||
def test_add_point(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
ms.add_point(42.0)
|
||||
assert len(ms.points) == 1
|
||||
assert ms.points[0].value == 42.0
|
||||
|
||||
def test_get_latest(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
ms.add_point(1.0)
|
||||
ms.add_point(2.0)
|
||||
latest = ms.get_latest()
|
||||
assert latest is not None
|
||||
assert latest.value == 2.0
|
||||
|
||||
def test_get_latest_empty(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
assert ms.get_latest() is None
|
||||
|
||||
def test_get_average(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
for v in [10.0, 20.0, 30.0]:
|
||||
ms.add_point(v)
|
||||
avg = ms.get_average(timedelta(minutes=5))
|
||||
assert avg == pytest.approx(20.0)
|
||||
|
||||
def test_get_average_empty(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
assert ms.get_average(timedelta(minutes=5)) is None
|
||||
|
||||
def test_get_max(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
for v in [10.0, 50.0, 30.0]:
|
||||
ms.add_point(v)
|
||||
mx = ms.get_max(timedelta(minutes=5))
|
||||
assert mx == 50.0
|
||||
|
||||
def test_labels(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
ms.add_point(1.0, {"region": "us-east"})
|
||||
assert ms.points[0].labels["region"] == "us-east"
|
||||
|
||||
def test_maxlen(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
for i in range(1100):
|
||||
ms.add_point(float(i))
|
||||
assert len(ms.points) == 1000
|
||||
|
||||
|
||||
class TestMetricsService:
|
||||
def test_init(self, mock_settings):
|
||||
with patch("src.services.metrics.psutil"):
|
||||
from src.services.metrics import MetricsService
|
||||
svc = MetricsService(mock_settings)
|
||||
assert svc._metrics is not None
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Tests for PoseService."""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TestPoseServiceInit:
|
||||
def test_init_sets_defaults(self, mock_settings, mock_domain_config):
|
||||
with patch.dict("sys.modules", {
|
||||
"torch": MagicMock(),
|
||||
"src.models.densepose_head": MagicMock(),
|
||||
"src.models.modality_translation": MagicMock(),
|
||||
}):
|
||||
from src.services.pose_service import PoseService
|
||||
svc = PoseService(mock_settings, mock_domain_config)
|
||||
assert svc.is_initialized is False
|
||||
assert svc.is_running is False
|
||||
assert svc.stats["total_processed"] == 0
|
||||
|
||||
def test_stats_are_zero_on_init(self, mock_settings, mock_domain_config):
|
||||
with patch.dict("sys.modules", {
|
||||
"torch": MagicMock(),
|
||||
"src.models.densepose_head": MagicMock(),
|
||||
"src.models.modality_translation": MagicMock(),
|
||||
}):
|
||||
from src.services.pose_service import PoseService
|
||||
svc = PoseService(mock_settings, mock_domain_config)
|
||||
assert svc.stats["successful_detections"] == 0
|
||||
assert svc.stats["failed_detections"] == 0
|
||||
assert svc.stats["average_confidence"] == 0.0
|
||||
|
||||
|
||||
class TestPoseServiceLifecycle:
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_sets_flag(self, mock_settings, mock_domain_config):
|
||||
with patch.dict("sys.modules", {
|
||||
"torch": MagicMock(),
|
||||
"src.models.densepose_head": MagicMock(),
|
||||
"src.models.modality_translation": MagicMock(),
|
||||
}):
|
||||
from src.services.pose_service import PoseService
|
||||
svc = PoseService(mock_settings, mock_domain_config)
|
||||
await svc.initialize()
|
||||
assert svc.is_initialized is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_stop(self, mock_settings, mock_domain_config):
|
||||
with patch.dict("sys.modules", {
|
||||
"torch": MagicMock(),
|
||||
"src.models.densepose_head": MagicMock(),
|
||||
"src.models.modality_translation": MagicMock(),
|
||||
}):
|
||||
from src.services.pose_service import PoseService
|
||||
svc = PoseService(mock_settings, mock_domain_config)
|
||||
await svc.initialize()
|
||||
await svc.start()
|
||||
assert svc.is_running is True
|
||||
await svc.stop()
|
||||
assert svc.is_running is False
|
||||
|
||||
|
||||
class TestPoseServiceStats:
|
||||
def test_initial_classification(self, mock_settings, mock_domain_config):
|
||||
with patch.dict("sys.modules", {
|
||||
"torch": MagicMock(),
|
||||
"src.models.densepose_head": MagicMock(),
|
||||
"src.models.modality_translation": MagicMock(),
|
||||
}):
|
||||
from src.services.pose_service import PoseService
|
||||
svc = PoseService(mock_settings, mock_domain_config)
|
||||
assert svc.last_error is None
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Tests for rate limiting middleware."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
|
||||
class TestRateLimitMiddleware:
|
||||
def test_init(self, mock_settings):
|
||||
with patch("src.api.middleware.rate_limit.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.rate_limit import RateLimitMiddleware
|
||||
app = MagicMock()
|
||||
mw = RateLimitMiddleware(app)
|
||||
assert "anonymous" in mw.rate_limits
|
||||
assert "authenticated" in mw.rate_limits
|
||||
assert "admin" in mw.rate_limits
|
||||
|
||||
def test_exempt_paths(self, mock_settings):
|
||||
with patch("src.api.middleware.rate_limit.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.rate_limit import RateLimitMiddleware
|
||||
app = MagicMock()
|
||||
mw = RateLimitMiddleware(app)
|
||||
assert "/health" in mw.exempt_paths
|
||||
assert "/metrics" in mw.exempt_paths
|
||||
|
||||
def test_is_exempt(self, mock_settings):
|
||||
with patch("src.api.middleware.rate_limit.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.rate_limit import RateLimitMiddleware
|
||||
app = MagicMock()
|
||||
mw = RateLimitMiddleware(app)
|
||||
assert mw._is_exempt_path("/health") is True
|
||||
assert mw._is_exempt_path("/api/v1/pose/current") is False
|
||||
|
||||
def test_path_specific_limits(self, mock_settings):
|
||||
with patch("src.api.middleware.rate_limit.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.rate_limit import RateLimitMiddleware
|
||||
app = MagicMock()
|
||||
mw = RateLimitMiddleware(app)
|
||||
assert "/api/v1/pose/current" in mw.path_limits
|
||||
assert mw.path_limits["/api/v1/pose/current"]["requests"] == 60
|
||||
|
||||
def test_trusted_proxies_not_blocked(self, mock_settings):
|
||||
with patch("src.api.middleware.rate_limit.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.rate_limit import RateLimitMiddleware
|
||||
app = MagicMock()
|
||||
mw = RateLimitMiddleware(app)
|
||||
assert not mw._is_client_blocked("new-client-id")
|
||||
|
||||
|
||||
class TestRateLimitConfig:
|
||||
def test_anonymous_limit(self, mock_settings):
|
||||
with patch("src.api.middleware.rate_limit.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.rate_limit import RateLimitMiddleware
|
||||
app = MagicMock()
|
||||
mw = RateLimitMiddleware(app)
|
||||
assert mw.rate_limits["anonymous"]["burst"] == 10
|
||||
|
||||
def test_admin_limit(self, mock_settings):
|
||||
with patch("src.api.middleware.rate_limit.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.rate_limit import RateLimitMiddleware
|
||||
app = MagicMock()
|
||||
mw = RateLimitMiddleware(app)
|
||||
assert mw.rate_limits["admin"]["requests"] == 10000
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Tests for StreamService."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
|
||||
class TestStreamServiceLifecycle:
|
||||
def test_init(self, mock_settings, mock_domain_config):
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
assert svc.is_running is False
|
||||
assert len(svc.connections) == 0
|
||||
assert svc.stats["active_connections"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize(self, mock_settings, mock_domain_config):
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
await svc.initialize()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start(self, mock_settings, mock_domain_config):
|
||||
mock_settings.enable_real_time_processing = False
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
await svc.start()
|
||||
assert svc.is_running is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop(self, mock_settings, mock_domain_config):
|
||||
mock_settings.enable_real_time_processing = False
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
await svc.start()
|
||||
await svc.stop()
|
||||
assert svc.is_running is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_double_start(self, mock_settings, mock_domain_config):
|
||||
mock_settings.enable_real_time_processing = False
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
await svc.start()
|
||||
await svc.start() # should be idempotent
|
||||
assert svc.is_running is True
|
||||
|
||||
|
||||
class TestStreamServiceConnections:
|
||||
def test_no_connections_on_init(self, mock_settings, mock_domain_config):
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
assert svc.stats["total_connections"] == 0
|
||||
assert svc.stats["messages_sent"] == 0
|
||||
|
||||
def test_buffer_sizes(self, mock_settings, mock_domain_config):
|
||||
mock_settings.stream_buffer_size = 50
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
assert svc.pose_buffer.maxlen == 50
|
||||
assert svc.csi_buffer.maxlen == 50
|
||||
|
||||
|
||||
class TestStreamServiceBroadcast:
|
||||
def test_stats_messages_failed_init_zero(self, mock_settings, mock_domain_config):
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
assert svc.stats["messages_failed"] == 0
|
||||
assert svc.stats["data_points_streamed"] == 0
|
||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
File diff suppressed because one or more lines are too long
@@ -1,5 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
*.log
|
||||
public/nvsim-pkg
|
||||
@@ -1,18 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>RuView · nvsim — NV-Diamond Magnetometer Simulator</title>
|
||||
<meta name="description" content="Deterministic forward simulator for NV-diamond magnetometry. WASM-backed CW-ODMR pipeline with witness-grade SHA-256 proofs." />
|
||||
<meta name="theme-color" content="#0d1117" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%23e6a86b'/><text x='16' y='22' text-anchor='middle' font-family='monospace' font-weight='700' font-size='14' fill='%231a0f00'>NV</text></svg>" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nv-app></nv-app>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-6525
File diff suppressed because it is too large
Load Diff
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"name": "@ruvnet/nvsim-dashboard",
|
||||
"version": "0.1.0",
|
||||
"description": "Vite + Lit dashboard for the nvsim NV-diamond magnetometer pipeline simulator (ADR-092).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview --port 4173",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:a11y": "playwright test tests/a11y.spec.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"lit": "^3.2.1",
|
||||
"workbox-window": "^7.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.11.2",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vitest": "^2.1.4"
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
retries: 0,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: 'http://localhost:4173',
|
||||
headless: true,
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run preview',
|
||||
port: 4173,
|
||||
timeout: 60_000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { browserName: 'chromium' } },
|
||||
{ name: 'firefox', use: { browserName: 'firefox' } },
|
||||
{ name: 'webkit', use: { browserName: 'webkit' } },
|
||||
],
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" width="192" height="192">
|
||||
<rect width="192" height="192" rx="36" fill="#e6a86b"/>
|
||||
<text x="96" y="124" text-anchor="middle" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-weight="700" font-size="80" fill="#1a0f00">NV</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 313 B |
@@ -1,10 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#e6a86b"/>
|
||||
<stop offset="1" stop-color="#a4633a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="96" fill="url(#g)"/>
|
||||
<text x="256" y="332" text-anchor="middle" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-weight="700" font-size="220" fill="#1a0f00">NV</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 504 B |
@@ -1,92 +0,0 @@
|
||||
/* nvsim dashboard — global styles
|
||||
Ported from `assets/NVsim Dashboard.zip` per ADR-092 §7.1.
|
||||
Per-component scoped styles live in each Lit element. */
|
||||
|
||||
:root {
|
||||
--bg-0: #07090d;
|
||||
--bg-1: #0d1117;
|
||||
--bg-2: #131a23;
|
||||
--bg-3: #1a232f;
|
||||
--line: #1f2a38;
|
||||
--line-2: #2a3848;
|
||||
--ink: #e6edf3;
|
||||
--ink-2: #b8c2cc;
|
||||
--ink-3: #7c8694;
|
||||
--ink-4: #4a5462;
|
||||
--accent: oklch(0.78 0.14 70);
|
||||
--accent-2: oklch(0.78 0.12 195);
|
||||
--accent-3: oklch(0.72 0.18 330);
|
||||
--accent-4: oklch(0.78 0.14 145);
|
||||
--warn: oklch(0.7 0.18 35);
|
||||
--ok: oklch(0.78 0.14 145);
|
||||
--bad: oklch(0.65 0.22 25);
|
||||
--grid: rgba(255, 255, 255, 0.04);
|
||||
--shadow: 0 20px 60px -20px rgba(0, 0, 0, 0.6),
|
||||
0 4px 12px -4px rgba(0, 0, 0, 0.4);
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
--sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-0: #f4f5f7;
|
||||
--bg-1: #fbfbfc;
|
||||
--bg-2: #ffffff;
|
||||
--bg-3: #f0f2f5;
|
||||
--line: #d8dde3;
|
||||
--line-2: #c1c8d1;
|
||||
--ink: #0e131a;
|
||||
--ink-2: #2c3744;
|
||||
--ink-3: #54606e; /* AA on --bg-1 #fbfbfc — was #6b7684 (3.7:1), now ~5.4:1 */
|
||||
--ink-4: #7a8390; /* improved from #9ba4b0 for incidental UI labels */
|
||||
--grid: rgba(0, 0, 0, 0.05);
|
||||
--shadow: 0 12px 40px -16px rgba(15, 30, 55, 0.18),
|
||||
0 2px 8px -2px rgba(15, 30, 55, 0.08);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
background: var(--bg-0);
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
button { font-family: inherit; color: inherit; cursor: pointer; }
|
||||
input, select { font-family: inherit; color: inherit; }
|
||||
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--ink-4); }
|
||||
|
||||
@keyframes pulse { 50% { opacity: 0.5; } }
|
||||
@keyframes dash { to { stroke-dashoffset: -200; } }
|
||||
@keyframes float-up {
|
||||
0% { opacity: 0; transform: translateY(8px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes diamond-spin {
|
||||
0% { transform: rotateY(0) rotateX(8deg); }
|
||||
100% { transform: rotateY(360deg) rotateX(8deg); }
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
body.reduce-motion *,
|
||||
body.reduce-motion *::before,
|
||||
body.reduce-motion *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Density (set via class on <body> by setDensity()) */
|
||||
body.density-comfy { font-size: 15px; }
|
||||
body.density-default { font-size: 14px; }
|
||||
body.density-compact { font-size: 13px; }
|
||||
@@ -1,399 +0,0 @@
|
||||
/* App Store — catalog of every WASM edge module + simulator app.
|
||||
*
|
||||
* Mirrors `wifi-densepose-wasm-edge`'s 60+ hot-loadable algorithms and
|
||||
* the `nvsim` simulator. Each card is filterable by category, fuzzy
|
||||
* name search, and maturity (available / beta / research). A toggle on
|
||||
* each card flips activation in the live session — that drives the
|
||||
* dashboard's event log when running. WS transport (future) pushes the
|
||||
* activation set to the connected ESP32 mesh.
|
||||
*
|
||||
* ADR-092 §18.
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { signal, effect } from '@preact/signals-core';
|
||||
import {
|
||||
APPS, CATEGORIES, defaultActivations, fuzzyMatch,
|
||||
type AppCategory, type AppManifest, type AppActivation,
|
||||
} from '../store/apps';
|
||||
import { kvGet, kvSet } from '../store/persistence';
|
||||
import { pushLog, activeAppIds, appEvents, appEventCounts } from '../store/appStore';
|
||||
import { hasRuntime } from '../store/appRuntimes';
|
||||
|
||||
const activations = signal<AppActivation[]>(defaultActivations());
|
||||
const query = signal<string>('');
|
||||
const activeCat = signal<AppCategory | 'all'>('all');
|
||||
const statusFilter = signal<'all' | 'available' | 'beta' | 'research'>('all');
|
||||
|
||||
(async () => {
|
||||
const saved = await kvGet<AppActivation[]>('app-activations');
|
||||
if (saved) activations.value = saved;
|
||||
})();
|
||||
|
||||
effect(() => {
|
||||
// Persist activations on change (post-load) AND mirror into the
|
||||
// active-set signal that main.ts watches to drive runtime dispatch.
|
||||
const v = activations.value;
|
||||
if (v.length > 0) void kvSet('app-activations', v);
|
||||
const set = new Set<string>();
|
||||
for (const a of v) if (a.active) set.add(a.id);
|
||||
activeAppIds.value = set;
|
||||
});
|
||||
|
||||
@customElement('nv-app-store')
|
||||
export class NvAppStore extends LitElement {
|
||||
@state() private renderTick = 0;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: radial-gradient(ellipse at 50% 30%, var(--bg-2) 0%, var(--bg-0) 70%);
|
||||
padding: 24px;
|
||||
}
|
||||
.head {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ttl {
|
||||
font-size: 22px; font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
flex: 1; min-width: 200px;
|
||||
}
|
||||
.ttl small {
|
||||
font-size: 12.5px; font-weight: 400;
|
||||
color: var(--ink-3); margin-left: 8px;
|
||||
}
|
||||
.search {
|
||||
width: 320px; max-width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--ink); outline: none;
|
||||
}
|
||||
.search:focus { border-color: var(--accent); }
|
||||
.filters {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.chip {
|
||||
padding: 4px 10px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
cursor: pointer;
|
||||
font-family: var(--mono);
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.chip:hover { color: var(--ink); border-color: var(--line-2); }
|
||||
.chip.on { background: var(--bg-3); border-color: var(--accent); color: var(--ink); }
|
||||
.chip .swatch {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
}
|
||||
.chip .count { color: var(--ink-3); font-size: 10px; }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.card {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 14px;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
.card:hover { border-color: var(--line-2); transform: translateY(-1px); }
|
||||
.card.active {
|
||||
border-color: oklch(0.78 0.14 145 / 0.7);
|
||||
background: linear-gradient(180deg, var(--bg-2) 0%, oklch(0.78 0.14 145 / 0.04) 100%);
|
||||
}
|
||||
.card-h {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.card-h .name {
|
||||
font-size: 13.5px; font-weight: 600; color: var(--ink);
|
||||
flex: 1; line-height: 1.3;
|
||||
}
|
||||
.card-h .swatch {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
flex-shrink: 0; margin-top: 4px;
|
||||
}
|
||||
.summary {
|
||||
font-size: 12px; color: var(--ink-2); line-height: 1.45;
|
||||
flex: 1;
|
||||
}
|
||||
.meta {
|
||||
display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px;
|
||||
font-family: var(--mono); font-size: 10px;
|
||||
}
|
||||
.badge {
|
||||
padding: 1px 6px; border-radius: 4px;
|
||||
background: var(--bg-3); color: var(--ink-3);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.badge.cat { color: var(--accent); border-color: oklch(0.78 0.14 70 / 0.3); }
|
||||
.badge.status-available { color: var(--ok); border-color: oklch(0.78 0.14 145 / 0.4); }
|
||||
.badge.status-beta { color: var(--warn); border-color: oklch(0.7 0.18 35 / 0.4); }
|
||||
.badge.status-research { color: var(--accent-3); border-color: oklch(0.72 0.18 330 / 0.4); }
|
||||
.badge.budget { color: var(--accent-2); border-color: oklch(0.78 0.12 195 / 0.3); }
|
||||
.badge.rt-running { color: var(--ok); border-color: oklch(0.78 0.14 145 / 0.5); background: oklch(0.78 0.14 145 / 0.08); }
|
||||
.badge.rt-simulated { color: var(--accent); border-color: oklch(0.78 0.14 70 / 0.5); background: oklch(0.78 0.14 70 / 0.08); }
|
||||
.badge.rt-mesh-only { color: var(--ink-3); border-color: var(--line); }
|
||||
.events-feed {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.events-feed h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px; font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
.events-feed .lead {
|
||||
font-size: 12px; color: var(--ink-3);
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.events-feed .lines {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
max-height: 160px; overflow-y: auto;
|
||||
}
|
||||
.ev-line {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 90px 1fr;
|
||||
gap: 10px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.ev-line:hover { background: var(--bg-3); }
|
||||
.ev-line .ts { color: var(--ink-4); font-size: 10.5px; }
|
||||
.ev-line .id { color: var(--accent); font-size: 10.5px; }
|
||||
.ev-line .body { color: var(--ink); }
|
||||
.ev-empty {
|
||||
font-size: 12px; color: var(--ink-3);
|
||||
padding: 8px 0;
|
||||
}
|
||||
.card-events-count {
|
||||
font-size: 10.5px;
|
||||
color: var(--accent-4);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.card-foot {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding-top: 8px; margin-top: 4px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 11px; color: var(--ink-3);
|
||||
}
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 32px; height: 18px;
|
||||
background: var(--bg-3); border: 1px solid var(--line-2);
|
||||
border-radius: 999px; cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle::after {
|
||||
content: ''; position: absolute;
|
||||
top: 1px; left: 1px;
|
||||
width: 12px; height: 12px;
|
||||
background: var(--ink-3); border-radius: 50%;
|
||||
transition: transform 0.15s, background 0.15s;
|
||||
}
|
||||
.toggle.on { background: var(--accent); border-color: var(--accent); }
|
||||
.toggle.on::after { background: #1a0f00; transform: translateX(14px); }
|
||||
.events {
|
||||
font-family: var(--mono); font-size: 10px; color: var(--ink-3);
|
||||
flex: 1;
|
||||
}
|
||||
.empty {
|
||||
padding: 40px;
|
||||
text-align: center; color: var(--ink-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
effect(() => {
|
||||
activations.value; query.value; activeCat.value; statusFilter.value;
|
||||
appEvents.value; appEventCounts.value;
|
||||
this.renderTick++;
|
||||
});
|
||||
}
|
||||
|
||||
private isActive(id: string): boolean {
|
||||
return activations.value.find((a) => a.id === id)?.active === true;
|
||||
}
|
||||
|
||||
private toggle(app: AppManifest): void {
|
||||
const wasActive = this.isActive(app.id);
|
||||
const next = activations.value.map((a) => a.id === app.id ? { ...a, active: !a.active, lastActivatedAt: Date.now() } : a);
|
||||
activations.value = next;
|
||||
if (!wasActive) {
|
||||
const r = app.runtime ?? 'mesh-only';
|
||||
const note = r === 'simulated' ? ' · live runtime engaged'
|
||||
: r === 'mesh-only' ? ' · queued (needs ESP32 mesh)'
|
||||
: '';
|
||||
pushLog('ok', `app <span class="k">${app.id}</span> activated${note}`);
|
||||
} else {
|
||||
pushLog('info', `app <span class="k">${app.id}</span> deactivated`);
|
||||
}
|
||||
}
|
||||
|
||||
private filtered(): AppManifest[] {
|
||||
let list = APPS;
|
||||
if (activeCat.value !== 'all') list = list.filter((a) => a.category === activeCat.value);
|
||||
if (statusFilter.value !== 'all') list = list.filter((a) => a.status === statusFilter.value);
|
||||
if (query.value.trim()) {
|
||||
list = list
|
||||
.map((a) => ({ a, s: fuzzyMatch(query.value, a) }))
|
||||
.filter((x) => x.s > 0)
|
||||
.sort((a, b) => b.s - a.s)
|
||||
.map((x) => x.a);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private categoryCounts(): Record<string, number> {
|
||||
const counts: Record<string, number> = { all: APPS.length };
|
||||
for (const k of Object.keys(CATEGORIES)) counts[k] = 0;
|
||||
for (const a of APPS) counts[a.category] = (counts[a.category] ?? 0) + 1;
|
||||
return counts;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const list = this.filtered();
|
||||
const counts = this.categoryCounts();
|
||||
const activeCount = activations.value.filter((a) => a.active).length;
|
||||
return html`
|
||||
<div class="head">
|
||||
<div class="ttl">
|
||||
App Store
|
||||
<small>${APPS.length} edge apps · ${activeCount} active</small>
|
||||
</div>
|
||||
<input class="search" id="app-search" placeholder="Search by name, tag, or category…"
|
||||
.value=${query.value}
|
||||
@input=${(e: Event) => { query.value = (e.target as HTMLInputElement).value; }} />
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<span class="chip ${activeCat.value === 'all' ? 'on' : ''}"
|
||||
@click=${() => activeCat.value = 'all'}>
|
||||
All<span class="count">${counts.all}</span>
|
||||
</span>
|
||||
${(Object.keys(CATEGORIES) as AppCategory[]).map((k) => html`
|
||||
<span class="chip ${activeCat.value === k ? 'on' : ''}"
|
||||
@click=${() => activeCat.value = k}>
|
||||
<span class="swatch" style=${`background:${CATEGORIES[k].color}`}></span>
|
||||
${CATEGORIES[k].label}
|
||||
<span class="count">${counts[k] ?? 0}</span>
|
||||
</span>
|
||||
`)}
|
||||
<span style="flex:1; min-width:8px"></span>
|
||||
<span class="chip ${statusFilter.value === 'all' ? 'on' : ''}" @click=${() => statusFilter.value = 'all'}>any</span>
|
||||
<span class="chip ${statusFilter.value === 'available' ? 'on' : ''}" @click=${() => statusFilter.value = 'available'}>available</span>
|
||||
<span class="chip ${statusFilter.value === 'beta' ? 'on' : ''}" @click=${() => statusFilter.value = 'beta'}>beta</span>
|
||||
<span class="chip ${statusFilter.value === 'research' ? 'on' : ''}" @click=${() => statusFilter.value = 'research'}>research</span>
|
||||
</div>
|
||||
|
||||
${this.renderEventsFeed()}
|
||||
|
||||
${list.length === 0
|
||||
? html`<div class="empty">No apps match the current filters.</div>`
|
||||
: html`<div class="grid">${list.map((app) => this.card(app))}</div>`}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEventsFeed() {
|
||||
const evs = appEvents.value.slice(-12).reverse();
|
||||
const activeSimCount = activations.value.filter((a) => a.active && hasRuntime(a.id)).length;
|
||||
return html`
|
||||
<div class="events-feed">
|
||||
<h3>Live runtime feed
|
||||
${activeSimCount > 0
|
||||
? html`<span class="card-events-count" style="margin-left: 8px;">${activeSimCount} simulated app${activeSimCount === 1 ? '' : 's'} active</span>`
|
||||
: ''}
|
||||
</h3>
|
||||
<p class="lead">
|
||||
Apps with the <span class="badge rt-simulated" style="font-size:9.5px; padding:0 4px;">simulated</span>
|
||||
runtime emit real i32 event IDs against nvsim's live frame stream below.
|
||||
Apps with <span class="badge rt-mesh-only" style="font-size:9.5px; padding:0 4px;">mesh-only</span>
|
||||
need an ESP32-S3 + WS transport (deferred to V2). The
|
||||
<span class="badge rt-running" style="font-size:9.5px; padding:0 4px;">running</span>
|
||||
badge marks <code>nvsim</code> itself, which is always running.
|
||||
</p>
|
||||
${evs.length === 0
|
||||
? html`<div class="ev-empty">No events yet. Toggle a card with the <i>simulated</i> badge and press <b>▶ Run</b>.</div>`
|
||||
: html`<div class="lines">${evs.map((ev) => {
|
||||
const dt = new Date(ev.ts);
|
||||
const ts = `${String(dt.getSeconds()).padStart(2, '0')}.${String(dt.getMilliseconds()).padStart(3, '0')}`;
|
||||
return html`
|
||||
<div class="ev-line">
|
||||
<span class="ts">${ts}</span>
|
||||
<span class="id">${ev.appId}</span>
|
||||
<span class="body"><b style="color:var(--accent-2);">${ev.eventName}</b><span style="color:var(--ink-3);"> · ${ev.eventId}</span> ${ev.detail ? `· ${ev.detail}` : ''}</span>
|
||||
</div>
|
||||
`;
|
||||
})}</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private card(app: AppManifest) {
|
||||
const active = this.isActive(app.id);
|
||||
const cat = CATEGORIES[app.category];
|
||||
const runtime = app.runtime ?? 'mesh-only';
|
||||
const evCount = appEventCounts.value[app.id] ?? 0;
|
||||
const runtimeLabel: Record<string, string> = {
|
||||
'running': 'running',
|
||||
'simulated': 'simulated',
|
||||
'mesh-only': 'needs mesh',
|
||||
};
|
||||
const runtimeTip: Record<string, string> = {
|
||||
'running': 'This app is genuinely running in your browser right now.',
|
||||
'simulated': 'A pared-down version of this algorithm runs against nvsim\'s magnetic frame stream as a proxy for its native CSI input. Toggle on, then press ▶ Run to see real event IDs in the feed.',
|
||||
'mesh-only': 'This algorithm needs CSI subcarrier data from an ESP32-S3 mesh. The toggle persists; activation is pushed via WS transport (V2).',
|
||||
};
|
||||
return html`
|
||||
<div class="card ${active ? 'active' : ''}" data-app-id=${app.id}>
|
||||
<div class="card-h">
|
||||
<span class="swatch" style=${`background:${cat.color}`}></span>
|
||||
<span class="name">${app.name}</span>
|
||||
</div>
|
||||
<div class="summary">${app.summary}</div>
|
||||
<div class="meta">
|
||||
<span class="badge cat">${cat.label}</span>
|
||||
<span class="badge status-${app.status}">${app.status}</span>
|
||||
<span class="badge rt-${runtime}" title=${runtimeTip[runtime]}>${runtimeLabel[runtime]}</span>
|
||||
${app.budget ? html`<span class="badge budget">budget ${app.budget}</span>` : ''}
|
||||
${app.adr ? html`<span class="badge">${app.adr}</span>` : ''}
|
||||
${app.events?.length ? html`<span class="badge">events ${app.events.join('·')}</span>` : ''}
|
||||
</div>
|
||||
<div class="card-foot">
|
||||
<span class="events">${app.crate}</span>
|
||||
${evCount > 0 ? html`<span class="card-events-count">⚡ ${evCount} ev</span>` : ''}
|
||||
<span class="toggle ${active ? 'on' : ''}" role="switch"
|
||||
aria-checked=${active}
|
||||
data-app-toggle=${app.id}
|
||||
@click=${() => this.toggle(app)}></span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/* Top-level shell: 4-zone grid with rail / topbar / sidebar / scene / inspector / console.
|
||||
* View routing is per-rail-button: the central area swaps between
|
||||
* `<nv-scene>`, `<nv-app-store>`, etc. */
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import './nv-rail';
|
||||
import './nv-topbar';
|
||||
import './nv-sidebar';
|
||||
import './nv-scene';
|
||||
import './nv-inspector';
|
||||
import './nv-console';
|
||||
import './nv-app-store';
|
||||
import './nv-toast';
|
||||
import './nv-modal';
|
||||
import './nv-palette';
|
||||
import './nv-debug-hud';
|
||||
import './nv-settings-drawer';
|
||||
import './nv-onboarding';
|
||||
import './nv-ghost-murmur';
|
||||
import './nv-help';
|
||||
import './nv-home';
|
||||
|
||||
export type View = 'home' | 'scene' | 'apps' | 'inspector' | 'witness' | 'ghost-murmur';
|
||||
|
||||
@customElement('nv-app')
|
||||
export class NvApp extends LitElement {
|
||||
@state() private view: View = 'home';
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background: var(--bg-0);
|
||||
}
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--accent);
|
||||
color: #1a0f00;
|
||||
border-radius: 6px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
z-index: 1000;
|
||||
transition: top 0.15s;
|
||||
}
|
||||
.skip-link:focus { top: 8px; }
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: 56px 280px 1fr 340px;
|
||||
grid-template-rows: 48px 1fr 220px;
|
||||
grid-template-areas:
|
||||
'rail topbar topbar topbar'
|
||||
'rail sidebar main inspector'
|
||||
'rail sidebar console inspector';
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
/* Home view simplifies: hides sidebar / inspector / console so the
|
||||
hero gets the full screen. Power-user panels stay one rail click away. */
|
||||
.app.simple {
|
||||
grid-template-columns: 56px 1fr;
|
||||
grid-template-rows: 48px 1fr;
|
||||
grid-template-areas:
|
||||
'rail topbar'
|
||||
'rail main';
|
||||
}
|
||||
.app.simple nv-sidebar,
|
||||
.app.simple nv-inspector,
|
||||
.app.simple nv-console { display: none; }
|
||||
nv-rail { grid-area: rail; }
|
||||
nv-topbar { grid-area: topbar; }
|
||||
nv-sidebar { grid-area: sidebar; }
|
||||
.main { grid-area: main; min-width: 0; min-height: 0; position: relative; overflow: hidden; }
|
||||
nv-inspector { grid-area: inspector; }
|
||||
nv-console { grid-area: console; min-height: 0; }
|
||||
@media (max-width: 1180px) {
|
||||
.app {
|
||||
grid-template-columns: 56px 1fr 320px;
|
||||
grid-template-areas:
|
||||
'rail topbar topbar'
|
||||
'rail main inspector'
|
||||
'rail console console';
|
||||
}
|
||||
nv-sidebar { display: none; }
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.app {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 52px 1fr 200px;
|
||||
grid-template-areas:
|
||||
'topbar'
|
||||
'main'
|
||||
'console';
|
||||
}
|
||||
nv-rail, nv-sidebar, nv-inspector { display: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
const isSimple = this.view === 'home';
|
||||
return html`
|
||||
<a class="skip-link" href="#main-content"
|
||||
@click=${(e: Event) => { e.preventDefault(); const sr = this.shadowRoot; sr?.querySelector<HTMLElement>('.main')?.focus(); }}>
|
||||
Skip to main content
|
||||
</a>
|
||||
<div class="app ${isSimple ? 'simple' : ''}">
|
||||
<nv-rail .view=${this.view} @navigate=${(e: CustomEvent<View>) => (this.view = e.detail)}></nv-rail>
|
||||
<nv-topbar></nv-topbar>
|
||||
<nv-sidebar></nv-sidebar>
|
||||
<main class="main" id="main-content" tabindex="-1" role="main" aria-label="Main view">
|
||||
${this.view === 'home'
|
||||
? html`<nv-home></nv-home>`
|
||||
: this.view === 'apps'
|
||||
? html`<nv-app-store></nv-app-store>`
|
||||
: this.view === 'ghost-murmur'
|
||||
? html`<nv-ghost-murmur></nv-ghost-murmur>`
|
||||
: this.view === 'inspector'
|
||||
? html`<nv-inspector expanded .pinTab=${'signal'}></nv-inspector>`
|
||||
: this.view === 'witness'
|
||||
? html`<nv-inspector expanded .pinTab=${'witness'}></nv-inspector>`
|
||||
: html`<nv-scene></nv-scene>`}
|
||||
</main>
|
||||
<nv-inspector
|
||||
.pinTab=${this.view === 'inspector' ? 'signal'
|
||||
: this.view === 'witness' ? 'witness' : null}>
|
||||
</nv-inspector>
|
||||
<nv-console></nv-console>
|
||||
</div>
|
||||
<nv-toast></nv-toast>
|
||||
<nv-modal></nv-modal>
|
||||
<nv-palette></nv-palette>
|
||||
<nv-debug-hud></nv-debug-hud>
|
||||
<nv-settings-drawer></nv-settings-drawer>
|
||||
<nv-onboarding></nv-onboarding>
|
||||
<nv-help></nv-help>
|
||||
`;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user