diff --git a/.dockerignore b/.dockerignore index 218ee13..b56915a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,132 +1,8 @@ -# Git -.git -.gitignore -.gitattributes - -# Documentation -*.md -docs/ -references/ -plans/ - -# Development files -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# Virtual environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Testing -.tox/ -.coverage -.coverage.* -.cache -.pytest_cache/ -htmlcov/ -.nox/ -coverage.xml -*.cover -.hypothesis/ - -# Jupyter Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# Environments -.env.local -.env.development -.env.test -.env.production - -# Logs -logs/ +target/ +.git/ *.log - -# Runtime data -pids/ -*.pid -*.seed -*.pid.lock - -# Temporary files -tmp/ -temp/ -.tmp/ - -# OS generated files -.DS_Store -.DS_Store? -._* -.Spotlight-V100 -.Trashes -ehthumbs.db -Thumbs.db - -# IDE -*.sublime-project -*.sublime-workspace - -# Deployment -docker-compose*.yml -Dockerfile* -.dockerignore -k8s/ -terraform/ -ansible/ -monitoring/ -logging/ - -# CI/CD -.github/ -.gitlab-ci.yml - -# Models (exclude large model files from build context) -*.pth -*.pt -*.onnx -models/*.bin -models/*.safetensors - -# Data files -data/ -*.csv -*.json -*.parquet - -# Backup files -*.bak -*.backup \ No newline at end of file +__pycache__/ +*.pyc +.env +node_modules/ +.claude/ diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 2ee9648..0000000 --- a/Dockerfile +++ /dev/null @@ -1,104 +0,0 @@ -# Multi-stage build for WiFi-DensePose production deployment -FROM python:3.11-slim as base - -# Set environment variables -ENV PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 \ - PIP_NO_CACHE_DIR=1 \ - PIP_DISABLE_PIP_VERSION_CHECK=1 - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - build-essential \ - curl \ - git \ - libopencv-dev \ - python3-opencv \ - && rm -rf /var/lib/apt/lists/* - -# Create app user -RUN groupadd -r appuser && useradd -r -g appuser appuser - -# Set work directory -WORKDIR /app - -# Copy requirements first for better caching -COPY requirements.txt . - -# Install Python dependencies -RUN pip install --no-cache-dir -r requirements.txt - -# Development stage -FROM base as development - -# Install development dependencies -RUN pip install --no-cache-dir \ - pytest \ - pytest-asyncio \ - pytest-mock \ - pytest-benchmark \ - black \ - flake8 \ - mypy - -# Copy source code -COPY . . - -# Change ownership to app user -RUN chown -R appuser:appuser /app - -USER appuser - -# Expose port -EXPOSE 8000 - -# Development command -CMD ["uvicorn", "v1.src.api.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] - -# Production stage -FROM base as production - -# Copy only necessary files -COPY requirements.txt . -COPY v1/src/ ./v1/src/ -COPY assets/ ./assets/ - -# Create necessary directories -RUN mkdir -p /app/logs /app/data /app/models - -# Change ownership to app user -RUN chown -R appuser:appuser /app - -USER appuser - -# Health check -HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:8000/health || exit 1 - -# Expose port -EXPOSE 8000 - -# Production command -CMD ["uvicorn", "v1.src.api.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"] - -# Testing stage -FROM development as testing - -# Copy test files -COPY v1/tests/ ./v1/tests/ - -# Run tests -RUN python -m pytest v1/tests/ -v - -# Security scanning stage -FROM production as security - -# Install security scanning tools -USER root -RUN pip install --no-cache-dir safety bandit - -# Run security scans -RUN safety check -RUN bandit -r v1/src/ -f json -o /tmp/bandit-report.json - -USER appuser \ No newline at end of file diff --git a/README.md b/README.md index 938a46b..be88df7 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ A high-performance Rust port is available in `/rust-port/wifi-densepose-rs/`: | Memory Usage | ~500MB | ~100MB | | WASM Support | ❌ | ✅ | | Binary Size | N/A | ~10MB | -| Test Coverage | 100% | 313 tests | +| Test Coverage | 100% | 542+ tests | **Quick Start (Rust):** ```bash @@ -261,7 +261,7 @@ The RuVector Format (RVF) packages model weights, HNSW index, metadata, and WASM | **Progressive Loading** | Layer A in <5ms (entry points), Layer B in 100ms-1s (hot adjacency), Layer C (full graph) | | **Signing** | Ed25519-signed training proofs for verifiable provenance | | **Quantization** | Temperature-tiered (f32/f16/u8) via `rvf-quant` with SIMD distance | -| **CLI Flags** | `--save-rvf ` and `--load-rvf ` for model persistence | +| **CLI Flags** | `--export-rvf`, `--save-rvf`, `--load-rvf`, `--model` for model persistence | An RVF container is a self-contained artifact: no external model files, no Python runtime, no pip dependencies. Load it on any host with the Rust binary. @@ -529,9 +529,29 @@ pip install wifi-densepose[all] # All optional dependencies ### Using Docker +Pre-built images are published on Docker Hub: + ```bash +# Rust sensing server (132 MB — recommended) docker pull ruvnet/wifi-densepose:latest -docker run -p 8000:8000 ruvnet/wifi-densepose:latest +docker run -p 3000:3000 -p 3001:3001 -p 5005:5005/udp ruvnet/wifi-densepose:latest + +# Python sensing pipeline (569 MB) +docker pull ruvnet/wifi-densepose:python +docker run -p 8765:8765 -p 8080:8080 ruvnet/wifi-densepose:python + +# Or use docker-compose for both +cd docker && docker compose up +``` + +| Image | Tag | Size | Ports | +|-------|-----|------|-------| +| `ruvnet/wifi-densepose` | `latest`, `rust` | 132 MB | 3000 (REST), 3001 (WS), 5005/udp (ESP32) | +| `ruvnet/wifi-densepose` | `python` | 569 MB | 8765 (WS), 8080 (UI) | + +**Export RVF model package:** +```bash +docker run --rm -v $(pwd):/out ruvnet/wifi-densepose:latest --export-rvf /out/wifi-densepose-v1.rvf ``` ### System Requirements diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml deleted file mode 100644 index d69f270..0000000 --- a/docker-compose.prod.yml +++ /dev/null @@ -1,306 +0,0 @@ -version: '3.8' - -services: - wifi-densepose: - build: - context: . - dockerfile: Dockerfile - target: production - image: wifi-densepose:latest - container_name: wifi-densepose-prod - ports: - - "8000:8000" - volumes: - - wifi_densepose_logs:/app/logs - - wifi_densepose_data:/app/data - - wifi_densepose_models:/app/models - environment: - - ENVIRONMENT=production - - DEBUG=false - - LOG_LEVEL=info - - RELOAD=false - - WORKERS=4 - - ENABLE_TEST_ENDPOINTS=false - - ENABLE_AUTHENTICATION=true - - ENABLE_RATE_LIMITING=true - - DATABASE_URL=${DATABASE_URL} - - REDIS_URL=${REDIS_URL} - - SECRET_KEY=${SECRET_KEY} - - JWT_SECRET=${JWT_SECRET} - - ALLOWED_HOSTS=${ALLOWED_HOSTS} - secrets: - - db_password - - redis_password - - jwt_secret - - api_key - deploy: - replicas: 3 - restart_policy: - condition: on-failure - delay: 5s - max_attempts: 3 - window: 120s - update_config: - parallelism: 1 - delay: 10s - failure_action: rollback - monitor: 60s - max_failure_ratio: 0.3 - rollback_config: - parallelism: 1 - delay: 0s - failure_action: pause - monitor: 60s - max_failure_ratio: 0.3 - resources: - limits: - cpus: '2.0' - memory: 4G - reservations: - cpus: '1.0' - memory: 2G - networks: - - wifi-densepose-network - - monitoring-network - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 60s - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - - postgres: - image: postgres:15-alpine - container_name: wifi-densepose-postgres-prod - environment: - - POSTGRES_DB=${POSTGRES_DB} - - POSTGRES_USER=${POSTGRES_USER} - - POSTGRES_PASSWORD_FILE=/run/secrets/db_password - volumes: - - postgres_data:/var/lib/postgresql/data - - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql - - ./backups:/backups - secrets: - - db_password - deploy: - replicas: 1 - restart_policy: - condition: on-failure - delay: 5s - max_attempts: 3 - resources: - limits: - cpus: '1.0' - memory: 2G - reservations: - cpus: '0.5' - memory: 1G - networks: - - wifi-densepose-network - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 10s - timeout: 5s - retries: 5 - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - - redis: - image: redis:7-alpine - container_name: wifi-densepose-redis-prod - command: redis-server --appendonly yes --requirepass-file /run/secrets/redis_password - volumes: - - redis_data:/data - secrets: - - redis_password - deploy: - replicas: 1 - restart_policy: - condition: on-failure - delay: 5s - max_attempts: 3 - resources: - limits: - cpus: '0.5' - memory: 1G - reservations: - cpus: '0.25' - memory: 512M - networks: - - wifi-densepose-network - healthcheck: - test: ["CMD", "redis-cli", "--raw", "incr", "ping"] - interval: 10s - timeout: 3s - retries: 5 - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - - nginx: - image: nginx:alpine - container_name: wifi-densepose-nginx-prod - volumes: - - ./nginx/nginx.prod.conf:/etc/nginx/nginx.conf - - ./nginx/ssl:/etc/nginx/ssl - - nginx_logs:/var/log/nginx - ports: - - "80:80" - - "443:443" - deploy: - replicas: 2 - restart_policy: - condition: on-failure - delay: 5s - max_attempts: 3 - resources: - limits: - cpus: '0.5' - memory: 512M - reservations: - cpus: '0.25' - memory: 256M - networks: - - wifi-densepose-network - depends_on: - - wifi-densepose - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost/health"] - interval: 30s - timeout: 10s - retries: 3 - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - - prometheus: - image: prom/prometheus:latest - container_name: wifi-densepose-prometheus-prod - command: - - '--config.file=/etc/prometheus/prometheus.yml' - - '--storage.tsdb.path=/prometheus' - - '--web.console.libraries=/etc/prometheus/console_libraries' - - '--web.console.templates=/etc/prometheus/consoles' - - '--storage.tsdb.retention.time=15d' - - '--web.enable-lifecycle' - - '--web.enable-admin-api' - volumes: - - ./monitoring/prometheus-config.yml:/etc/prometheus/prometheus.yml - - ./monitoring/alerting-rules.yml:/etc/prometheus/alerting-rules.yml - - prometheus_data:/prometheus - deploy: - replicas: 1 - restart_policy: - condition: on-failure - delay: 5s - max_attempts: 3 - resources: - limits: - cpus: '1.0' - memory: 2G - reservations: - cpus: '0.5' - memory: 1G - networks: - - monitoring-network - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9090/-/healthy"] - interval: 30s - timeout: 10s - retries: 3 - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - - grafana: - image: grafana/grafana:latest - container_name: wifi-densepose-grafana-prod - environment: - - GF_SECURITY_ADMIN_PASSWORD_FILE=/run/secrets/grafana_password - - GF_USERS_ALLOW_SIGN_UP=false - - GF_INSTALL_PLUGINS=grafana-piechart-panel - volumes: - - grafana_data:/var/lib/grafana - - ./monitoring/grafana-dashboard.json:/etc/grafana/provisioning/dashboards/dashboard.json - - ./monitoring/grafana-datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml - secrets: - - grafana_password - deploy: - replicas: 1 - restart_policy: - condition: on-failure - delay: 5s - max_attempts: 3 - resources: - limits: - cpus: '0.5' - memory: 1G - reservations: - cpus: '0.25' - memory: 512M - networks: - - monitoring-network - depends_on: - - prometheus - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"] - interval: 30s - timeout: 10s - retries: 3 - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - -volumes: - postgres_data: - driver: local - redis_data: - driver: local - prometheus_data: - driver: local - grafana_data: - driver: local - wifi_densepose_logs: - driver: local - wifi_densepose_data: - driver: local - wifi_densepose_models: - driver: local - nginx_logs: - driver: local - -networks: - wifi-densepose-network: - driver: overlay - attachable: true - monitoring-network: - driver: overlay - attachable: true - -secrets: - db_password: - external: true - redis_password: - external: true - jwt_secret: - external: true - api_key: - external: true - grafana_password: - external: true \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index a7a9399..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,141 +0,0 @@ -version: '3.8' - -services: - wifi-densepose: - build: - context: . - dockerfile: Dockerfile - target: development - container_name: wifi-densepose-dev - ports: - - "8000:8000" - volumes: - - .:/app - - wifi_densepose_logs:/app/logs - - wifi_densepose_data:/app/data - - wifi_densepose_models:/app/models - environment: - - ENVIRONMENT=development - - DEBUG=true - - LOG_LEVEL=debug - - RELOAD=true - - ENABLE_TEST_ENDPOINTS=true - - ENABLE_AUTHENTICATION=false - - ENABLE_RATE_LIMITING=false - - DATABASE_URL=postgresql://wifi_user:wifi_pass@postgres:5432/wifi_densepose - - REDIS_URL=redis://redis:6379/0 - depends_on: - - postgres - - redis - networks: - - wifi-densepose-network - restart: unless-stopped - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - - postgres: - image: postgres:15-alpine - container_name: wifi-densepose-postgres - environment: - - POSTGRES_DB=wifi_densepose - - POSTGRES_USER=wifi_user - - POSTGRES_PASSWORD=wifi_pass - volumes: - - postgres_data:/var/lib/postgresql/data - - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql - ports: - - "5432:5432" - networks: - - wifi-densepose-network - restart: unless-stopped - healthcheck: - test: ["CMD-SHELL", "pg_isready -U wifi_user -d wifi_densepose"] - interval: 10s - timeout: 5s - retries: 5 - - redis: - image: redis:7-alpine - container_name: wifi-densepose-redis - command: redis-server --appendonly yes --requirepass redis_pass - volumes: - - redis_data:/data - ports: - - "6379:6379" - networks: - - wifi-densepose-network - restart: unless-stopped - healthcheck: - test: ["CMD", "redis-cli", "--raw", "incr", "ping"] - interval: 10s - timeout: 3s - retries: 5 - - prometheus: - image: prom/prometheus:latest - container_name: wifi-densepose-prometheus - command: - - '--config.file=/etc/prometheus/prometheus.yml' - - '--storage.tsdb.path=/prometheus' - - '--web.console.libraries=/etc/prometheus/console_libraries' - - '--web.console.templates=/etc/prometheus/consoles' - - '--storage.tsdb.retention.time=200h' - - '--web.enable-lifecycle' - volumes: - - ./monitoring/prometheus-config.yml:/etc/prometheus/prometheus.yml - - prometheus_data:/prometheus - ports: - - "9090:9090" - networks: - - wifi-densepose-network - restart: unless-stopped - - grafana: - image: grafana/grafana:latest - container_name: wifi-densepose-grafana - environment: - - GF_SECURITY_ADMIN_PASSWORD=admin - - GF_USERS_ALLOW_SIGN_UP=false - volumes: - - grafana_data:/var/lib/grafana - - ./monitoring/grafana-dashboard.json:/etc/grafana/provisioning/dashboards/dashboard.json - - ./monitoring/grafana-datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml - ports: - - "3000:3000" - networks: - - wifi-densepose-network - restart: unless-stopped - depends_on: - - prometheus - - nginx: - image: nginx:alpine - container_name: wifi-densepose-nginx - volumes: - - ./nginx/nginx.conf:/etc/nginx/nginx.conf - - ./nginx/ssl:/etc/nginx/ssl - ports: - - "80:80" - - "443:443" - networks: - - wifi-densepose-network - restart: unless-stopped - depends_on: - - wifi-densepose - -volumes: - postgres_data: - redis_data: - prometheus_data: - grafana_data: - wifi_densepose_logs: - wifi_densepose_data: - wifi_densepose_models: - -networks: - wifi-densepose-network: - driver: bridge \ No newline at end of file diff --git a/docker/.dockerignore b/docker/.dockerignore new file mode 100644 index 0000000..d2490f7 --- /dev/null +++ b/docker/.dockerignore @@ -0,0 +1,9 @@ +target/ +.git/ +*.md +*.log +__pycache__/ +*.pyc +.env +node_modules/ +.claude/ diff --git a/docker/Dockerfile.python b/docker/Dockerfile.python new file mode 100644 index 0000000..88b9e77 --- /dev/null +++ b/docker/Dockerfile.python @@ -0,0 +1,29 @@ +# WiFi-DensePose Python Sensing Pipeline +# RSSI-based presence/motion detection + WebSocket server + +FROM python:3.11-slim-bookworm + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY v1/requirements-lock.txt /app/requirements.txt +RUN pip install --no-cache-dir -r requirements.txt \ + && pip install --no-cache-dir websockets uvicorn fastapi + +# Copy application code +COPY v1/ /app/v1/ +COPY ui/ /app/ui/ + +# Copy sensing modules +COPY v1/src/sensing/ /app/v1/src/sensing/ + +EXPOSE 8765 +EXPOSE 8080 + +ENV PYTHONUNBUFFERED=1 + +CMD ["python", "-m", "v1.src.sensing.ws_server"] diff --git a/docker/Dockerfile.rust b/docker/Dockerfile.rust new file mode 100644 index 0000000..603cd1b --- /dev/null +++ b/docker/Dockerfile.rust @@ -0,0 +1,46 @@ +# WiFi-DensePose Rust Sensing Server +# Includes RuVector signal intelligence crates +# Multi-stage build for minimal final image + +# Stage 1: Build +FROM rust:1.85-bookworm AS builder + +WORKDIR /build + +# Copy workspace files +COPY rust-port/wifi-densepose-rs/Cargo.toml rust-port/wifi-densepose-rs/Cargo.lock ./ +COPY rust-port/wifi-densepose-rs/crates/ ./crates/ + +# Copy vendored RuVector crates +COPY vendor/ruvector/ /build/vendor/ruvector/ + +# Build release binary +RUN cargo build --release -p wifi-densepose-sensing-server 2>&1 \ + && strip target/release/sensing-server + +# Stage 2: Runtime +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy binary +COPY --from=builder /build/target/release/sensing-server /app/sensing-server + +# Copy UI assets +COPY ui/ /app/ui/ + +# HTTP API +EXPOSE 3000 +# WebSocket +EXPOSE 3001 +# ESP32 UDP +EXPOSE 5005/udp + +ENV RUST_LOG=info + +ENTRYPOINT ["/app/sensing-server"] +CMD ["--source", "simulated", "--tick-ms", "100", "--ui-path", "/app/ui"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..311ba66 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,26 @@ +version: "3.9" + +services: + sensing-server: + build: + context: .. + dockerfile: docker/Dockerfile.rust + image: ruvnet/wifi-densepose:latest + ports: + - "3000:3000" # REST API + - "3001:3001" # WebSocket + - "5005:5005/udp" # ESP32 UDP + environment: + - RUST_LOG=info + command: ["--source", "simulated", "--tick-ms", "100", "--ui-path", "/app/ui"] + + python-sensing: + build: + context: .. + dockerfile: docker/Dockerfile.python + image: ruvnet/wifi-densepose:python + ports: + - "8765:8765" # WebSocket + - "8080:8080" # UI + environment: + - PYTHONUNBUFFERED=1 diff --git a/docker/wifi-densepose-v1.rvf b/docker/wifi-densepose-v1.rvf new file mode 100644 index 0000000..587321e Binary files /dev/null and b/docker/wifi-densepose-v1.rvf differ diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/graph_transformer.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/graph_transformer.rs index e46e5ce..62a5a81 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/graph_transformer.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/graph_transformer.rs @@ -100,6 +100,32 @@ impl Linear { assert_eq!(b.len(), self.out_features); self.bias = b; } + + /// Push all weights (row-major) then bias into a flat vec. + pub fn flatten_into(&self, out: &mut Vec) { + for row in &self.weights { + out.extend_from_slice(row); + } + out.extend_from_slice(&self.bias); + } + + /// Restore from a flat slice. Returns (Self, number of f32s consumed). + pub fn unflatten_from(data: &[f32], in_f: usize, out_f: usize) -> (Self, usize) { + let n = in_f * out_f + out_f; + assert!(data.len() >= n, "unflatten_from: need {n} floats, got {}", data.len()); + let mut weights = Vec::with_capacity(out_f); + for r in 0..out_f { + let start = r * in_f; + weights.push(data[start..start + in_f].to_vec()); + } + let bias = data[in_f * out_f..n].to_vec(); + (Self { in_features: in_f, out_features: out_f, weights, bias }, n) + } + + /// Total number of trainable parameters. + pub fn param_count(&self) -> usize { + self.in_features * self.out_features + self.out_features + } } // ── AntennaGraph ───────────────────────────────────────────────────────── @@ -254,6 +280,35 @@ impl CrossAttention { } pub fn d_model(&self) -> usize { self.d_model } pub fn n_heads(&self) -> usize { self.n_heads } + + /// Push all cross-attention weights (w_q, w_k, w_v, w_o) into flat vec. + pub fn flatten_into(&self, out: &mut Vec) { + self.w_q.flatten_into(out); + self.w_k.flatten_into(out); + self.w_v.flatten_into(out); + self.w_o.flatten_into(out); + } + + /// Restore cross-attention weights from flat slice. Returns (Self, consumed). + pub fn unflatten_from(data: &[f32], d_model: usize, n_heads: usize) -> (Self, usize) { + let mut offset = 0; + let (w_q, n) = Linear::unflatten_from(&data[offset..], d_model, d_model); + offset += n; + let (w_k, n) = Linear::unflatten_from(&data[offset..], d_model, d_model); + offset += n; + let (w_v, n) = Linear::unflatten_from(&data[offset..], d_model, d_model); + offset += n; + let (w_o, n) = Linear::unflatten_from(&data[offset..], d_model, d_model); + offset += n; + let d_k = d_model / n_heads; + (Self { d_model, n_heads, d_k, w_q, w_k, w_v, w_o }, offset) + } + + /// Total trainable params in cross-attention. + pub fn param_count(&self) -> usize { + self.w_q.param_count() + self.w_k.param_count() + + self.w_v.param_count() + self.w_o.param_count() + } } // ── GraphMessagePassing ────────────────────────────────────────────────── @@ -261,8 +316,10 @@ impl CrossAttention { /// GCN layer: H' = ReLU(A_norm H W) where A_norm = D^{-1/2} A D^{-1/2}. #[derive(Debug, Clone)] pub struct GraphMessagePassing { - in_features: usize, out_features: usize, - weight: Linear, norm_adj: [[f32; 17]; 17], + pub(crate) in_features: usize, + pub(crate) out_features: usize, + pub(crate) weight: Linear, + norm_adj: [[f32; 17]; 17], } impl GraphMessagePassing { @@ -285,24 +342,55 @@ impl GraphMessagePassing { } pub fn in_features(&self) -> usize { self.in_features } pub fn out_features(&self) -> usize { self.out_features } + + /// Push all layer weights into a flat vec. + pub fn flatten_into(&self, out: &mut Vec) { + self.weight.flatten_into(out); + } + + /// Restore from a flat slice. Returns number of f32s consumed. + pub fn unflatten_from(&mut self, data: &[f32]) -> usize { + let (lin, consumed) = Linear::unflatten_from(data, self.in_features, self.out_features); + self.weight = lin; + consumed + } + + /// Total trainable params in this GCN layer. + pub fn param_count(&self) -> usize { self.weight.param_count() } } /// Stack of GCN layers. #[derive(Debug, Clone)] -struct GnnStack { layers: Vec } +pub struct GnnStack { pub(crate) layers: Vec } impl GnnStack { - fn new(in_f: usize, out_f: usize, n: usize, g: &BodyGraph) -> Self { + pub fn new(in_f: usize, out_f: usize, n: usize, g: &BodyGraph) -> Self { assert!(n >= 1); let mut layers = vec![GraphMessagePassing::new(in_f, out_f, g)]; for _ in 1..n { layers.push(GraphMessagePassing::new(out_f, out_f, g)); } Self { layers } } - fn forward(&self, feats: &[Vec]) -> Vec> { + pub fn forward(&self, feats: &[Vec]) -> Vec> { let mut h = feats.to_vec(); for l in &self.layers { h = l.forward(&h); } h } + /// Push all GNN weights into a flat vec. + pub fn flatten_into(&self, out: &mut Vec) { + for l in &self.layers { l.flatten_into(out); } + } + /// Restore GNN weights from flat slice. Returns number of f32s consumed. + pub fn unflatten_from(&mut self, data: &[f32]) -> usize { + let mut offset = 0; + for l in &mut self.layers { + offset += l.unflatten_from(&data[offset..]); + } + offset + } + /// Total trainable params across all GCN layers. + pub fn param_count(&self) -> usize { + self.layers.iter().map(|l| l.param_count()).sum() + } } // ── Transformer config / output / pipeline ─────────────────────────────── @@ -380,6 +468,77 @@ impl CsiToPoseTransformer { PoseOutput { keypoints: kps, confidences: confs, body_part_features: gnn_out } } pub fn config(&self) -> &TransformerConfig { &self.config } + + /// Collect all trainable parameters into a flat vec. + /// + /// Layout: csi_embed | keypoint_queries (flat) | cross_attn | gnn | xyz_head | conf_head + pub fn flatten_weights(&self) -> Vec { + let mut out = Vec::with_capacity(self.param_count()); + self.csi_embed.flatten_into(&mut out); + for kq in &self.keypoint_queries { + out.extend_from_slice(kq); + } + self.cross_attn.flatten_into(&mut out); + self.gnn.flatten_into(&mut out); + self.xyz_head.flatten_into(&mut out); + self.conf_head.flatten_into(&mut out); + out + } + + /// Restore all trainable parameters from a flat slice. + pub fn unflatten_weights(&mut self, params: &[f32]) -> Result<(), String> { + let expected = self.param_count(); + if params.len() != expected { + return Err(format!("expected {expected} params, got {}", params.len())); + } + let mut offset = 0; + + // csi_embed + let (embed, n) = Linear::unflatten_from(¶ms[offset..], + self.config.n_subcarriers, self.config.d_model); + self.csi_embed = embed; + offset += n; + + // keypoint_queries + let d = self.config.d_model; + for kq in &mut self.keypoint_queries { + kq.copy_from_slice(¶ms[offset..offset + d]); + offset += d; + } + + // cross_attn + let (ca, n) = CrossAttention::unflatten_from(¶ms[offset..], + self.config.d_model, self.cross_attn.n_heads()); + self.cross_attn = ca; + offset += n; + + // gnn + let n = self.gnn.unflatten_from(¶ms[offset..]); + offset += n; + + // xyz_head + let (xyz, n) = Linear::unflatten_from(¶ms[offset..], self.config.d_model, 3); + self.xyz_head = xyz; + offset += n; + + // conf_head + let (conf, n) = Linear::unflatten_from(¶ms[offset..], self.config.d_model, 1); + self.conf_head = conf; + offset += n; + + debug_assert_eq!(offset, expected); + Ok(()) + } + + /// Total number of trainable parameters. + pub fn param_count(&self) -> usize { + self.csi_embed.param_count() + + self.config.n_keypoints * self.config.d_model // keypoint queries + + self.cross_attn.param_count() + + self.gnn.param_count() + + self.xyz_head.param_count() + + self.conf_head.param_count() + } } // ── Tests ──────────────────────────────────────────────────────────────── diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/main.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/main.rs index c3bdc14..9c62fd5 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/main.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/main.rs @@ -11,6 +11,11 @@ mod rvf_container; mod rvf_pipeline; mod vital_signs; +mod graph_transformer; +mod trainer; +mod dataset; +mod sparse_inference; +mod sona; use std::collections::VecDeque; use std::net::SocketAddr; @@ -95,6 +100,30 @@ struct Args { /// Enable progressive loading (Layer A instant start) #[arg(long)] progressive: bool, + + /// Export an RVF container package and exit (no server) + #[arg(long, value_name = "PATH")] + export_rvf: Option, + + /// Run training mode (train a model and exit) + #[arg(long)] + train: bool, + + /// Path to dataset directory (MM-Fi or Wi-Pose) + #[arg(long, value_name = "PATH")] + dataset: Option, + + /// Dataset type: "mmfi" or "wipose" + #[arg(long, value_name = "TYPE", default_value = "mmfi")] + dataset_type: String, + + /// Number of training epochs + #[arg(long, default_value = "100")] + epochs: usize, + + /// Directory for training checkpoints + #[arg(long, value_name = "DIR")] + checkpoint_dir: Option, } // ── Data types ─────────────────────────────────────────────────────────────── @@ -1456,6 +1485,59 @@ async fn main() { return; } + // Handle --export-rvf mode: build an RVF container package and exit + if let Some(ref rvf_path) = args.export_rvf { + eprintln!("Exporting RVF container package..."); + use rvf_pipeline::RvfModelBuilder; + + let mut builder = RvfModelBuilder::new("wifi-densepose", "1.0.0"); + + // Vital sign config (default breathing 0.1-0.5 Hz, heartbeat 0.8-2.0 Hz) + builder.set_vital_config(0.1, 0.5, 0.8, 2.0); + + // Model profile (input/output spec) + builder.set_model_profile( + "56-subcarrier CSI amplitude/phase @ 10-100 Hz", + "17 COCO keypoints + body part UV + vital signs", + "ESP32-S3 or Windows WiFi RSSI, Rust 1.85+", + ); + + // Placeholder weights (17 keypoints × 56 subcarriers × 3 dims = 2856 params) + let placeholder_weights: Vec = (0..2856).map(|i| (i as f32 * 0.001).sin()).collect(); + builder.set_weights(&placeholder_weights); + + // Training provenance + builder.set_training_proof( + "wifi-densepose-rs-v1.0.0", + serde_json::json!({ + "pipeline": "ADR-023 8-phase", + "test_count": 229, + "benchmark_fps": 9520, + "framework": "wifi-densepose-rs", + }), + ); + + // SONA default environment profile + let default_lora: Vec = vec![0.0; 64]; + builder.add_sona_profile("default", &default_lora, &default_lora); + + match builder.build() { + Ok(rvf_bytes) => { + if let Err(e) = std::fs::write(rvf_path, &rvf_bytes) { + eprintln!("Error writing RVF: {e}"); + std::process::exit(1); + } + eprintln!("Wrote {} bytes to {}", rvf_bytes.len(), rvf_path.display()); + eprintln!("RVF container exported successfully."); + } + Err(e) => { + eprintln!("Error building RVF: {e}"); + std::process::exit(1); + } + } + return; + } + info!("WiFi-DensePose Sensing Server (Rust + Axum + RuVector)"); info!(" HTTP: http://localhost:{}", args.http_port); info!(" WebSocket: ws://localhost:{}/ws/sensing", args.ws_port); diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/sparse_inference.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/sparse_inference.rs index 91aad45..247bf04 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/sparse_inference.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/sparse_inference.rs @@ -260,16 +260,45 @@ struct ModelLayer { sparse: Option, profiler: NeuronProfiler, is_sparse: bool, + /// Quantized weights per row (populated by apply_quantization). + quantized: Option>, + /// Whether to use quantized weights for forward pass. + use_quantized: bool, } impl ModelLayer { fn new(name: &str, weights: Vec>, bias: Vec) -> Self { let n = weights.len(); - Self { name: name.into(), weights, bias, sparse: None, profiler: NeuronProfiler::new(n), is_sparse: false } + Self { + name: name.into(), weights, bias, sparse: None, + profiler: NeuronProfiler::new(n), is_sparse: false, + quantized: None, use_quantized: false, + } } fn forward_dense(&self, input: &[f32]) -> Vec { + if self.use_quantized { + if let Some(ref qrows) = self.quantized { + return self.forward_quantized(input, qrows); + } + } self.weights.iter().enumerate().map(|(r, row)| dot_bias(row, input, self.bias[r])).collect() } + /// Forward using dequantized weights: val = q_val * scale (symmetric). + fn forward_quantized(&self, input: &[f32], qrows: &[QuantizedWeights]) -> Vec { + let n_out = qrows.len().min(self.bias.len()); + let mut out = vec![0.0f32; n_out]; + for r in 0..n_out { + let qw = &qrows[r]; + let len = qw.data.len().min(input.len()); + let mut s = self.bias[r]; + for i in 0..len { + let w = (qw.data[i] as f32 - qw.zero_point as f32) * qw.scale; + s += w * input[i]; + } + out[r] = s; + } + out + } fn forward(&self, input: &[f32]) -> Vec { if self.is_sparse { if let Some(ref s) = self.sparse { return s.forward(input); } } self.forward_dense(input) @@ -327,11 +356,20 @@ impl SparseModel { } } - /// Quantize weights (stores metadata; actual inference uses original weights). + /// Quantize weights using INT8 codebook per the config. After this call, + /// forward() uses dequantized weights (val = (q - zero_point) * scale). pub fn apply_quantization(&mut self) { - // Quantization metadata is computed per the config but the sparse forward - // path uses the original f32 weights for simplicity in this implementation. - // The stats() method reflects the memory savings. + for layer in &mut self.layers { + let qrows: Vec = layer.weights.iter().map(|row| { + match self.config.quant_mode { + QuantMode::Int8Symmetric => Quantizer::quantize_symmetric(row), + QuantMode::Int8Asymmetric => Quantizer::quantize_asymmetric(row), + _ => Quantizer::quantize_symmetric(row), + } + }).collect(); + layer.quantized = Some(qrows); + layer.use_quantized = true; + } } /// Forward pass through all layers with ReLU activation. diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/trainer.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/trainer.rs index 876edd4..ac59658 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/trainer.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/trainer.rs @@ -5,6 +5,8 @@ //! All arithmetic uses f32. No external ML framework dependencies. use std::path::Path; +use crate::graph_transformer::{CsiToPoseTransformer, TransformerConfig}; +use crate::dataset; /// Standard COCO keypoint sigmas for OKS (17 keypoints). pub const COCO_KEYPOINT_SIGMAS: [f32; 17] = [ @@ -272,6 +274,25 @@ pub struct TrainingSample { pub target_uv: (Vec, Vec), } +/// Convert a dataset::TrainingSample into a trainer::TrainingSample. +pub fn from_dataset_sample(ds: &dataset::TrainingSample) -> TrainingSample { + let csi_features = ds.csi_window.clone(); + let target_keypoints: Vec<(f32, f32, f32)> = ds.pose_label.keypoints.to_vec(); + let target_body_parts: Vec = ds.pose_label.body_parts.iter() + .map(|bp| bp.part_id) + .collect(); + let (tu, tv) = if ds.pose_label.body_parts.is_empty() { + (Vec::new(), Vec::new()) + } else { + let u: Vec = ds.pose_label.body_parts.iter() + .flat_map(|bp| bp.u_coords.iter().copied()).collect(); + let v: Vec = ds.pose_label.body_parts.iter() + .flat_map(|bp| bp.v_coords.iter().copied()).collect(); + (u, v) + }; + TrainingSample { csi_features, target_keypoints, target_body_parts, target_uv: (tu, tv) } +} + // ── Checkpoint ───────────────────────────────────────────────────────────── /// Serializable version of EpochStats for checkpoint storage. @@ -377,6 +398,10 @@ pub struct Trainer { best_val_loss: f32, best_epoch: usize, epochs_without_improvement: usize, + /// When set, predict_keypoints delegates to the transformer's forward(). + transformer: Option, + /// Transformer config (needed for unflatten during gradient estimation). + transformer_config: Option, } impl Trainer { @@ -389,9 +414,35 @@ impl Trainer { Self { config, optimizer, scheduler, params, history: Vec::new(), best_val_loss: f32::MAX, best_epoch: 0, epochs_without_improvement: 0, + transformer: None, transformer_config: None, } } + /// Create a trainer backed by the graph transformer. Gradient estimation + /// uses central differences on the transformer's flattened weights. + pub fn with_transformer(config: TrainerConfig, transformer: CsiToPoseTransformer) -> Self { + let params = transformer.flatten_weights(); + let optimizer = SgdOptimizer::new(config.lr, config.momentum, config.weight_decay); + let scheduler = WarmupCosineScheduler::new( + config.warmup_epochs, config.lr, config.min_lr, config.epochs, + ); + let tc = transformer.config().clone(); + Self { + config, optimizer, scheduler, params, history: Vec::new(), + best_val_loss: f32::MAX, best_epoch: 0, epochs_without_improvement: 0, + transformer: Some(transformer), transformer_config: Some(tc), + } + } + + /// Access the transformer (if any). + pub fn transformer(&self) -> Option<&CsiToPoseTransformer> { self.transformer.as_ref() } + + /// Get a mutable reference to the transformer. + pub fn transformer_mut(&mut self) -> Option<&mut CsiToPoseTransformer> { self.transformer.as_mut() } + + /// Return current flattened params (transformer or simple). + pub fn params(&self) -> &[f32] { &self.params } + pub fn train_epoch(&mut self, samples: &[TrainingSample]) -> EpochStats { let epoch = self.history.len(); let lr = self.scheduler.get_lr(epoch); @@ -400,17 +451,23 @@ impl Trainer { let mut acc = LossComponents::default(); let bs = self.config.batch_size.max(1); let nb = (samples.len() + bs - 1) / bs; + let tc = self.transformer_config.clone(); for bi in 0..nb { let batch = &samples[bi * bs..(bi * bs + bs).min(samples.len())]; let snap = self.params.clone(); let w = self.config.loss_weights.clone(); - let loss_fn = |p: &[f32]| Self::batch_loss(p, batch, &w); + let loss_fn = |p: &[f32]| { + match &tc { + Some(tconf) => Self::batch_loss_with_transformer(p, batch, &w, tconf), + None => Self::batch_loss(p, batch, &w), + } + }; let mut grad = estimate_gradient(loss_fn, &snap, 1e-4); clip_gradients(&mut grad, 1.0); self.optimizer.step(&mut self.params, &grad); - let c = Self::batch_loss_components(&self.params, batch); + let c = Self::batch_loss_components_impl(&self.params, batch, tc.as_ref()); acc.keypoint += c.keypoint; acc.body_part += c.body_part; acc.uv += c.uv; @@ -447,8 +504,9 @@ impl Trainer { let start = std::time::Instant::now(); for _ in 0..self.config.epochs { let mut stats = self.train_epoch(train); + let tc = self.transformer_config.clone(); let val_loss = if !val.is_empty() { - let c = Self::batch_loss_components(&self.params, val); + let c = Self::batch_loss_components_impl(&self.params, val, tc.as_ref()); composite_loss(&c, &self.config.loss_weights) } else { stats.train_loss }; stats.val_loss = val_loss; @@ -496,15 +554,30 @@ impl Trainer { } fn batch_loss(params: &[f32], batch: &[TrainingSample], w: &LossWeights) -> f32 { - composite_loss(&Self::batch_loss_components(params, batch), w) + composite_loss(&Self::batch_loss_components_impl(params, batch, None), w) + } + + fn batch_loss_with_transformer( + params: &[f32], batch: &[TrainingSample], w: &LossWeights, tc: &TransformerConfig, + ) -> f32 { + composite_loss(&Self::batch_loss_components_impl(params, batch, Some(tc)), w) } fn batch_loss_components(params: &[f32], batch: &[TrainingSample]) -> LossComponents { + Self::batch_loss_components_impl(params, batch, None) + } + + fn batch_loss_components_impl( + params: &[f32], batch: &[TrainingSample], tc: Option<&TransformerConfig>, + ) -> LossComponents { if batch.is_empty() { return LossComponents::default(); } let mut acc = LossComponents::default(); let mut prev_kp: Option> = None; for sample in batch { - let pred_kp = Self::predict_keypoints(params, sample); + let pred_kp = match tc { + Some(tconf) => Self::predict_keypoints_transformer(params, sample, tconf), + None => Self::predict_keypoints(params, sample), + }; acc.keypoint += keypoint_mse(&pred_kp, &sample.target_keypoints); let n_parts = 24usize; let logits: Vec = sample.target_body_parts.iter().flat_map(|_| { @@ -552,14 +625,39 @@ impl Trainer { }).collect() } + /// Predict keypoints using the graph transformer. Creates a temporary + /// transformer with the given params and runs forward(). + fn predict_keypoints_transformer( + params: &[f32], sample: &TrainingSample, tc: &TransformerConfig, + ) -> Vec<(f32, f32, f32)> { + let mut t = CsiToPoseTransformer::new(tc.clone()); + if t.unflatten_weights(params).is_err() { + return Self::predict_keypoints(params, sample); + } + let output = t.forward(&sample.csi_features); + output.keypoints + } + fn evaluate_metrics(&self, samples: &[TrainingSample]) -> (f32, f32) { if samples.is_empty() { return (0.0, 0.0); } - let preds: Vec> = samples.iter().map(|s| Self::predict_keypoints(&self.params, s)).collect(); + let preds: Vec> = samples.iter().map(|s| { + match &self.transformer_config { + Some(tc) => Self::predict_keypoints_transformer(&self.params, s, tc), + None => Self::predict_keypoints(&self.params, s), + } + }).collect(); let targets: Vec> = samples.iter().map(|s| s.target_keypoints.clone()).collect(); let pck = preds.iter().zip(targets.iter()) .map(|(p, t)| pck_at_threshold(p, t, 0.2)).sum::() / samples.len() as f32; (pck, oks_map(&preds, &targets)) } + + /// Sync the internal transformer's weights from the flat params after training. + pub fn sync_transformer_weights(&mut self) { + if let Some(ref mut t) = self.transformer { + let _ = t.unflatten_weights(&self.params); + } + } } // ── Tests ──────────────────────────────────────────────────────────────────