diff --git a/.github/workflows/firmware-ci.yml b/.github/workflows/firmware-ci.yml index 04f3ec53f..522ac845a 100644 --- a/.github/workflows/firmware-ci.yml +++ b/.github/workflows/firmware-ci.yml @@ -162,10 +162,14 @@ jobs: 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 + cp build/bootloader/bootloader.bin release-staging/bootloader.bin + cp build/ota_data_initial.bin release-staging/ota_data_initial.bin + cp version.txt release-staging/version.txt + (cd release-staging && sha256sum \ + "${{ matrix.artifact_app }}" \ + "${{ matrix.artifact_pt }}" \ + bootloader.bin ota_data_initial.bin version.txt \ + > SHA256SUMS.txt) ls -la release-staging/ - name: Check QEMU ESP32-S3 support status diff --git a/.github/workflows/ruforecast-ci.yml b/.github/workflows/ruforecast-ci.yml new file mode 100644 index 000000000..2f17c8ab9 --- /dev/null +++ b/.github/workflows/ruforecast-ci.yml @@ -0,0 +1,627 @@ +name: RuForecast Rust CI + +on: + push: + branches: [main, develop, 'feat/*', 'feature/*'] + paths: + - 'v2/crates/ruforecast' + - 'v2/Cargo.toml' + - 'v2/Cargo.lock' + - 'v2/rust-toolchain.toml' + - 'scripts/csi-data-policy-check.sh' + - 'scripts/run-ruforecast-benchmarks.sh' + - 'docs/adr/ADR-348-*.md' + - 'docs/adr/ADR-349-*.md' + - 'docs/adr/ADR-350-*.md' + - 'docs/adr/README.md' + - 'docs/benchmarks/ruforecast.md' + - 'docs/huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md' + - 'docs/security/ruview-forecast-*' + - 'docs/validation/ruforecast-requirements-evidence.md' + - '.github/workflows/ruforecast-ci.yml' + pull_request: + branches: [main, develop] + paths: + - 'v2/crates/ruforecast' + - 'v2/Cargo.toml' + - 'v2/Cargo.lock' + - 'v2/rust-toolchain.toml' + - 'scripts/csi-data-policy-check.sh' + - 'scripts/run-ruforecast-benchmarks.sh' + - 'docs/adr/ADR-348-*.md' + - 'docs/adr/ADR-349-*.md' + - 'docs/adr/ADR-350-*.md' + - 'docs/adr/README.md' + - 'docs/benchmarks/ruforecast.md' + - 'docs/huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md' + - 'docs/security/ruview-forecast-*' + - 'docs/validation/ruforecast-requirements-evidence.md' + - '.github/workflows/ruforecast-ci.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ruforecast-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + CARGO_PROFILE_DEV_DEBUG: '0' + CARGO_PROFILE_TEST_DEBUG: '0' + CARGO_PROFILE_BENCH_DEBUG: '0' + CARGO_PROFILE_RELEASE_DEBUG: '0' + +jobs: + contract-rust-189: + name: Forecast contract and feature-off boundary (Rust 1.89) + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout recursively + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + submodules: recursive + + - name: Install Rust 1.89 with lint components + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 + with: + toolchain: 1.89.0 + components: rustfmt,clippy + + - name: Cache Cargo artifacts + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + workspaces: v2/crates/ruforecast + key: ruforecast-rust-189 + + - name: Validate benchmark runner and evidence contract + shell: bash + run: | + set -euo pipefail + bash -n scripts/run-ruforecast-benchmarks.sh + test -x scripts/run-ruforecast-benchmarks.sh + test -f docs/benchmarks/ruforecast.md + test -f docs/huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md + test -f docs/security/ruview-forecast-clean-room.md + test -f docs/security/ruview-forecast-threat-model.md + test -f docs/validation/ruforecast-requirements-evidence.md + + for adr_number in 348 349 350; do + mapfile -t adr_files < <( + find docs/adr -maxdepth 1 -type f -name "ADR-${adr_number}-*.md" -print + ) + if [[ "${#adr_files[@]}" -ne 1 ]]; then + echo "Expected exactly one ADR-${adr_number} file, found ${#adr_files[@]}" >&2 + exit 1 + fi + grep -q "^# ADR-${adr_number}:" "${adr_files[0]}" + grep -Eq '^[-*] \*\*Status\*\*: (Proposed|Accepted|Superseded|Deprecated)( |$|—)|^## Status' "${adr_files[0]}" + done + + grep -q '^## Requirements' docs/adr/ADR-348-*.md + grep -q '^## Acceptance gates' docs/adr/ADR-348-*.md + grep -q '^## Requirements and acceptance' docs/adr/ADR-349-*.md + grep -q '^## Requirements and acceptance' docs/adr/ADR-350-*.md + for adr_number in 348 349 350; do + grep -q "| \[ADR-${adr_number}\](ADR-${adr_number}-" docs/adr/README.md \ + || { echo "ADR-${adr_number} is missing from the ADR index" >&2; exit 1; } + done + + for number in $(seq 1 12); do + requirement="$(printf 'RF-%03d' "$number")" + grep -q "| ${requirement} |" docs/validation/ruforecast-requirements-evidence.md \ + || { echo "Missing evidence row for ${requirement}" >&2; exit 1; } + done + for number in $(seq 1 8); do + requirement="$(printf 'FT-%03d' "$number")" + grep -q "| ${requirement} |" docs/validation/ruforecast-requirements-evidence.md \ + || { echo "Missing evidence row for ${requirement}" >&2; exit 1; } + done + for number in $(seq 1 9); do + requirement="$(printf 'PM-%03d' "$number")" + grep -q "| ${requirement} |" docs/validation/ruforecast-requirements-evidence.md \ + || { echo "Missing evidence row for ${requirement}" >&2; exit 1; } + done + + python3 - <<'PY' + from pathlib import Path + import tomllib + + root = Path("v2/crates/ruforecast/crates") + core = tomllib.loads((root / "ruforecast-core/Cargo.toml").read_text()) + model = tomllib.loads((root / "ruforecast-model/Cargo.toml").read_text()) + train = tomllib.loads((root / "ruforecast-train/Cargo.toml").read_text()) + + assert core.get("features", {}).get("default", []) == [] + assert model["features"]["default"] == [], "model defaults must stay backend-free" + assert train["features"]["default"] == [], "train defaults must stay backend-free" + assert {"model", "cpu", "cuda", "wgpu", "ruvector"} <= model["features"].keys() + assert {"cpu", "cuda", "training", "cli", "server", "fal-client"} <= train["features"].keys() + assert {"model", "dep:burn-ndarray"} <= set(model["features"]["cpu"]) + assert {"model", "dep:burn-cuda"} <= set(model["features"]["cuda"]) + assert {"model", "dep:burn-wgpu"} <= set(model["features"]["wgpu"]) + assert "dep:ruvector-core" in model["features"]["ruvector"] + assert {"ruforecast-model/cpu", "training"} <= set(train["features"]["cpu"]) + assert {"ruforecast-model/cuda", "training"} <= set(train["features"]["cuda"]) + + bins = {entry["name"]: entry for entry in train.get("bin", [])} + assert bins["ruforecast"]["required-features"] == ["cli"] + + model_benches = {entry["name"]: entry for entry in model.get("bench", [])} + train_benches = {entry["name"]: entry for entry in train.get("bench", [])} + assert model_benches["forecast_inference"]["harness"] is False + assert model_benches["forecast_inference"]["required-features"] == ["cpu"] + assert train_benches["data_pipeline"]["harness"] is False + assert train_benches["data_pipeline"]["required-features"] == ["cpu"] + PY + + - name: Check formatting for forecast packages only + working-directory: v2/crates/ruforecast + run: >- + cargo +1.89.0 fmt + -p ruforecast-core + -p ruforecast-model + -p ruforecast-train + -- --check + + - name: Prove feature-off graph excludes Burn and CubeCL + working-directory: v2/crates/ruforecast + shell: bash + run: | + set -euo pipefail + : > forecast-feature-off-tree.txt + for package in ruforecast-core ruforecast-model ruforecast-train; do + cargo +1.89.0 tree --locked -e normal,build -p "$package" --no-default-features \ + >> forecast-feature-off-tree.txt + done + if grep -Eiq '(^|[[:space:]])(burn|cubecl)(-|[[:space:]])' forecast-feature-off-tree.txt; then + echo 'Burn/CubeCL entered the Rust 1.89 feature-off dependency graph.' >&2 + cat forecast-feature-off-tree.txt >&2 + exit 1 + fi + + - name: Check feature-off packages + working-directory: v2/crates/ruforecast + run: >- + cargo +1.89.0 check --locked + -p ruforecast-core + -p ruforecast-model + -p ruforecast-train + --no-default-features --all-targets + + - name: Test model-neutral core + working-directory: v2/crates/ruforecast + run: >- + cargo +1.89.0 test --locked + -p ruforecast-core + --no-default-features --lib --tests + + - name: Test model contracts without a model backend + working-directory: v2/crates/ruforecast + run: >- + cargo +1.89.0 test --locked + -p ruforecast-model + --no-default-features --lib --tests + + - name: Test RuVector retrieval boundary without a model backend + working-directory: v2/crates/ruforecast + run: >- + cargo +1.89.0 test --locked + -p ruforecast-model + --no-default-features --features ruvector --lib --tests + + - name: Clippy RuVector retrieval boundary + working-directory: v2/crates/ruforecast + run: >- + cargo +1.89.0 clippy --locked + -p ruforecast-model + --no-default-features --features ruvector --all-targets -- -D warnings + + - name: Clippy forecast packages without backend features + working-directory: v2/crates/ruforecast + run: >- + cargo +1.89.0 clippy --locked + -p ruforecast-core + -p ruforecast-model + -p ruforecast-train + --no-default-features --all-targets -- -D warnings + + - name: Upload feature-off dependency tree + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: ruforecast-feature-off-tree + path: v2/crates/ruforecast/forecast-feature-off-tree.txt + if-no-files-found: error + + hosted-boundaries-rust-189: + name: Forecast hosted boundaries and privacy contract (Rust 1.89) + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout recursively + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + submodules: recursive + + - name: Install Rust 1.89 with Clippy + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 + with: + toolchain: 1.89.0 + components: clippy + + - name: Cache Cargo artifacts + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + workspaces: v2/crates/ruforecast + key: ruforecast-hosted-rust-189 + + - name: Enforce clean-room source, secret, and hosted-wire boundary + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + import re + import sys + + roots = [ + Path("v2/crates/ruforecast/crates/ruforecast-core"), + Path("v2/crates/ruforecast/crates/ruforecast-model"), + Path("v2/crates/ruforecast/crates/ruforecast-train"), + ] + text_suffixes = {".json", ".py", ".rs", ".sh", ".toml", ".yaml", ".yml"} + forbidden_source = re.compile(r"(?i)\b(?:times[ _-]?fm|google[ _/-]?research)\b") + secret_patterns = { + "private key": re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"), + "AWS access key": re.compile(r"\bAKIA[0-9A-Z]{16}\b"), + "JWT": re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"), + "presigned URL": re.compile(r"(?i)[?&](?:X-Amz-Signature|X-Goog-Signature)="), + "literal FAL_KEY": re.compile(r"(?i)\bFAL_KEY\b\s*[:=]\s*['\"][^'$<{\"\n]{8,}['\"]"), + } + forbidden_blobs = {".ckpt", ".mpk", ".npy", ".npz", ".onnx", ".pt", ".pth", ".safetensors"} + + errors = [] + for root in roots: + for path in root.rglob("*"): + if not path.is_file() or "target" in path.parts: + continue + if path.suffix.lower() in forbidden_blobs: + errors.append(f"forbidden checked-in model/data blob: {path}") + continue + if path.suffix.lower() not in text_suffixes: + continue + text = path.read_text(encoding="utf-8") + if forbidden_source.search(text): + errors.append(f"forbidden clean-room source/import reference: {path}") + for label, pattern in secret_patterns.items(): + if pattern.search(text): + errors.append(f"possible {label} in {path}") + + fal_source = Path("v2/crates/ruforecast/crates/ruforecast-train/src/fal.rs").read_text() + for struct_name in ("HostedOptimizer", "HostedBudget", "HostedSyntheticPayload"): + match = re.search( + rf"pub struct {struct_name}\s*\{{(?P.*?)^\}}", + fal_source, + flags=re.MULTILINE | re.DOTALL, + ) + if match is None: + errors.append(f"missing hosted wire struct: {struct_name}") + continue + body = match.group("body") + fields = re.findall(r"^\s*pub\s+([A-Za-z0-9_]+)\s*:", body, re.MULTILINE) + forbidden_fields = ( + "account", "data_policy", "dataset", "device", "identity", "path", + "person", "room", "session", "site", "split", "subject", "tenant", + "workspace", + ) + for field in fields: + lowered = field.lower() + if any(fragment in lowered for fragment in forbidden_fields): + errors.append(f"forbidden hosted field {struct_name}.{field}") + if "bytes" in lowered and field not in { + "max_artifact_bytes", + "max_memory_bytes", + }: + errors.append(f"unapproved hosted byte field {struct_name}.{field}") + if "DataPolicy" in body: + errors.append(f"DataPolicy entered hosted wire struct {struct_name}") + + if errors: + print("\n".join(errors), file=sys.stderr) + raise SystemExit(1) + PY + + - name: Enforce tracked-data policy + run: | + set -euo pipefail + bash scripts/csi-data-policy-check.sh --self-test + bash scripts/csi-data-policy-check.sh --tracked + + - name: Validate minimal fal deployment archive policy + run: >- + python3 + v2/crates/ruforecast/crates/ruforecast-train/deploy/fal/deploy.py + self-test + + - name: Prove hosted feature graph excludes Burn and CubeCL + working-directory: v2/crates/ruforecast + shell: bash + run: | + set -euo pipefail + cargo +1.89.0 tree --locked -e normal,build \ + -p ruforecast-train \ + --no-default-features --features cli,server,fal-client \ + > forecast-hosted-feature-tree.txt + if grep -Eiq '(^|[[:space:]])(burn|cubecl)(-|[[:space:]])' \ + forecast-hosted-feature-tree.txt; then + echo 'Burn/CubeCL entered the Rust 1.89 hosted dependency graph.' >&2 + cat forecast-hosted-feature-tree.txt >&2 + exit 1 + fi + + - name: Require named hosted boundary regressions + working-directory: v2/crates/ruforecast + shell: bash + env: + FAL_KEY: '' + run: | + set -euo pipefail + cargo +1.89.0 test --locked \ + -p ruforecast-train \ + --no-default-features --features cli,server,fal-client \ + --lib --bins --tests -- --list \ + | tee hosted-boundary-tests.txt + for regression in \ + fal_app_rejects_path_and_host_confusion \ + fal_urls_require_exact_origin_path_and_no_query \ + fal_submit_headers_disable_retry_and_omit_store_io \ + hosted_payload_dto_has_no_customer_fields \ + queue_status_discards_provider_error_body \ + result_url_accepts_exact_response_suffix \ + artifact_file_url_uses_expected_api_path \ + fal_key_debug_is_redacted \ + hosted_payload_rejects_app_field \ + hosted_reservation_cannot_exceed_source_retention \ + artifact_handoff_expiry_boundary_is_fail_closed \ + fal_result_enforces_cumulative_artifact_budget_boundary \ + fal_download_rejects_over_budget_without_writing \ + direct_server_train_requires_request_id_header \ + request_id_rejects_path_injection \ + unknown_cancel_is_not_success \ + privacy_external_dataset_payload_is_denied; do + grep -q "$regression" hosted-boundary-tests.txt \ + || { echo "Missing hosted boundary regression: $regression" >&2; exit 1; } + done + + - name: Exercise hosted external-data rejection + working-directory: v2/crates/ruforecast + env: + FAL_KEY: '' + run: >- + cargo +1.89.0 test --locked + -p ruforecast-train + --no-default-features --features cli,server,fal-client + privacy_external_dataset_payload_is_denied + + - name: Test server and fal client with mock integrations + working-directory: v2/crates/ruforecast + env: + FAL_KEY: '' + run: >- + cargo +1.89.0 test --locked + -p ruforecast-train + --no-default-features --features cli,server,fal-client + --lib --bins --tests + + - name: Clippy server and fal client boundaries + working-directory: v2/crates/ruforecast + env: + FAL_KEY: '' + run: >- + cargo +1.89.0 clippy --locked + -p ruforecast-train + --no-default-features --features cli,server,fal-client + --all-targets -- -D warnings + + - name: Clippy standalone CLI feature boundary + working-directory: v2/crates/ruforecast + env: + FAL_KEY: '' + run: >- + cargo +1.89.0 clippy --locked + -p ruforecast-train + --no-default-features --features cli + --bin ruforecast -- -D warnings + + - name: Upload hosted dependency tree + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: ruforecast-hosted-feature-tree + path: v2/crates/ruforecast/forecast-hosted-feature-tree.txt + if-no-files-found: error + + burn-cpu-rust-192: + name: Forecast Burn CPU tests and bench compile (Rust 1.92) + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Checkout recursively + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + submodules: recursive + + - name: Install Rust 1.92 with Clippy + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 + with: + toolchain: 1.92.0 + components: clippy + + - name: Cache Cargo artifacts + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + workspaces: v2/crates/ruforecast + key: ruforecast-rust-192-cpu + + - name: Test model with Burn CPU backend + working-directory: v2/crates/ruforecast + run: >- + cargo +1.92.0 test --locked + -p ruforecast-model + --no-default-features --features cpu --lib --tests + + - name: Test trainer and CLI with Burn CPU backend + working-directory: v2/crates/ruforecast + run: >- + cargo +1.92.0 test --locked + -p ruforecast-train + --no-default-features --features cpu,cli --lib --bins + + - name: Run one-step local JSONL training smoke + working-directory: v2/crates/ruforecast + run: >- + cargo +1.92.0 test --locked + -p ruforecast-train + --no-default-features --features cpu + --test local_jsonl_smoke -- + --exact local_hash_addressed_jsonl_executes_one_real_optimizer_step + + - name: Run idempotent synthetic CLI training smoke + working-directory: v2/crates/ruforecast + run: >- + cargo +1.92.0 test --locked + -p ruforecast-train + --no-default-features --features cpu,cli + --test cli_smoke -- + --exact cli_smoke_trains_and_writes_the_complete_candidate_set + + - name: Test combined CPU and hosted feature surface + working-directory: v2/crates/ruforecast + env: + FAL_KEY: '' + run: >- + cargo +1.92.0 test --locked + -p ruforecast-train + --no-default-features --features cpu,cli,server,fal-client + --lib --bins + + - name: Clippy model with Burn CPU backend + working-directory: v2/crates/ruforecast + run: >- + cargo +1.92.0 clippy --locked + -p ruforecast-model + --no-default-features --features cpu --all-targets -- -D warnings + + - name: Clippy trainer and CLI with Burn CPU backend + working-directory: v2/crates/ruforecast + env: + FAL_KEY: '' + run: >- + cargo +1.92.0 clippy --locked + -p ruforecast-train + --no-default-features --features cpu,cli,server,fal-client + --all-targets -- -D warnings + + - name: Compile forecast inference benchmark + working-directory: v2/crates/ruforecast + run: >- + cargo +1.92.0 bench --locked + -p ruforecast-model + --no-default-features --features cpu + --bench forecast_inference --no-run + + - name: Compile forecast data-pipeline benchmark + working-directory: v2/crates/ruforecast + run: >- + cargo +1.92.0 bench --locked + -p ruforecast-train + --no-default-features --features cpu + --bench data_pipeline --no-run + + - name: Run quick inference benchmark for trend visibility + continue-on-error: true + timeout-minutes: 15 + working-directory: v2/crates/ruforecast + run: | + set -o pipefail + mkdir -p ../../bench-out/ruforecast + cargo +1.92.0 bench --locked \ + -p ruforecast-model \ + --no-default-features --features cpu \ + --bench forecast_inference -- \ + --warm-up-time 1 --measurement-time 2 --sample-size 10 \ + | tee ../../bench-out/ruforecast/forecast-inference.txt + + - name: Run quick data-pipeline benchmark for trend visibility + continue-on-error: true + timeout-minutes: 15 + working-directory: v2/crates/ruforecast + run: | + set -o pipefail + mkdir -p ../../bench-out/ruforecast + cargo +1.92.0 bench --locked \ + -p ruforecast-train \ + --no-default-features --features cpu \ + --bench data_pipeline -- \ + --warm-up-time 1 --measurement-time 2 --sample-size 10 \ + | tee ../../bench-out/ruforecast/data-pipeline.txt + + - name: Upload informational benchmark logs + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: ruforecast-informational-cpu-benchmarks + path: bench-out/ruforecast/ + if-no-files-found: warn + + burn-cuda-compile-rust-192: + name: Forecast Burn CUDA/WGPU compile checks only (Rust 1.92) + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout recursively + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + submodules: recursive + + - name: Install Rust 1.92 + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 + with: + toolchain: 1.92.0 + + - name: Cache Cargo artifacts + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + workspaces: v2/crates/ruforecast + key: ruforecast-rust-192-cuda-check + + - name: Check model CUDA feature + working-directory: v2/crates/ruforecast + run: >- + cargo +1.92.0 check --locked + -p ruforecast-model + --no-default-features --features cuda --lib + + - name: Check model WGPU feature + working-directory: v2/crates/ruforecast + run: >- + cargo +1.92.0 check --locked + -p ruforecast-model + --no-default-features --features wgpu --lib + + - name: Check trainer CUDA and hosted-server feature combination + working-directory: v2/crates/ruforecast + env: + FAL_KEY: '' + run: >- + cargo +1.92.0 check --locked + -p ruforecast-train + --no-default-features --features cuda,cli,server + --lib --bins diff --git a/.gitmodules b/.gitmodules index 39ef969a3..312c1b9bf 100644 --- a/.gitmodules +++ b/.gitmodules @@ -33,3 +33,7 @@ path = vendor/metaharness url = https://github.com/ruvnet/metaharness branch = main +[submodule "v2/crates/ruforecast"] + path = v2/crates/ruforecast + url = https://github.com/ruvnet/RuForecast.git + branch = main diff --git a/README.md b/README.md index 5a16412d3..38fd36672 100644 --- a/README.md +++ b/README.md @@ -2,20 +2,11 @@

- RuView - WiFi DensePose - -

-

- - Cognitum Musica - -

-

- - RuCelium — environmental intelligence + RuView - WiFi DensePose — animated visualization of real-time pose estimation, breathing, and heart-rate sensing through WiFi

+ ## **See through walls with WiFi** ## **Turn ordinary WiFi into a spatial intelligence / sensing system.** Detect people, measure breathing and heart rate, track movement, and monitor rooms — through walls, in the dark, with no cameras or wearables. Just physics. diff --git a/assets/ruview-hero-h3-v3.gif b/assets/ruview-hero-h3-v3.gif new file mode 100644 index 000000000..9761dce03 Binary files /dev/null and b/assets/ruview-hero-h3-v3.gif differ diff --git a/docs/adr/ADR-262-rufield-ruview-integration.md b/docs/adr/ADR-262-rufield-ruview-integration.md index 64702aaf2..053a7d9c9 100644 --- a/docs/adr/ADR-262-rufield-ruview-integration.md +++ b/docs/adr/ADR-262-rufield-ruview-integration.md @@ -139,6 +139,32 @@ Implement the §3.3 mapping: `effective_class → PrivacyClass`, `cog-ha-matter` Add an opt-in `/ws/field` endpoint (or a `field_events` array on `SensingUpdate` behind a flag) carrying the signed `FieldEvent` + a privacy badge. Add an ingest route to `rufield-viewer` (it has none today — `server.rs:63-72`) so it can replay RuView's live feed instead of only `SyntheticSim`. **Gate:** a WS integration test asserting a connected client receives a privacy-badged, signature-verifiable `FieldEvent`; a viewer test asserting the new ingest route renders a live event. The `cognitum` appliance can speak RuField by consuming this endpoint (it already runs `ruview-vitals-worker`); deferred to its own ADR. **P4 — fusion composition + multi-modality (ARCHITECTURE, optional).** + +> **Update — second modality landed as a library.** Open question 5 below asked +> whether the second modality should be `rvcsi`. It is **ultrasonic**, because +> the cost collapsed: `rufield-adapters` now ships `UltrasonicReplayAdapter`, +> the first adapter for `Modality::Ultrasonic` (registry code 7, empty since +> v0.1), which parses, validates and signs [BatVu](https://github.com/ruvnet/batvu) +> range profiles upstream. RuView only has to decide what it will put on a wire. +> +> `wifi-densepose-rufield::ultrasonic` is that decision, and it is expressed +> structurally: the adapter is configured for its 32-bin coarse output (`P1`, +> egress-safe) rather than its full per-bin frame (`P0`, edge-local), because a +> consumer cannot un-coarsen a coarse profile whereas a runtime check can be +> reordered. The `network_egress_allowed` gate still runs and is asserted to +> drop nothing. +> +> Gates: `tests/ultrasonic_gates.rs`, 12 tests — round-trip, signature-verify, +> fusion ingest, P1 on **both** tensor and observation, structural unreachability +> of P4/P5, trust-tier refusal in both directions, determinism, whole-file +> rejection of a malformed recording. Plus one asserting the honest negative +> result: **an ultrasonic scan produces no fused inferences at all**, because the +> adapter declines to populate `presence` (one transducer pair cannot tell a +> person from a coat on a chair) and the engine's feature vocabulary is entirely +> statements about a body. RuField v0.1 has no predicate for static geometry. +> +> Not wired into the running server. P1 shipped as a library before P3 wired it +> in; this follows the same staging. Wire a second modality (cheapest: an `rvcsi`-sourced event, or recorded mmWave) into `RuFieldFusion` alongside the WiFi event, proving cross-modality fusion above ruvsense. **Gate:** a fusion test with two modalities producing ≥1 cross-modal inference, with provenance coverage 100%. --- diff --git a/docs/adr/ADR-346-fail-closed-edge-occupancy-evidence.md b/docs/adr/ADR-346-fail-closed-edge-occupancy-evidence.md new file mode 100644 index 000000000..0148a2b82 --- /dev/null +++ b/docs/adr/ADR-346-fail-closed-edge-occupancy-evidence.md @@ -0,0 +1,52 @@ +# ADR 346: Fail closed ESP32 occupancy evidence + +## Status + +Accepted and implemented. Physical qualification is required after each firmware build. + +## Date + +2026 08 31 + +## Context + +The ESP32 Tier 2 pipeline produces two different signals. Presence is a debounced room level decision. Person count is a bounded subcarrier diversity heuristic. A live four node installation emitted packets with `presence=false` and `n_persons=3` or `4`. The server eventually gated the aggregate room count, but raw WebSocket consumers and diagnostics could still treat the contradictory count as occupancy evidence. + +That contradiction is more dangerous than a missed optional count. It can contaminate empty room calibration, train a room model on false labels, and encourage a product claim that the firmware cannot support. The count is not identity, pose, or a validated multi person estimator. + +## Decision + +1. Firmware person slots are subordinate to the debounced presence gate. +2. When presence is false, the firmware clears slot activity, slot history, candidate count, persistence streak, and stable count. +3. The serialized person count is always zero when presence is false and is clamped to `EDGE_MAX_PERSONS` when presence is true. +4. The sensing server repeats the invariant for older firmware. A contradictory or out of range count becomes zero and carries `person_count_valid=false`. +5. Fused CSI plus mmWave packets use either CSI presence or mmWave presence as the supporting presence condition. +6. The node inventory and WebSocket diagnostics expose person count validity. Consumers must not infer a person from an invalid count. +7. No count accuracy claim is created by this change. The firmware output remains a heuristic until a leakage free, held out physical dataset demonstrates otherwise. +8. OTA admission uses the selected update partition size rather than a stale fixed 900 KB ceiling. The status endpoint reports that same hardware bound, while image validation and authenticated OTA remain mandatory. + +## Security and privacy + +The change retains no raw CSI or personal data. It reduces authority by preventing a secondary heuristic from asserting occupancy after the primary gate has closed. The host validates packet length, magic, range, and logical consistency before using count evidence. + +## Consequences + +Older firmware remains wire compatible. Invalid count evidence becomes visibly unavailable instead of silently affecting calibration. A true multi person event can still be undercounted when the presence gate is false, which is the intended fail closed behavior. Current C6 images larger than 900 KB can use the installed 1,900,544 byte OTA slots after one serial upgrade, without weakening the OTA authentication gate. + +The largest uncertainty is whether the current presence gate itself generalizes across the installed rooms. The fix path is a room bound empty baseline plus the fixed room selective held out protocol, not a global threshold reduction. + +## Evidence and acceptance + +MEASURED before implementation on 2026 08 31: four live nodes streamed for 86 seconds with zero transport errors, while edge packets repeatedly contradicted `presence=false` with counts of three or four. + +Software acceptance requires: + +1. Host firmware tests prove absent plus four active slots serializes zero. +2. Rust parser tests prove contradictory and out of range counts fail closed. +3. The node API exposes count validity without breaking older firmware. + +Physical acceptance requires the updated firmware on a confirmed board, a captured boot log, five minutes of live packets, zero logical count contradictions, and no increase in transport errors. Accuracy remains unmeasured until labelled held out sequences are recorded. + +Physical occupancy qualification completed for ESP32 C6 node 4 on 2026 08 31. The five minute run observed 242 edge packets, including 61 absent packets, with zero logical count contradictions and zero parse errors. See `docs/validation/2026-08-31-esp32-c6-occupancy-integrity.md`. + +ESP32 C6 node 7 was subsequently identified, upgraded to firmware 0.8.8, and transport qualified for five minutes with zero fused presence count contradictions and zero steady state transport errors. Its controlled empty room sequence remains required before occupancy qualification. See `docs/validation/2026-08-31-esp32-c6-node7-rate-aware-sensing.md`. Other nodes remain unqualified until separately identified and upgraded. diff --git a/docs/adr/ADR-347-rate-aware-esp32-temporal-sensing.md b/docs/adr/ADR-347-rate-aware-esp32-temporal-sensing.md new file mode 100644 index 000000000..289011174 --- /dev/null +++ b/docs/adr/ADR-347-rate-aware-esp32-temporal-sensing.md @@ -0,0 +1,85 @@ +# ADR 347: Rate aware ESP32 temporal sensing + +## Status + +Accepted. Implemented in firmware 0.8.8. The timing and transport path is +physically qualified on ESP32 C6. The independent raw transport path is also +physically qualified on an ESP32 S3 running Tier 0. Held out inference accuracy +remains required. + +## Context + +The ESP32 firmware creates CSI opportunities by sending one byte ICMP probes to +the connected access point. The traffic source is configured for 50 Hz, but the +delivered CSI cadence varies with channel contention and callback safety gates. +A physical ESP32 C6 produced 28 to 37 callbacks per second during the baseline +capture. Firmware 0.8.5 then exposed that the old per-interval estimator saw +only 12 to 16 Hz because WiFi replies arrived in short bursts separated by +longer gaps. The filters still consumed those burst frames, so excluding them +from the clock estimate was incorrect. + +The edge DSP estimates its sample rate from timestamps so that breathing, +heartbeat, motion, and future Doppler features stay in physical Hertz. That +estimator was capped at 30 Hz. Once the actual cadence exceeded the cap, every +temporal feature was scaled against the wrong clock. + +Physical firmware 0.8.5 validation corrected that initial diagnosis. Although +the callback path received 26 to 40 frames per second, Tier 2 on the unicore C6 +processed an irregular subset that converged toward the 8 Hz estimator floor. +The right design is not to force the edge DSP to match raw capture. The paths +need independent, explicit cadence contracts. + +The device free gesture preprint at +`https://www.preprints.org/manuscript/202602.0018` reinforces the importance of +timestamp correct Doppler features, but its 100 Hz controlled link is not a +safe firmware default for RuView. Existing S3 and C6 evidence records WiFi ISR +and packet buffer failures under sustained callback pressure above 50 Hz. + +## Decision + +1. Make the connected STA probe rate a build time setting from 10 through 50 + Hz, with a default and hard ceiling of 50 Hz. + +2. Track the delivered DSP cadence by counting every processed frame interval + over one second timestamp windows, then smooth successive windows in an 8 + through 60 Hz estimator range. The 60 Hz estimator ceiling accommodates + timestamp jitter; it does not authorize more than 50 Hz callback processing. + +3. Reject incomplete windows below one second and stalled windows above three + seconds. Do not discard valid burst frames from the estimated clock. + +4. Surface the DSP rate in the one second controller diagnostic so hardware + validation can compare callback yield with the clock used by temporal + filters. + +5. Keep raw CSI on the wire at the independent network cadence. Rate-limit the + C6 on-device Tier 1 and Tier 2 DSP input to a uniform 8 Hz. Physical 0.8.7 + evidence showed that a requested 10 Hz input still converged to 8.0 through + 8.4 Hz under Tier 2 load, while raw delivery remained 30 through 40 pps. + Eight hertz retains a 4 Hz Nyquist limit for the 0.1 through 2.0 Hz vital + bands without creating a backlog. The S3 default remains 20 Hz. + +6. STFT, spectrogram gating, and learned temporal + classification remain host or iPhone responsibilities where memory, + rollback, and held out evaluation are stronger. + +## Consequences + +Heartbeat, respiration, and motion features receive a stable timestamped clock +instead of an accidental subset determined by C6 backlog. Operators can lower +the probe or DSP load for constrained networks without editing source. The host +still receives the higher-rate raw stream for richer Doppler processing. + +This does not prove vital sign accuracy or gesture recognition. Higher temporal +fidelity only improves the representation available to a separately validated +model. The 50 Hz ceiling also means the paper's 100 Hz results are not directly +transferable. + +## Acceptance test + +On a physical C6, run at least five minutes after flashing. Pass when the boot +log reports the configured probe and DSP rates, the controller converges within +one hertz of the configured DSP cadence, raw callback yield remains at least 20 +pps, no steady-state ENOMEM, watchdog, panic, or reboot occurs, and the fail +closed occupancy invariant remains zero contradictions for at least 30 absent +packets. diff --git a/docs/adr/ADR-348-independent-rust-multivariate-forecasting.md b/docs/adr/ADR-348-independent-rust-multivariate-forecasting.md new file mode 100644 index 000000000..50272fe23 --- /dev/null +++ b/docs/adr/ADR-348-independent-rust-multivariate-forecasting.md @@ -0,0 +1,336 @@ +# ADR-348: Independent Rust multivariate forecasting for RuView + +- **Status**: Proposed +- **Date**: 2026-09-01 +- **Deciders**: ruv +- **Owners**: RuView perception, model, security, and data-governance maintainers +- **Tags**: forecasting, rust, ruvector, temporal, uncertainty, clean-room, provenance +- **Extends**: ADR-016, ADR-020, ADR-145, ADR-273, ADR-282, ADR-295, + ADR-298, ADR-302, ADR-304, ADR-317, ADR-318, ADR-319 +- **Supersedes**: None + +## Executive decision + +RuView will develop an independently specified and independently trained Rust +multivariate forecasting subsystem. It will forecast bounded, versioned +temporal feature streams, publish calibrated quantiles and an explicit +abstention state, optionally use a split-safe RuVector analogue index, and +remain advisory until all release gates in this ADR pass. + +This is not a port, compatibility layer, distillation, or behavioural clone of +Google TimesFM. The implementation API is derived from RuView requirements. +Its weights must descend from an approved random initialization and approved +training data only. + +**Evidence status at proposal:** the forecasting accuracy, false-alert +reduction, calibration, CPU latency, memory use, cross-building generalization, +and operational value described below are all **UNMEASURED targets**. This ADR +authorizes implementation and evaluation; it makes no `MEASURED` capability +claim and approves no production model. + +## Context + +RuView currently observes present and recent RF state. Forecasting could add a +separate answer to questions such as whether a feature trajectory is consistent +with real occupancy, whether motion is likely to transition between zones, or +whether a radio link is degrading. A forecast is not a sensor observation. It +is derived evidence with uncertainty and must never overwrite the immutable +observation that produced it. + +TimesFM 3 is useful research context because its public description discusses +multivariate targets, past covariates, known-future covariates, probabilistic +outputs, temporal patching, and cross-series attention. Its licensing creates +two distinct surfaces: + +- the Google TimesFM source repository states that source code is Apache-2.0; +- the TimesFM 3 pretrained weights are distributed under a separate + non-commercial, non-production license that also restricts commercial + training, fine-tuning, and distillation from the model. + +An Apache-compliant Rust port would be a permissible but attributed derivative +source path, subject to its exact license obligations. It would not support the +strongest independent-development claim. Using the restricted TimesFM 3 model +or its outputs to create a RuView commercial model is outside this ADR. The +project therefore chooses the stricter clean-room protocol in +[`../security/ruview-forecast-clean-room.md`](../security/ruview-forecast-clean-room.md). + +Clean-room controls reduce copyright, contract, and provenance risk. They do +not establish freedom to operate against patents, clear trademarks, approve a +dataset, or replace legal review in a launch jurisdiction. + +## Options considered + +1. **Use the TimesFM 3 checkpoint in RuView.** Rejected for commercial and + production use under the current checkpoint license. +2. **Translate the Apache-licensed implementation into Rust.** Not selected. + This can be evaluated under a separate ADR, with attribution and license + compliance, but must not be mixed with the independent-development path. +3. **Build an independent Rust forecasting specialist for RuView.** Chosen. + This narrows model scope, keeps inference native, controls provenance, and + makes RF-specific evaluation the source of authority. +4. **Do not add forecasting.** Retained as the deployment baseline. Forecasting + must beat simpler deterministic and statistical baselines before acquiring + runtime authority. + +## Requirements + +| ID | Requirement | Verification authority | +|---|---|---| +| RF-001 | No TimesFM 3 code, configuration, weights, parameters, outputs, activations, or derivatives enter specification, implementation, training, evaluation feedback, or release artifacts. | Clean-room manifest, contributor attestations, repository and artifact scans | +| RF-002 | Every contributor, reference, dependency, dataset, transform, training job, and checkpoint has immutable provenance. | Signed provenance manifests and review receipts | +| RF-003 | Every training byte has documented rights for commercial machine-learning use and applicable privacy approval. | Dataset licensing gate and data-steward approval | +| RF-004 | The default runtime is native Rust, bounded, offline, deterministic for a fixed artifact/input within the declared platform class, and free of runtime model download. | Unit, property, fuzz, replay, and dependency tests | +| RF-005 | Inputs and outputs are versioned, finite, bounded, timestamped, mask-aware, and provenance-labelled. Invalid, stale, insufficient, or OOD inputs abstain. | Contract tests and malformed-input corpus | +| RF-006 | Forecasts remain derived evidence. They cannot rewrite observations, silently increase observation confidence, or be relabelled `MEASURED`. | Schema invariants and evidence-engine tests | +| RF-007 | Quantile ordering, interval calibration, missing-data behaviour, and abstention are evaluated on leakage-free site/session/device holdouts. | Frozen evaluation manifest and reproducible report | +| RF-008 | RuVector retrieval is optional, split-scoped, provenance-recorded, and evaluated against the same model without retrieval. | Retrieval isolation tests and paired ablation | +| RF-009 | RuVLLM may explain or summarize a signed forecast but cannot alter numeric forecasts, bypass policy, spend money, or trigger medical, emergency, access-control, or life-safety action. | Capability tests and downstream policy review | +| RF-010 | Training on local Linux or a hosted accelerator uses identical signed code, container, data, and configuration identities; the provider is an untrusted processor. | Training receipts and digest comparison | +| RF-011 | Accuracy, latency, memory, power, and operational claims remain `UNMEASURED` or `CLAIMED` until a named reproducer satisfies the repository evidence contract. | Documentation and release review | +| RF-012 | Rollout is reversible and cannot advance from offline evaluation to shadow or advisory operation without the mapped gates below. | Signed mode transition and rollback drill | + +## Architecture boundary + +The initial implementation is split across three crates: + +| Crate | Owned responsibility | Dependency/runtime rule | +|---|---|---| +| `ruview-forecast-core` | Backend-neutral schemas, invariants, metrics, forecast receipts, and the `Forecaster` trait | No Burn, CUDA, WGPU, provider SDK, sensing-server, or network dependency | +| `ruview-forecast-model` | Independent Burn 0.21 patch-mixer architecture and artifact execution | CPU, CUDA, and WGPU are explicit optional features; every backend feature is off by default | +| `ruview-forecast-train` | Dataset manifests/splits, trainer, evaluator, the `ruforecast` CLI, and Linux/fal.ai training assets | Training-only authority; no production activation or sensing-server mutation | + +This PR does not connect the forecaster to the sensing server. A shadow bridge +requires a follow-up change after the core contract and evidence receipt have +stabilized. Landing crates or passing synthetic tests therefore creates no +live RuView forecasting capability. + +Version one activates only the exact reviewed `tiny_ci` and `large_linux` +architecture profiles. The model and training boundaries enforce checked +dimension, parameter, activation-cell, input-cell, and forward multiply-add +limits per batch. Adding or changing a profile is therefore a reviewed code and +artifact-schema decision, not untrusted request configuration. + +The proposed logical pipeline is: + +```text +authenticated RF observations + | + v +versioned one-second feature windows + | + +----> split-scoped RuVector analogue retrieval (optional) + | + v +independent Rust temporal model + | + v +point forecast + ordered quantiles + validity mask + abstention + | + v +signed derived-evidence record + | + +----> RuView policy/evidence engine + +----> RuVLLM explanation with no numeric mutation authority +``` + +The initial feature contract should favour compact one-second summaries rather +than feeding an unbounded raw CSI stream. Candidate fields include motion +energy, Doppler summary, amplitude/phase dispersion, coherence, RSSI, packet +loss, channel utilization, current detector confidence, device temperature, +and source-validity masks. Each field needs a schema version, physical unit, +aggregation rule, validity semantics, and provenance. Adding a field is a +schema change, not an implicit positional extension. + +The model may independently combine normalization, temporal patch encoding, +temporal mixing, cross-stream fusion, and quantile heads. This is a functional +design space, not permission to reproduce protected source expression, +checkpoint dimensions, constants, tests, diagrams, or API choices. Exact +architecture and parameter count remain implementation decisions recorded in +the model card and training receipt. + +## Forecast contract + +For each request the runtime returns one typed result, including abstention and +error paths. The result must identify: + +- input schema, feature-window, model, configuration, and calibration digests; +- source time range, requested horizon, cadence, target streams, and masks; +- point estimate and declared quantiles for each target/horizon cell; +- whether quantiles were corrected for crossing and which method was used; +- OOD, insufficient-context, stale-input, and non-finite dispositions; +- optional RuVector index version, neighbour identifiers, distances, and + leakage-scope receipt; +- runtime platform class, duration, and peak-memory measurement when enabled; +- evidence label `DERIVED_FORECAST`, never `MEASURED_OBSERVATION`; +- deterministic content hash and optional RVF signature. + +The runtime rejects non-finite values, duplicate or non-monotonic timestamps, +unsupported schema versions, unbounded dimensions, invalid quantile requests, +future covariates without an allowed source, and payloads above configured +limits. It abstains rather than imputing an authoritative state when history or +validity coverage is below the model-card threshold. + +## RuVector integration + +RuVector supplies temporal memory, not ground truth. During training and +evaluation, each split has a separate index built only from records permitted +for that split. A test query may retrieve training analogues, but it may never +retrieve a window from the same subject/session/site holdout, an overlapping +target horizon, or any test record. Fitted normalizers and retrieval thresholds +come from training data only. + +Every release reports three comparable rows on identical examples: + +1. deterministic/statistical baseline; +2. forecasting model without retrieval; +3. forecasting model with RuVector retrieval. + +This prevents retrieval from hiding a weak model or leaking future state. + +## RuVLLM and action authority + +RuVLLM consumes an immutable, signed forecast record and may produce an +explanation referencing its uncertainty and provenance. Its prose is not the +forecast and receives no higher evidence grade. Numeric values exposed to APIs +come from the forecasting record, not regenerated text. + +No forecast or LLM explanation independently triggers a health alert, emergency +response, access decision, actuator, firmware change, model promotion, or +commercial spend. Such actions require a separate governed policy, explicit +capability, and appropriate observed evidence. + +## Training and artifact boundary + +Training jobs run from a pinned container or reproducible local environment. +The receipt binds the source commit, lockfile, compiler, container, datasets, +transforms, architecture, hyperparameters, seeds, hardware, provider job ID, +start/end time, and every emitted checkpoint. Hosted workers receive only the +least data and credentials necessary. Provider caches, logs, retention, and +reuse rights must pass security and data review before customer-derived data is +uploaded. + +A release artifact is immutable and hash addressed. It is loaded locally after +signature, schema, size, and compatibility verification. The inference process +does not fetch models, execute embedded code, accept arbitrary operators, or +contact the training provider. + +## Leakage and evaluation protocol + +- Split by deployment/site first, then subject, session, device, and contiguous + time block as applicable. No overlapping raw sequence or derived window may + cross a split. +- Freeze test manifests before tuning. A failed test result does not become a + new training target; material architecture changes require a new untouched + holdout. +- Fit normalization, calibration, feature selection, thresholds, and RuVector + index parameters on training/validation data only. +- Report missingness, abstention coverage, selective risk, per-horizon errors, + interval coverage, quantile loss, and results by site/device/interference + regime. Pooled performance cannot hide a failed domain. +- Compare against last value, seasonal naive, and at least one small classical + or recurrent baseline. TimesFM 3 output is not an acceptance oracle. +- Forecast evaluation does not prove presence, pose, fall, respiration, or + medical accuracy. Any downstream claim requires its own labelled protocol. + +## Acceptance gates and requirement mapping + +Targets below are release criteria, not current results. + +| Gate | Requirements | Pass condition | Current state | +|---|---|---|---| +| G0 independent-authoring boundary | RF-001, RF-002 | Approved source allowlist; 100% current contributor attestations; zero restricted artifacts or outputs; all similarity findings adjudicated by the clean-room custodian. | **OPEN / UNMEASURED** | +| G1 data and lineage | RF-002, RF-003, RF-010 | 100% of training/evaluation bytes resolve to approved manifests; zero unknown, noncommercial, research-only, no-ML, or no-derivatives sources; checkpoint lineage reaches approved random initialization; local/hosted receipts bind identical governed inputs. | **OPEN / UNMEASURED** | +| G2 bounded Rust contract | RF-004, RF-005, RF-006 | Unit/property/fuzz tests cover dimensions, non-finite values, timestamp order, masks, quantile order, stale/OOD/insufficient context, deterministic hashes, offline loading, evidence labels, and resource caps; 24-hour accelerated replay has no panic or unbounded growth. | **OPEN / UNMEASURED** | +| G3 leakage-free model evidence | RF-007, RF-008, RF-011 | Frozen site/session/device-disjoint report; all baseline and ablation rows present; nominal 80% interval coverage target is 75%-85%; weighted quantile loss target is at least 10% better than seasonal naive; every metric has a reproducer. | **OPEN / TARGETS UNMEASURED** | +| G4 RuView shadow value | RF-006, RF-007, RF-008, RF-009 | At least 14 days shadow-only; empty-room false-alert target is at least 50% relative reduction without more than 2 percentage points occupied-room recall loss; no safety-critical action authority; drift and abstention reported by deployment. | **OPEN / TARGETS UNMEASURED** | +| G5 deployment fitness | RF-004, RF-009, RF-010, RF-011, RF-012 | CPU p95 target is at most 1 second for 32 declared streams with peak process memory at most 4 GiB; signed model card/SBOM/provenance; privacy, security, trademark, dataset, and patent reviews; rollback drill; no open severity 1/2 issue. | **OPEN / TARGETS UNMEASURED** | + +Failure at any gate keeps the model offline or shadow-only. Passing a software +gate is not real-hardware evidence and does not convert an accuracy target into +a measurement. + +## Rollout and rollback + +The only permitted progression is: + +```text +OFF -> OFFLINE_EVAL -> SHADOW -> ADVISORY +``` + +This ADR does not authorize autonomous action. Each transition records actor, +old/new mode, artifact and configuration digests, evidence report, and reason. +Regression, provenance failure, calibration drift, security incident, or +licensing uncertainty returns immediately to `OFF` or `SHADOW` while observed +RuView sensing continues unchanged. + +Rollback deactivates the forecast artifact atomically, clears ephemeral model +and retrieval state, preserves signed aggregate evidence and incident records, +and never downgrades or rewrites the raw observation stream. + +## Security and privacy + +Forecasting can infer routines from occupancy and movement even without raw +CSI. Feature windows, neighbour identifiers, forecasts, and explanations are +therefore deployment data subject to purpose limitation, tenant isolation, +retention, deletion, access control, and audit. + +Model artifacts, feature schemas, calibration, indexes, and manifests are +untrusted until their signature and digest verify. Cardinality, dimensions, +horizon, context, allocations, execution time, and concurrent requests are +bounded. Metrics use allowlisted aggregate values and exclude raw CSI, precise +room coordinates, persistent person identifiers, and unrestricted feature +payloads. + +The detailed authoring, data, AI-tool, incident, trademark, and patent controls +are normative in +[`../security/ruview-forecast-clean-room.md`](../security/ruview-forecast-clean-room.md). + +## Consequences + +### Positive + +- RuView gains a provider-neutral predictive evidence primitive with explicit + uncertainty and abstention. +- Rust-native inference can be evaluated on CPU without requiring a Python or + cloud runtime in production. +- RuVector retrieval becomes a measurable ablation rather than an implicit + memory claim. +- Signed provenance supports reproducible Linux and hosted training. + +### Negative + +- Independent training needs substantial diverse, correctly licensed temporal + data and untouched deployment holdouts. +- Strict source separation and manifests increase contributor and review cost. +- A plausible forecast can make weak sensing look more authoritative unless + downstream evidence labels remain intact. +- Clean-room development does not remove patent, trademark, privacy, or dataset + risk. + +### Neutral + +- This ADR does not repair an inaccurate upstream presence or pose estimator. +- It does not approve TimesFM 3 for RuView use. +- It does not select a final model size, training budget, or hosted provider. +- No production checkpoint or measured benchmark is created by this decision. + +## References + +- [TimesFM 3 public research article](https://research.google/blog/timesfm-3-a-zero-shot-foundation-model-for-multivariate-forecasting/) +- [Google TimesFM repository and source-license notice](https://github.com/google-research/timesfm) +- [TimesFM 3 checkpoint license](https://huggingface.co/google/timesfm-3.0-pytorch/blob/main/LICENSE) +- [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0) +- [17 U.S.C. section 102](https://www.copyright.gov/title17/92chap1.html) +- [USPTO Patent Public Search](https://www.uspto.gov/patents/search/patent-public-search) +- [USPTO comprehensive trademark clearance](https://www.uspto.gov/trademarks/search/comprehensive-clearance-search-similar-trademarks) +- [RuView Forecast clean-room protocol](../security/ruview-forecast-clean-room.md) +- [RuView Forecast model-card template](../huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md) +- [ADR-145](./ADR-145-ablation-eval-harness-privacy-leakage.md) +- [ADR-282](./ADR-282-ruview-ecosystem-positioning.md) +- [ADR-295](./ADR-295-source-provenance-state-machine.md) +- [ADR-298](./ADR-298-model-release-sanity-gates.md) +- [ADR-302](./ADR-302-out-of-distribution-detection.md) +- [ADR-304](./ADR-304-evidence-engine.md) +- [ADR-317](./ADR-317-benchmark-multi-domain-scorecard.md) +- [ADR-318](./ADR-318-capability-certificates.md) +- [ADR-319](./ADR-319-witness-chain.md) diff --git a/docs/adr/ADR-349-governed-local-and-fal-forecast-training.md b/docs/adr/ADR-349-governed-local-and-fal-forecast-training.md new file mode 100644 index 000000000..caafd240f --- /dev/null +++ b/docs/adr/ADR-349-governed-local-and-fal-forecast-training.md @@ -0,0 +1,231 @@ +# ADR-349: Governed local and fal.ai forecast training + +- **Status**: Proposed +- **Date**: 2026-09-01 +- **Deciders**: ruv +- **Owners**: RuView forecast training, security, and release maintainers +- **Tags**: forecasting, training, fal, rust, receipts, idempotency, cost-control +- **Parent**: ADR-348 +- **Extends**: ADR-010, ADR-145, ADR-298, ADR-319, ADR-348 +- **Supersedes**: None + +## Decision + +RuView Forecast will use one Rust training engine behind two deliberately +different request boundaries. The local Linux request may bind a governed, +hash-addressed RuView JSONL shard. Hosted v1 accepts only a bounded, +deterministic synthetic generator recipe. It cannot serialize a local +`TrainSpec`, `DataPolicy`, path, dataset bytes, tenant/account/workspace/site/ +device/session identity, split receipt, or RuVector namespace. + +The fal.ai path is a bounded execution adapter, not a remote shell. It may +train the fixed synthetic profile and export fixed artifact kinds, but it may +not choose an arbitrary command, executable, container, URL, environment +variable, output path, or release action. A later real-data hosted path needs a +new accepted ADR, provider/privacy review, and an operator-signed export grant; +it is intentionally not implemented here. + +Every hosted result is untrusted and quarantined until it is exported with a +complete receipt, downloaded through the authenticated provider channel, +re-hashed locally, scanned, and bound to the original request. Only the local +release boundary may sign or activate it. Signing keys never enter fal.ai. + +This ADR delegates dataset rights, clean-room evidence, model-card, patent, +trademark, and production gates to [ADR-348](./ADR-348-independent-rust-multivariate-forecasting.md) +and the [clean-room protocol](../security/ruview-forecast-clean-room.md). + +**Evidence status:** typed local and hosted configuration, root-confined file +verification, atomic fixed-kind artifact publication, and cooperative +cancellation source surfaces exist. End-to-end local training and mock hosted +transport are software-testable in this change. Real fal.ai execution, +provider cancellation, cost reconciliation, quarantine promotion, and local +signature verification remain **UNMEASURED and unapproved** until their named +acceptance evidence exists. + +## Context + +The same training engine needs to run on a 128 GiB Linux workstation and on an +optional hosted accelerator without creating two architecture lineages. A +generic remote-command interface would allow request data to become executable +authority, make cost difficult to bound, and weaken cancellation. Sending a +general local request to fal would also expose governance and customer +identifiers that the hosted smoke path does not need. + +The initial `ruview-forecast-train` crate already separates three relevant +modules: + +| Module | Current contract | +|---|---| +| [`config.rs`](../../v2/crates/ruview-forecast-train/src/config.rs) | `TrainingRequest`, `ValidatedTrainingRequest`, bounded `JobId`, root-relative dataset path, exact size/SHA-256, named model profiles, typed CPU/CUDA choice, and bounded optimizer values; unknown fields are denied | +| [`artifact.rs`](../../v2/crates/ruview-forecast-train/src/artifact.rs) | verified open dataset handle, root confinement, fixed `Model`/`Manifest`/`Receipt`/`Checkpoint` kinds, atomic writes, content descriptors, and conflict-on-different-bytes idempotency | +| [`cancel.rs`](../../v2/crates/ruview-forecast-train/src/cancel.rs) | `Cancellation`, `CancelToken`, and cooperative cancellation checks for batch/checkpoint boundaries | + +The training crate features keep `cli`, `server`, `fal-client`, `cpu`, and +`cuda` explicit, with default features empty. WGPU remains a model-crate +inference backend and is not advertised as a training runner. The `ruforecast` +binary requires the `cli` feature. Source presence is not execution evidence. + +## Typed execution contracts + +The non-serializable trusted `TrainingRequest` is built locally from a +size-bounded `LocalTrainingRequestWire`. It contains: + +- stable job identity; +- a constructor-validated local split/horizon/normalization `TrainSpec`; +- either a root-relative dataset identity with exact byte count and SHA-256, + or a deterministic synthetic recipe; +- one allowlisted model capacity profile and compiled-in local backend; +- bounded optimizer, checkpoint cadence, seed, memory, step, time, checkpoint, + and artifact budgets. + +Local JSON and TOML deny unknown fields. Dataset paths resolve below an +operator-configured root, and the exact regular file remains open after +size/hash verification to avoid replacement between validation and use. JSONL +windows are decoded incrementally with per-line and per-window caps rather +than loading the full shard into memory. + +The distinct `HostedSyntheticRequestV1` contains only a schema version, +idempotency/request digests, a fixed model profile, deterministic generator +parameters and seed, bounded optimizer/resource caps, and public build +identities. Its constructor needs a non-serializable core governance authority +proving that the synthetic recipe is the approved hosted source. Hosted v1 has +no external dataset field. + +Neither request contains a shell string, argument vector, executable path, +container/image selector, package installer, arbitrary URL, callback URL, +environment map, secret, source fragment, or unrestricted output name. + +The local client maps `HostedSyntheticRequestV1` to one allowlisted, +process-configured fal app and pre-deployed content-addressed worker image. The +caller cannot override the app, machine, worker entry point, or artifact +destination. The endpoint returns typed status or artifact descriptors only. +Provider paths are derived from validated job identity and fixed artifact +kinds, then downloaded through the authenticated fal Platform Files API; they +are not accepted as remote URLs or local paths. + +The deploy wrapper builds from an allowlisted `git archive`, selects the exact +`fal_app.py::run_server` symbol, and passes `--auth private` for both `fal run` +and `fal deploy`; ephemeral Fal runs otherwise default to public. Hosted v1 +retains only bounded synthetic request/result metadata so its typed queue result +can be reconciled. It sets `X-Fal-No-Retry: 1` and a bounded timeout rather than +claiming that `X-Fal-Store-IO: 0` or an unverified lifecycle header protects +the result. A live unauthenticated 401/403 probe and provider retention/deletion +reconciliation remain blocking operational evidence. + +## Idempotency and cost authority + +The local effective idempotency identity is: + +```text +job_id + canonical request digest + source commit + lockfile digest ++ container digest + dataset/split digest + initial-weight digest +``` + +Hosted v1 replaces `dataset/split digest` with the canonical synthetic-recipe +digest. No local governance identifier is part of, or derivable from, the +hosted payload. + +Repeating an identical completed request returns the existing verified receipt +and descriptors. Reusing `job_id` with any different governed input fails as a +conflict. A retry after an ambiguous network failure must first query the same +provider job; it must not silently create a second billable run. + +Before hosted submission, the caller sets explicit maximum steps, wall-clock +time, memory, checkpoint count, export bytes, and billable units. Submission +also requires an explicit local spend-approval record bound to the request +digest and maximum units. The adapter rejects a request when its configured +cap exceeds that approval. The final receipt records estimated and actual +provider units/cost when the provider supplies them, the price source/time, +and whether the amount is final or provider-reported. A provider estimate is +`CLAIMED`, never `MEASURED`, until reconciled against a bill. + +No automatic retry may increase the approved budget. Budget changes create a +new signed authorization record. The initial wire carries operator ceilings +for wall time, billable seconds, and micro-USD, but it does not yet bind a +provider price quote or reconcile a final provider ledger. Monetary enforcement +therefore remains an operator/Fal-account control and the production hosted +spend gate stays open. + +## Cancellation and checkpoints + +Cancellation is cooperative and idempotent: + +1. Local signal or authenticated hosted cancel marks one `job_id` cancelled. +2. Training checks `Cancellation` at every batch boundary and before/after each + checkpoint/export boundary. +3. A cancellation checkpoint is committed atomically when safe and within the + artifact budget; a partial file never becomes a valid artifact. +4. The terminal receipt records `cancelled`, the last completed epoch/step, + checkpoint digest, provider state, elapsed resource units, and final known + cost. +5. Repeating cancel returns the same terminal state. + +Cancellation does not promise immediate GPU termination. Cancellation latency, +checkpoint durability, and residual provider billing are **UNMEASURED** until a +real fal.ai run supplies a receipt. A timeout or lost cancellation response +keeps the job and any output quarantined. + +## Export, quarantine, and local signing + +The worker may emit only the fixed artifact set from `ArtifactKind`: + +| Kind | Required purpose | +|---|---| +| `Model` | Burn model record bytes | +| `Manifest` | architecture, data/split, seed, build, and clean-room identity | +| `Receipt` | request, environment, metrics, cost, status, and export lineage | +| `Checkpoint` | final or cancellation-time model weights; v1 does not claim optimizer/cursor resume | + +Every descriptor includes kind, size, and SHA-256. Export acceptance requires +all mandatory descriptors, bounded lengths, exact hashes, the expected job and +request digest, no duplicate kind, and a terminal status consistent with the +artifact set. + +Downloaded files enter a non-executable quarantine outside the model search +path. Local verification repeats envelope/schema/size/hash checks, SBOM and +malware/policy scans, clean-room declarations, dataset/split lineage, metric +labels, and parent-checkpoint validation. No provider output may update a +symlink, current-model pointer, RuVector index, sensing server, or release tag. + +After quarantine passes, an authorized local signer binds the exact export +receipt and artifact digests to an `ArtifactReceipt` and release manifest. The +signature records algorithm, public key ID, signer capability, and time. Fal.ai +receives no private signing material and cannot promote its own output. The +current core supplies canonical digests and receipts, not a complete release +signature implementation; signature-backed activation remains open. + +## Requirements and acceptance + +| ID | Requirement | ADR-348 gate | Acceptance evidence | Current state | +|---|---|---|---|---| +| FT-001 | Local and hosted adapters invoke the same model/trainer implementation while using intentionally separate local-data and hosted-synthetic request schemas. | G1 | Shared-engine tests plus hosted DTO exclusion and recipe-digest binding tests | **OPEN / UNMEASURED** | +| FT-002 | No request field or endpoint can select arbitrary code, command, image, URL, environment, or output path. | G2 | Schema negatives, endpoint capability test, dependency review | **OPEN / UNMEASURED** | +| FT-003 | Job/request idempotency prevents duplicate execution and conflicts on changed governed input. | G1, G5 | Concurrent/retry/lost-response tests with provider job query | **OPEN / UNMEASURED** | +| FT-004 | Hosted execution has explicit non-escalating resource and monetary caps with estimated/actual cost receipts. | G5 | Over-budget rejection and reconciled provider-bill fixture plus real-run receipt | **OPEN / UNMEASURED** | +| FT-005 | Cancellation is authenticated, cooperative, idempotent, checkpoint-safe, and terminally receipted. | G2, G5 | Local property tests and a real hosted cancellation drill | **OPEN / UNMEASURED** | +| FT-006 | Export is fixed-kind, bounded, complete, hash-verified, and bound to request/job/environment. | G1, G2 | Missing/duplicate/truncated/tampered export tests | **OPEN / UNMEASURED** | +| FT-007 | Hosted outputs remain quarantined until local verification and local-only signing succeed. | G1, G5 | Promotion-denial tests, signing-key absence check, rollback drill | **OPEN / UNMEASURED** | +| FT-008 | Hosted v1 receives synthetic inputs only; provider credentials, logs, retention, region, output reuse, and deletion satisfy ADR-348 privacy/security approval before any real run. | G1, G5 | Hosted DTO exclusion tests, provider review, and deletion/export receipts | **OPEN / UNMEASURED** | + +No successful unit test closes a hosted gate. G5 requires one bounded real +fal.ai run, one cancellation drill, one ambiguous-retry/idempotency drill, cost +reconciliation, quarantine rejection of a tampered export, and local signature +verification over the accepted artifact. Until then fal.ai support is a typed +software surface, not an operational capability claim. + +## Consequences + +The design gives local and hosted training one auditable lineage and keeps +provider output below local release authority. It adds receipt, budget, +quarantine, and signing work, and cooperative cancellation may still incur +provider cost. If a hosted provider cannot support bounded idempotent status, +export, cancellation, and deletion, the Linux path remains the only approved +training environment. + +## References + +- [ADR-348](./ADR-348-independent-rust-multivariate-forecasting.md) +- [RuView Forecast clean-room protocol](../security/ruview-forecast-clean-room.md) +- [`ruview-forecast-train` manifest](../../v2/crates/ruview-forecast-train/Cargo.toml) +- [`ruview-forecast-core` receipts](../../v2/crates/ruview-forecast-core/src/receipt.rs) +- [`ruview-forecast-model` public artifact boundary](../../v2/crates/ruview-forecast-model/src/lib.rs) diff --git a/docs/adr/ADR-350-ruvector-predictive-memory-and-ruvllm-boundary.md b/docs/adr/ADR-350-ruvector-predictive-memory-and-ruvllm-boundary.md new file mode 100644 index 000000000..675eeeff0 --- /dev/null +++ b/docs/adr/ADR-350-ruvector-predictive-memory-and-ruvllm-boundary.md @@ -0,0 +1,217 @@ +# ADR-350: RuVector predictive memory and RuVLLM authority boundary + +- **Status**: Proposed +- **Date**: 2026-09-01 +- **Deciders**: ruv +- **Owners**: RuView forecast, RuVector, evidence, and RuVLLM integration maintainers +- **Tags**: forecasting, ruvector, ruvllm, retrieval, memory, receipts, authority +- **Parent**: ADR-348 +- **Extends**: ADR-004, ADR-010, ADR-016, ADR-145, ADR-261, ADR-295, + ADR-304, ADR-319, ADR-348 +- **Supersedes**: None + +## Decision + +RuVector may augment RuView Forecast by retrieving analogous historical +feature/forecast states from a tenant- and split-scoped predictive-memory +index. Retrieval is optional context, never ground truth. Every release must +report the same frozen examples with retrieval disabled and enabled so its +incremental value and leakage risk remain visible. + +Each forecast is published in an immutable, locally signed envelope binding the +model artifact, request, output, source evidence, and retrieval receipt. RuVLLM +may read that envelope to explain uncertainty and analogous history. It may not +rewrite numeric forecasts, create a stronger evidence class, select a model, +promote an artifact, spend money, invoke an actuator, or make medical, +emergency, access-control, or life-safety decisions. + +This child ADR delegates forecasting, clean-room, data, and general production +gates to [ADR-348](./ADR-348-independent-rust-multivariate-forecasting.md). It +does not authorize a sensing-server bridge. + +**Evidence status:** canonical forecast/receipt types and an indexable latent +state exist in source. A RuVector adapter, retrieval receipt, signature wrapper, +outcome reconciler, RuVLLM explanation adapter, paired retrieval evaluation, +latency, storage cost, accuracy lift, and operational value are all +**UNMEASURED and unapproved**. + +## Existing implementation boundary + +The current crates establish only the preconditions: + +| Module | Implemented surface used by this ADR | +|---|---| +| [`ruview-forecast-core/src/forecast.rs`](../../v2/crates/ruview-forecast-core/src/forecast.rs) | validated `ForecastRequest`, backend-neutral `Forecaster`, ordered finite `Forecast`, canonical output digest, and receipt verification | +| [`ruview-forecast-core/src/receipt.rs`](../../v2/crates/ruview-forecast-core/src/receipt.rs) | `SourceState`, `ArtifactReceipt`, and `ForecastReceipt`; derived forecasts cannot retain `MEASURED` merely because measured input existed | +| [`ruview-forecast-core/src/series.rs`](../../v2/crates/ruview-forecast-core/src/series.rs) | bounded feature schema, masked time series, canonical series digest, and training-only scaler fit | +| [`ruview-forecast-model/src/network.rs`](../../v2/crates/ruview-forecast-model/src/network.rs) | `ForecastModelOutput.state` with shape `[batch, variates, d_model]`, intended as a bounded representation candidate for indexing | + +`ForecastReceipt` is content-addressed but not itself a digital signature. +`ForecastModelOutput.state` is indexable but not automatically safe, private, +stable across model versions, or useful. Those distinctions remain release +gates. + +## Predictive-memory record + +RuVector stores a versioned `PredictiveMemoryRecord` containing: + +- tenant and index namespace; +- model/artifact/config/feature-schema digests; +- source series and forecast-request digests; +- split, site, subject-class, session, device, calibration, and bounded time + scope using pseudonymous identifiers; +- bounded latent-state or engineered-feature digest plus the approved vector; +- forecast envelope digest and horizon; +- observed validity mask and evidence class; +- optional reconciled outcome digest added only after the forecast horizon; +- retention/deletion class and creation/expiry time. + +Raw CSI, unrestricted feature windows, precise room coordinates, persistent +person identity, RuVLLM prompts, and explanation prose are excluded by default. +The vector is still potentially sensitive because it can encode routines or +location. Tenant isolation, encryption, access control, retention, deletion, +and membership-inference review apply. + +An outcome is appended through a new immutable record linked to the original; +the historical forecast is never rewritten. Outcome reconciliation can measure +forecast quality but cannot retroactively turn the forecast into a measured +observation. + +## Split-scoped analogue retrieval + +Every index is bound to an immutable corpus manifest, model version, feature +schema, preprocessing configuration, and split policy. These rules are +mandatory: + +1. Tenants never share an index or query result without a separately authorized + privacy-preserving federation protocol. +2. Training, validation, calibration, and test records have distinct + namespaces. A test query may retrieve approved training analogues only; it + may not retrieve test examples or validation/calibration records used to set + thresholds. +3. The same site, subject, session, device, calibration episode, overlapping + context, or overlapping target horizon is excluded when that dimension is a + holdout. +4. Index construction, distance metric, normalization, filter policy, `k`, and + score threshold are fitted on training data and frozen before test. +5. Missing, stale, mismatched, unauthorized, or unverifiable indexes disable + retrieval and return the no-retrieval forecast or abstention according to + the model card. They never trigger an implicit global-index fallback. +6. Every query returns a bounded `RetrievalReceipt` even when zero neighbours + qualify. + +The retrieval receipt binds query digest, index/corpus/policy digests, +namespace, exclusion filter, neighbour record IDs and distances, `k`, latency, +and disposition. It excludes raw neighbour payloads from ordinary logs. + +## Paired ablation + +Retrieval has no deployment authority without a paired evaluation on identical +frozen requests: + +| Row | Model and examples | Retrieval | +|---|---|---| +| A | exact candidate artifact and frozen examples | disabled | +| B | exact candidate artifact and frozen examples | enabled with frozen index/policy | + +The report includes weighted quantile loss, interval coverage/width, +abstention, per-horizon error, per-site/device/interference slices, retrieval +hit/filtered/empty rates, latency, and memory/storage overhead. It reports both +aggregate and paired deltas with uncertainty. A gain on pooled error cannot +hide a failed deployment domain, calibration regression, or neighbour leakage. + +The no-retrieval row remains a supported fallback. Retrieval is removed when +its lower confidence bound does not show useful improvement, when it weakens +calibration beyond the ADR-348 gate, or when its privacy/latency/storage cost +exceeds its measured value. No uplift is claimed today. + +## Immutable signed forecast envelope + +The signed envelope covers: + +```text +envelope schema/version ++ ArtifactReceipt canonical digest ++ ForecastRequest canonical digest ++ Forecast payload/output digest ++ ForecastReceipt canonical digest ++ RetrievalReceipt digest or explicit retrieval-disabled marker ++ policy/calibration/index digests ++ creation/expiry time and tenant namespace +``` + +Signing occurs at the trusted local boundary after artifact and retrieval +verification. The wrapper records algorithm, public key ID, signer capability, +and signature. Private keys never enter model artifacts, RuVector, RuVLLM, or a +hosted training worker. + +Consumers verify signature, expiry, tenant, schemas, every nested digest, and +the `Forecast::verify_receipt` invariant before using numeric values. Any +failure yields unavailable/abstain. Unsigned content-addressed receipts remain +useful for local tests but are not described as signed and cannot cross the +production trust boundary. + +## RuVLLM explanation boundary + +RuVLLM receives a read-only projection of the verified envelope: + +- exact point and quantile values as structured fields; +- units, horizons, calibration/OOD/abstention state; +- bounded analogue summaries and retrieval receipt references; +- evidence labels, model/version, and envelope digest; +- approved explanation policy and audience. + +Explanation prose is stored separately and linked to the envelope digest. It +is `CLAIMED_EXPLANATION`, not a replacement forecast. Numeric API fields are +copied from the verified envelope after generation, never parsed back from LLM +text. If prose contradicts a structured number, evidence state, unit, or +disposition, the response fails validation and the numeric envelope remains +authoritative. + +The explanation capability exposes no tools for artifact activation, model +promotion, retraining, spending, messaging, emergency dispatch, access control, +or actuation. A separate downstream policy may consume a forecast only under +its own ADR, capabilities, approvals, and observed-evidence requirements. The +LLM cannot grant itself that authority or lower an approval threshold. + +## Requirements and acceptance + +| ID | Requirement | ADR-348 gate | Acceptance evidence | Current state | +|---|---|---|---|---| +| PM-001 | Every predictive-memory query is tenant-, model-, schema-, corpus-, split-, and time-scope bound. | G3, G5 | Cross-tenant/split/version negative tests and signed index manifest | **OPEN / UNMEASURED** | +| PM-002 | Holdout identities, overlapping contexts, and target horizons cannot appear as neighbours. | G3 | Property tests plus frozen leakage report | **OPEN / UNMEASURED** | +| PM-003 | Every query, including disabled/empty/error, produces a bounded retrieval receipt. | G2, G3 | Receipt round-trip, tamper, bound, and zero-result tests | **OPEN / UNMEASURED** | +| PM-004 | Retrieval-disabled and retrieval-enabled rows use identical artifact/examples and report paired metrics and overhead. | G3 | Frozen paired ablation with reproducer | **OPEN / UNMEASURED** | +| PM-005 | Forecast envelope signatures bind all nested forecast/retrieval identities and fail closed on mutation, expiry, tenant, or key error. | G2, G5 | Signature/tamper/replay/cross-tenant tests and local signing receipt | **OPEN / UNMEASURED** | +| PM-006 | Derived forecast and explanation can never become `MEASURED_OBSERVATION` or exceed source evidence. | G2, G4 | Evidence-monotonicity schema/property tests | **OPEN / UNMEASURED** | +| PM-007 | RuVLLM cannot mutate structured numbers; contradictions fail validation and explanations remain separately labelled. | G4 | Numeric/unit/evidence mutation corpus and fail-closed integration tests | **OPEN / UNMEASURED** | +| PM-008 | RuVLLM explanation has no spending, promotion, messaging, safety, access-control, or actuator capability. | G4, G5 | Default-deny capability and attempted-escalation tests | **OPEN / UNMEASURED** | +| PM-009 | Vector/explanation privacy, retention, deletion, extraction, and membership risks are approved and operationally tested. | G5 | Privacy review, tenant deletion drill, and access audit | **OPEN / UNMEASURED** | + +G3 additionally requires the paired retrieval ablation to satisfy ADR-348's +forecast and calibration gates. G4 requires shadow evidence that retrieval and +explanation improve operator comprehension or forecast value without changing +numeric/action authority. G5 requires signed-envelope verification, rollback, +tenant deletion, and key-rotation drills. None has passed. + +## Consequences + +RuVector can evolve from retrospective search into outcome-linked predictive +memory while preserving a measurable no-retrieval baseline. RuVLLM can make +forecasts understandable without becoming the numeric or action authority. The +cost is additional index isolation, storage, signature, deletion, evaluation, +and policy complexity. If paired evidence does not justify that cost, the +correct release keeps retrieval and explanation disabled. + +## References + +- [ADR-348](./ADR-348-independent-rust-multivariate-forecasting.md) +- [ADR-349](./ADR-349-governed-local-and-fal-forecast-training.md) +- [RuView Forecast clean-room protocol](../security/ruview-forecast-clean-room.md) +- [RuView Forecast model-card template](../huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md) +- [RuForecast benchmark protocol](../benchmarks/ruforecast.md) +- [ADR-016](./ADR-016-ruvector-integration.md) +- [ADR-145](./ADR-145-ablation-eval-harness-privacy-leakage.md) +- [ADR-261](./ADR-261-ruvector-graph-ann-index.md) +- [ADR-304](./ADR-304-evidence-engine.md) +- [ADR-319](./ADR-319-witness-chain.md) diff --git a/docs/adr/README.md b/docs/adr/README.md index 77317d11e..293c514a5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -92,6 +92,9 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme | [ADR-150](ADR-150-rf-foundation-encoder.md) | RF Foundation Encoder: pose-preserving, subject/room/device-invariant CSI embedding | Proposed | | [ADR-151](ADR-151-room-calibration-specialist-training.md) | Per-Room Calibration & Specialized Model Training (room-first → bank of small ruVector specialists) | Proposed | | [ADR-152](ADR-152-wifi-pose-sota-2026-intake.md) | WiFi-Pose SOTA 2026 Intake: geometry-conditioned calibration, external benchmarks, foundation-encoder recipe | Proposed | +| [ADR-348](ADR-348-independent-rust-multivariate-forecasting.md) | Independent Rust multivariate forecasting for RuView | Proposed | +| [ADR-349](ADR-349-governed-local-and-fal-forecast-training.md) | Governed local and fal.ai forecast training | Proposed | +| [ADR-350](ADR-350-ruvector-predictive-memory-and-ruvllm-boundary.md) | RuVector predictive memory and RuVLLM authority boundary | Proposed | ### Platform and UI @@ -106,6 +109,8 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme | [ADR-036](ADR-036-rvf-training-pipeline-ui.md) | Training Pipeline UI Integration | Proposed | | [ADR-043](ADR-043-sensing-server-ui-api-completion.md) | Sensing Server UI API Completion (14 endpoints) | Accepted | | [ADR-344](ADR-344-adaptive-local-installation-discovery.md) | Adaptive Local Installation Discovery | Accepted (local software path) | +| [ADR-346](ADR-346-fail-closed-edge-occupancy-evidence.md) | Fail closed ESP32 occupancy evidence | Accepted (C6 occupancy integrity qualified) | +| [ADR-347](ADR-347-rate-aware-esp32-temporal-sensing.md) | Rate aware ESP32 temporal sensing | Accepted (C6 timing and transport qualified) | | [ADR-115](ADR-115-home-assistant-integration.md) | Home Assistant integration via MQTT auto-discovery + Matter bridge (HA-DISCO + HA-FABRIC + HA-MIND) | Accepted (MQTT track) / Proposed (Matter SDK P8b) | | [ADR-169](ADR-169-adam-mode-light-theme.md) | adam-mode — light theme toggle for the three.js realtime demo | Proposed | | [ADR-170](ADR-170-yoga-mode-pose-system.md) | yoga-mode — yoga pose detection, classification, and scoring for the three.js realtime demo | Proposed | diff --git a/docs/benchmarks/ruforecast.md b/docs/benchmarks/ruforecast.md new file mode 100644 index 000000000..a8393c53e --- /dev/null +++ b/docs/benchmarks/ruforecast.md @@ -0,0 +1,380 @@ +# RuForecast benchmark protocol + +## Evidence status + +No RuForecast runtime, accuracy, calibration, memory, or operational result is +recorded here yet. Every threshold below is an ADR-348 target, not a measured +claim. The first accepted row must identify a clean commit, the `Cargo.lock` +digest, the exact Rust toolchain, host, backend, model/configuration digest, +fixture or corpus digest, command, and evidence label. + +## Benchmark boundaries + +RuForecast uses two different forms of evidence: + +1. Deterministic correctness evidence comes from Rust unit, property, replay, + split-isolation, and artifact-tamper tests. A fixed input and artifact must + produce the same output within its declared platform class. +2. Runtime evidence comes from Criterion on a named host. Criterion inputs are + generated from fixed checked-in code and seeds, but elapsed time is not + deterministic. Timing from shared GitHub runners is informational only. + +The benchmark implementation must not download a model or dataset, read raw +CSI, use a hosted model output, or enable CUDA implicitly. CPU benchmarks use +the explicit `cpu` feature and the Burn ndarray backend. CUDA validation belongs +to a separately governed Linux or hosted-accelerator receipt. + +## Required benchmark targets + +| Package | Target | Purpose | CI authority | +|---|---|---|---| +| `ruview-forecast-model` | `forecast_inference` | Fixed-seed forward pass, batch and shape scaling, ordered-quantile output | Compile gate; shared-runner timing is informational | +| `ruview-forecast-train` | `data_pipeline` | Fixed generated records through validation, windowing, masking and batching | Compile gate; shared-runner timing is informational | + +Both targets must use code-generated synthetic inputs, fixed seeds, bounded +allocations, `criterion::black_box`, and `required-features = ["cpu"]`. Setup, +artifact construction, and dataset generation stay outside the timed region +unless a benchmark name explicitly says they are included. + +The model implementation owns structural parameter-count assertions. The +currently reviewed design values are 35,700 parameters for the tiny CI preset +and 20,285,108 for the large preset. These are design invariants, not benchmark +results, and must be derived by a test from the actual module graph before they +are quoted in a model card. + +## Local Linux reproducer + +Run from a clean checkout after installing Rust 1.92.0: + +```bash +RUFORECAST_CPUSET=0-7 \ +RUFORECAST_THREADS=8 \ +scripts/run-ruforecast-benchmarks.sh +``` + +The runner executes the focused contract/model/training tests, one real +optimizer step over a local hash-addressed synthetic JSONL shard, and the +idempotent synthetic CLI smoke. It then compile-checks both Criterion targets, +runs the targets, captures the CPU and toolchain metadata, and hashes every +output. A failed run retains its partial logs with `status=FAILED` and its exit +code rather than looking like a complete report. Results go under +`target/ruforecast-evidence/`, which is excluded from source control. + +For a conservative single-thread reproducibility check, omit both environment +variables. To run against an uncommitted tree for diagnosis only, set +`RUFORECAST_ALLOW_DIRTY=1`; the resulting metadata is labelled `SYNTHETIC` +with scope `DIRTY_WORKTREE_DIAGNOSTIC_ONLY` and cannot support a release claim. + +A clean run labels its host timing `MEASURED` and its input class `SYNTHETIC`, +but remains `UNREVIEWED`. Only a maintainer may append it to the accepted ledger +after checking the digests, shape, command, Criterion report and host scope. + +The runner intentionally has no CUDA option and does not parse Criterion output +into a pass/fail performance verdict. This prevents a noisy host result from +silently acquiring release authority. + +To compile the two benchmark targets without measuring them: + +```bash +cd v2 +cargo +1.92.0 bench --locked -p ruview-forecast-model \ + --no-default-features --features cpu --bench forecast_inference --no-run +cargo +1.92.0 bench --locked -p ruview-forecast-train \ + --no-default-features --features cpu --bench data_pipeline --no-run +``` + +For a short informational run, use the same targets without `--no-run`: + +```bash +cargo +1.92.0 bench --locked -p ruview-forecast-model \ + --no-default-features --features cpu --bench forecast_inference -- \ + --warm-up-time 1 --measurement-time 2 --sample-size 10 +cargo +1.92.0 bench --locked -p ruview-forecast-train \ + --no-default-features --features cpu --bench data_pipeline -- \ + --warm-up-time 1 --measurement-time 2 --sample-size 10 +``` + +Running these commands does not add a ledger row automatically. Preserve the +raw report and environment metadata, then have a maintainer assign its evidence +scope before publishing a number. + +The inference bench runs only `tiny_ci` by default so a routine CI trend step +cannot accidentally start the very expensive large CPU probe. Set +`RUFORECAST_BENCH_LARGE=1` only on a controlled host when intentionally +measuring the fixed deployment shape: + +```bash +RUFORECAST_BENCH_LARGE=1 scripts/run-ruforecast-benchmarks.sh +``` + +## Deployment measurement shape + +The initial CPU deployment probe is batch 1, context 1,024, 32 declared feature +streams, the fixed `large_linux` horizon of 300, and all seven declared +quantiles. Record at least 20 warmup +iterations and 200 measured iterations for a release candidate. Report p50, +p95, p99 or maximum, throughput, and the process peak resident set size. + +ADR-348 G5 currently targets p95 at or below one second for 32 declared streams +and peak process memory at or below 4 GiB. A Criterion result alone cannot close +the memory gate because Criterion and Cargo are not the production inference +process. G5 remains open until a standalone inference probe reports its own peak +resident set size. + +## Accuracy and calibration protocol + +Runtime speed never substitutes for forecasting quality. The frozen evaluation +manifest must report identical examples for: + +1. Last-value and seasonal-naive baselines. +2. RuForecast without RuVector retrieval. +3. RuForecast with split-scoped RuVector retrieval. + +Required report fields include weighted quantile loss by horizon, nominal 80% +interval coverage, missingness, abstention coverage, selective risk, site and +device slices, interference regime, and retrieval ablation. ADR-348 G3 targets +weighted quantile loss at least 10% better than seasonal naive and 80% interval +coverage between 75% and 85%. Those targets remain unmeasured until a frozen, +leakage-free report is attached. + +### Informal HPO exploration note (unaccepted, not a release claim) + +**2026-09-01.** An exploratory session ran the accuracy protocol above end to +end against a governed 24-window **synthetic** dataset (`tiny_ci` profile, +context 64 / horizon 12, temporal train/test split, not entity-holdout — +only one synthetic generator was used, so entity holdout does not apply) and +a small `OptimizerSpec` hyperparameter search (learning rate, weight decay, +gradient clip norm, batch size, epochs) using a new Darwin Mode numeric-genome +evolution engine (upstream: `ruvnet/metaharness` PR #260, not yet merged). +This is **exploratory evidence only** — not a frozen, leakage-free, +maintainer-reviewed report, and not eligible for the ledger below until one +is produced. + +Prior to this exploration, a **single real household window** (76 real +1 Hz vital-signs samples, one physical ESP32 sensor, temporal not entity +holdout) scored **worse than both baselines** (WQL 0.537 vs. last-value +0.106 and seasonal-naive 0.123) — consistent with a single training window +overfitting rather than generalizing. + +With a larger (still synthetic, still `tiny_ci`) 24-window training set and +three rounds of hyperparameter search, weighted quantile loss on the held-out +synthetic split improved and stayed ahead of both baselines throughout: + +| Round | learning_rate | weight_decay | grad_clip | batch | epochs | WQL (model) | WQL (last-value) | WQL (seasonal-naive) | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| Default config | 0.0010000 | 1.00e-4 | 1.000 | 8 | 60 | 0.257 | 0.277 | 0.514 | +| Search round 1 | 0.0002356 | 3.05e-6 | 0.100 | 27 | 195 | 0.161 | 0.277 | 0.514 | +| Search round 2 | 0.0000298 | 4.09e-11 | 4.746 | 26 | 356 | **0.153** | 0.277 | 0.514 | + +Round-2 gain over round 1 (−0.008) was much smaller than round-1's gain over +the default (−0.096) — a diminishing-returns signal consistent with a local +optimum for this model size and dataset, not a converged global result. +`gradient_clip_norm` landed at opposite bound extremes across rounds +(0.1 then 4.7), so no directional recommendation on that parameter should be +drawn from this exploration alone. + +**Explicit scope limits — do not generalize beyond these:** +- `tiny_ci` only. Nothing here has been run against `large_linux`; its far + larger parameter count and different compute profile mean these + hyperparameters are not a starting point for it without their own search. +- Synthetic dataset only (24 windows, one generator/seed family). Not + validated against any real corpus at this scale. +- Self-signed, evaluation-only model activation (a throwaway local Ed25519 + key, not a release signature) was used to run inference for scoring. +- No security/provenance/maintainer-approval gate has passed — the Darwin + Mode promotion rule correctly refused to promote any candidate here. + +Reproducer: `harness/ruview/flywheel/ruforecast/` (genome, gate, evaluator, +dry-run/`--confirm` driver) in the `ruvnet/RuView` repo, paired with +`ruvnet/metaharness` PR #260 (`evolve-numeric`) linked locally via +`npm link`. Neither the genome defaults here nor any repo default config +were changed by this note — it is a record of exploratory evidence, not a +committed recommendation. + +### Amendment (2026-09-01, later same day): the round-2 result above did not generalize -- retracted + +**The "Search round 2" row above (WQL 0.153, learning_rate=0.0000298 etc.) is +RETRACTED as a claim of improvement.** It is kept in the table (append-only, +never silently edit a prior measurement) but must be read together with this +amendment: independent verification, run the same day using a new +regression-candidate promotion path (`ruvnet/autogenous` PR-in-progress, +branch `feat/regression-candidate-kind`, not yet merged/pushed -- +`v2/crates/ruforecast-autogenous-bridge` in this worktree) against TWO FRESH +synthetic corpora that were never part of that search (seeds 1000/1097, vs. +the search's single fixed seed 0), showed that "winner" genome performing +**WORSE than the baseline on both**: + +| Judge corpus | Candidate WQL | Baseline WQL | Candidate beats baseline by | +|---|---:|---:|---:| +| seed 1000 | 0.155 | **0.099** | -0.056 (worse) | +| seed 1097 | 0.219 | **0.109** | -0.110 (worse) | + +**Root cause**: every evaluation in the three search rounds above (default, +round 1, round 2) trained and scored every candidate against the exact same +fixed synthetic corpus (`prepare-synthetic-dataset`'s implicit `--seed 0` +default). The search had learned to exploit that one corpus's specific +random windows -- textbook overfitting -- not found a genuinely better +hyperparameter configuration. This is a real, measured failure mode, not a +hypothetical caveat. + +**Fix applied**: `harness/ruview/flywheel/ruforecast/gate.mjs`'s +`evaluateGenome` now trains and scores every candidate against THREE +independent synthetic corpora (`DEFAULT_SEARCH_SEEDS = [11, 23, 47]`, none +of which overlap the retracted search's seed 0 or the verification seeds +1000/1097) and takes the WORST CASE `primary` across them, not an average -- +a candidate only counts as a win if it beats both baselines on every corpus. +See `harness/ruview/flywheel/ruforecast/README.md`'s "Multi-seed fitness" +section for the full account. + +**Re-running the search with the fix, still no verified improvement.** A +fresh search under the corrected multi-seed fitness (2 generations x 2 +children, cleared `.metaharness-numeric` archive so no stale pre-fix state +leaked in) found a genuine winner ON ITS OWN THREE SEARCH SEEDS -- +`learning_rate=0.008012, weight_decay=0.000159, gradient_clip_norm=0.267, +batch_size=24, epochs=20`, beating the baseline on all three (primary +0.099 / 0.556 / 0.576, worst-case 0.099). Independent verification against +the same fresh seeds 1000/1097 (never part of this candidate's own search) +again showed it losing to the baseline on both: + +| Judge corpus | Candidate WQL | Baseline WQL | Candidate beats baseline by | +|---|---:|---:|---:| +| seed 1000 | 0.178 | **0.099** | -0.079 (worse) | +| seed 1097 | 0.314 | **0.109** | -0.205 (worse) | + +**Honest conclusion**: two independent search rounds (pre- and post- the +multi-seed fitness fix) both produced a genome that looked like a real +improvement on its own search data and both failed independent out-of-sample +verification. This converges on a different, deeper explanation than "the +search methodology was broken" (that part IS fixed): at this dataset scale +(24 synthetic training windows), held-out WQL varies enormously by which +corpus is drawn REGARDLESS of hyperparameters -- the baseline genome itself, +evaluated with the corrected multi-seed fitness function, swings from +primary 0.83 (a strong win) to a full regression (primary 0, WQL worse than +last-value) purely from which of the three fixed search seeds was used, with +identical hyperparameters throughout. The corpus-noise floor at n=24 +windows appears to dominate any real hyperparameter effect. No RuForecast +hyperparameter configuration has been shown, by this exploration, to +reliably beat the trivial baselines out-of-sample at this scale. A larger +training corpus (more windows, ideally real governed data under the +`large_linux` profile) is the more promising next lever than further +hyperparameter search on this fixture. + +A related implementation-only fix: the promotion verifier +(`envelope::regression::verify_regression_promotion` in the Autogenous +branch above) initially required all judges' receipts to share one +`corpus_id`, inherited unreviewed from a same-evidence review model that +does not fit this kind's intentionally cross-corpus judge design. This was +corrected (`ReceiptCorpusMismatch` is no longer produced by that function); +it did not change either REJECT verdict above, both of which were already +correctly driven by the real `NotBetterThanParent` signal. + +Reproducer for both verification runs above: same as below, plus +`v2/crates/ruforecast-autogenous-bridge` (`cargo +1.92.0 run --manifest-path +crates/ruforecast-autogenous-bridge/Cargo.toml -- --ruforecast-bin +./target/debug/ruforecast --candidate-genome .json --parent-genome +.json --judges 2 --work-dir `) against the unpushed +`ruvnet/autogenous` branch `feat/regression-candidate-kind`. + +### Real-household-data result (2026-09-01, MEASURED, unaccepted, not a release claim) + +Following the informal HPO exploration above, this session also collected +**6,390 real 1 Hz vital-signs samples** (heart rate, breathing rate, +signal quality) from a real, live, ESP32-sourced household sensing +deployment over a continuous 2-hour window (88.75% real sample coverage; +gaps handled honestly via `observed_mask=0`, never fabricated +interpolation) — the "more real training data" lever flagged as the +credible next step in the note above. This directly answers that open +question. + +Two genuinely independent temporal splits of the same real corpus (not +synthetic seeds — real data has no seed to vary, so independence here +means two different train/test boundary choices on the same timeline, +each with its own 90s embargo gap) were trained (default, untuned +`OptimizerSpec`: `lr=0.001, weight_decay=0.0001, gradient_clip_norm=1.0, +batch_size=8, epochs=60`) and scored via the real `evaluate` CLI, then +independently, cryptographically verified through +`ruforecast-autogenous-bridge`'s real signed regression-candidate +promotion path (`ruvnet/autogenous`, `envelope::regression`): + +| Judge | Split | Real test windows | Model WQL | Best trivial baseline WQL | Model beats baseline by | +|---|---|---:|---:|---:|---:| +| 1 | 70% train / 90s embargo / 30% test | 27 | 0.0514 | 0.0563 (last-value) | +0.0049 | +| 2 | 50% train / 90s embargo / 50% test | 46 | 0.0670 | 0.0543 (seasonal-naive) | −0.0128 | + +**Signed verdict: REJECT.** Judge 1's nominal win (+0.0049) is below the +0.01 non-inferiority margin, so it doesn't clear the promotion bar even +on its own; Judge 2 lost outright. Both rejections are recorded as +`NotBetterThanParent` in the signed promotion envelope. + +**Honest conclusion:** even with a real household corpus (n=6,390 real +samples, not synthetic), the result is exactly the same shape as every +synthetic search this session — a result that looks like a win on one +split does not hold up on an independently verified second split. This +is not evidence that real data can't help; it is evidence that this +scale of real data (6,390 samples, one household, one physical sensor) +is not yet enough to distinguish a genuine effect from split-dependent +noise. The credible next lever remains more real data — more households, +longer collection windows, or the `large_linux` profile — not further +hyperparameter search on any fixture this small, synthetic or real. + +Reproducer: `v2/crates/ruforecast-autogenous-bridge/examples/real_data_verify.rs` +and `v2/crates/ruforecast/crates/ruforecast-train/examples/real_data_windows.rs` +in this worktree (`train/ruforecast-rust` branch, commits `130f547` in the +`ruforecast` submodule and `788401c5` in this repo — both local, not yet +pushed). Raw real vitals data never left the collecting/training hosts and +was never written to any git-tracked or pushed path. + +## Append-only evidence ledger + +Never replace a prior measurement. Append a row and retain the failed or stale +row when code, model, configuration, corpus, hardware, or methodology changes. + +| Date | Commit | Lock SHA-256 | Host/toolchain | Backend/config | Shape | Samples | p50 | p95 | p99/max | Peak RSS | Evidence | Reproducer | +|---|---|---|---|---|---|---:|---:|---:|---:|---:|---|---| + +No rows have been accepted. + +### Real public dataset: BIDMC PPG/Respiration (2026-09-02, cross-entity holdout) + +Every real-data test up to this point used a single household/entity with only +a **temporal** holdout (same physical sensor, different time windows). This +test is the first with a genuine **cross-entity** holdout: 53 real ICU +patients from the BIDMC PPG and Respiration Dataset (PhysioNet, Open Data +Commons Attribution License v1.0, public and openly licensed — no +credentialing, https://physionet.org/content/bidmc/1.0.0/), splitting by +*patient*, not by time, so the held-out test set contains real people the +model never saw during training. + +25,546 real 1 Hz rows across 53 recordings (~8 minutes each), heart rate + +respiratory rate + SpO2. 3 of 53 patients' windows were excluded honestly +(genuine sensor-dropout `NaN` values in the source recordings, not +fabricated/interpolated). Two independent, disjoint patient-partition splits: + +| Judge | Train patients | Test patients | Test windows | Model WQL | Best baseline WQL | Result | +|---|---:|---:|---:|---:|---:|---| +| A (contiguous split) | 34 | 16 | 16 | 0.01964 | 0.01159 (last-value) | worse, +69% | +| B (interleaved split) | 24 | 26 | 26 | 0.07022 | 0.01002 (last-value) | worse, +601% | + +**Same conclusion as every prior test this session**, now on real, public, +multi-subject clinical data with genuine cross-entity generalization: the +model does not beat trivial forecasting baselines. The margin is decisive on +both independent splits, not a near-miss — the earlier hypothesis that a +larger, genuinely diverse real dataset (many different people, not one +household) might change the picture does not hold at this scale/model +configuration either. + +**Honest scope note on verification**: prior real-data tests in this document +were independently checked through Autogenous's signed regression-candidate +promotion path. That additional cryptographic-signing step was **not** run +for this test — the result is reported directly from the `evaluate` CLI's +real output on two genuinely disjoint, real patient-holdout splits, which is +itself real, independent, out-of-sample evidence, but it does not carry a +signed promotion-gate verdict the way the earlier entries do. Flagging this +explicitly rather than presenting it with the same evidentiary weight. + +Reproducer: `v2/crates/ruforecast/crates/ruforecast-train/examples/bidmc_prepare.rs` +(untracked scratch example, worktree `train/ruforecast-rust`) — downloads +`bidmc_NN_Numerics.csv` for patients 01-53 directly from PhysioNet, builds +one 76-row (context 64 + horizon 12) window per eligible patient, and writes +governed `train.jsonl`/`test.jsonl`/`train-local.toml` per judge split. Raw +data cached at `/tmp/bidmc-raw/` on the training host only. diff --git a/docs/huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md b/docs/huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md new file mode 100644 index 000000000..13cee5924 --- /dev/null +++ b/docs/huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md @@ -0,0 +1,616 @@ +# RuView Forecast model card template + +> **Template only. Do not publish or treat this file as a model release.** Copy +> it for one immutable candidate, replace every `` field, and retain +> every unresolved item as an explicit blocker. Deleting a placeholder does not +> satisfy it. + +This template implements the model-card requirements in +[ADR-348](../adr/ADR-348-independent-rust-multivariate-forecasting.md) and the +[RuView Forecast clean-room protocol](../security/ruview-forecast-clean-room.md). +It does not imply that source, training, evaluation, clean-room, security, +privacy, trademark, patent, or production gates have passed. + +## Completion rules + +1. Create one card per exact weight digest. Do not reuse a card across retrains. +2. Label every numeric claim `MEASURED`, `SYNTHETIC`, `CLAIMED`, or + `UNMEASURED` under the repository evidence policy. +3. A `MEASURED` value needs an immutable dataset/split, artifact digest, + configuration, hardware/environment, metric definition, and reproducer. +4. Use `UNMEASURED` when evidence does not exist. Do not substitute an estimate. +5. State source-code, weight, dataset, and service terms separately. +6. Do not publish raw CSI, customer data, precise room coordinates, identities, + secrets, private contracts, or private contributor records in this card. +7. A completed card documents evidence; it cannot waive an ADR-348 gate. + +## Suggested Hugging Face metadata + +Copy and complete this front matter in the release card: + +```yaml +--- +license: +tags: + - time-series-forecasting + - multivariate-forecasting + - wifi-sensing + - rust + - ruvector + - probabilistic-forecasting + - edge-ai +language: + - en +library_name: burn +pipeline_tag: time-series-forecasting +--- +``` + +`license: other` is acceptable only when the card links the complete exact +license. Do not infer the weight license from the Rust source license. + +## Candidate identity + +Replace the template title with `` in the +release copy. + +## Release decision and evidence status + +| Field | Required value | +|---|---| +| Release candidate ID | `` | +| Weight SHA-256 | `` | +| Git commit | `` | +| Model-card SHA-256 | `` | +| Proposed mode | `OFFLINE_EVAL`, `SHADOW`, or `ADVISORY`; `` | +| ADR-348 highest gate passed | `G0` through `G5`, or `NONE`; `` | +| Accuracy status | `UNMEASURED` until a qualifying report exists; `` | +| Calibration status | `UNMEASURED` until a qualifying report exists; `` | +| CPU latency status | `UNMEASURED` until a named CPU reproducer exists; `` | +| Memory status | `UNMEASURED` until a named runtime reproducer exists; `` | +| Cross-site generalization status | `UNMEASURED` until untouched site holdouts exist; `` | +| Clean-room status | `OPEN`, `PASS`, or `FAIL`; `` | +| Production approval | `NOT APPROVED` unless all G5 receipts are signed; `` | + +### Release summary + +`` + +## Model details + +| Field | Value | +|---|---| +| Developer/owner | `` | +| Model family | `RuView Forecast` | +| Model version | `` | +| Artifact format | `` | +| Parameter count | `` | +| Numeric precision | `` | +| Architecture/config digest | `` | +| Input schema version | `` | +| Output schema version | `` | +| Maximum context | `` | +| Supported horizons | `` | +| Declared quantiles | `` | +| Training framework | `` | +| Inference runtime | `` | +| Source license | `` | +| Weight license | `` | + +### Rust implementation boundary + +Describe the exact code surfaces used by this candidate: + +| Crate or binary | Version/digest | Responsibility | Enabled features | +|---|---|---|---| +| `ruview-forecast-core` | `` | Backend-neutral schemas, invariants, metrics, receipts, and `Forecaster` trait | `` | +| `ruview-forecast-model` | `` | Independent Burn 0.21 patch mixer and artifact execution | `` | +| `ruview-forecast-train` / `ruforecast` | `` | Dataset splits, trainer, evaluator, training receipt, and Linux/fal.ai assets | `` | +| `` | `` | `` | `` | + +State whether inference is offline and whether any runtime network capability +exists. The expected production answer is no runtime model download and no +network requirement: + +`` + +State whether a sensing-server bridge exists. For the initial ADR-348 PR the +required answer is `NO: contracts/training only; a separately reviewed shadow +bridge is deferred`: + +`` + +## Intended use + +### Approved candidate use + +`` + +### Out-of-scope and prohibited use + +This artifact must not be used unless a later approved card explicitly changes +the boundary: + +- as a sensor observation or ground-truth label; +- as the sole source for medical, emergency, fall-response, industrial-safety, + access-control, policing, insurance, employment, or autonomous-actuation + decisions; +- to infer identity or protected characteristics; +- outside the feature schema, cadence, hardware, site, population, and horizon + validated by this card; +- after calibration, feature schema, source provenance, or OOD checks fail; +- to train another model unless the weight, dataset, and output licenses and a + new provenance review explicitly permit it; +- to claim TimesFM compatibility, affiliation, endorsement, or equivalence. + +Add candidate-specific exclusions: + +`` + +## Functional architecture + +Describe independently implemented components without reproducing external +source expression, diagrams, identifiers, or constants: + +```text + +``` + +| Component | Candidate implementation | Security/resource bound | +|---|---|---| +| Feature validation | `` | `` | +| Normalization | `` | `` | +| Temporal encoding/mixing | `` | `` | +| Cross-stream fusion | `` | `` | +| Missing-data handling | `` | `` | +| Point/quantile head | `` | `` | +| Quantile crossing policy | `` | `` | +| OOD/abstention | `` | `` | +| RuVector retrieval | `` | `` | +| Artifact verification | `` | `` | + +## Input contract + +### Feature schema + +| Field | Unit | Cadence/aggregation | Range | Missing/invalid semantics | Source provenance | +|---|---|---|---|---|---| +| `` | `` | `` | `` | `` | `` | + +State: + +- minimum and maximum context length; +- maximum targets, covariates, horizon, and batch; +- timestamp monotonicity and duplicate policy; +- whether any future-known covariate is permitted and how its authority is + verified; +- finite/range checks and allocation limits; +- minimum validity coverage before abstention; +- treatment of cadence gaps, resets, device changes, and calibration changes; +- OOD input behaviour. + +`` + +### Data not accepted + +`` + +## Output contract + +| Output | Shape/unit | Meaning | Evidence class | +|---|---|---|---| +| Point forecast | `` | `` | `DERIVED_FORECAST` | +| Quantile forecast | `` | `` | `DERIVED_FORECAST` | +| Validity mask | `` | `` | `DERIVED_METADATA` | +| Abstention/disposition | `` | `` | `DERIVED_METADATA` | +| OOD score/state | `` | `` | `DERIVED_METADATA` | +| Receipt/provenance | `` | `` | `SIGNED_METADATA` if signed | + +The output must bind model, configuration, input schema, calibration, source +time range, optional retrieval index, and content hashes. Explain whether and +how quantile crossing is corrected: + +`` + +## RuVector retrieval + +| Field | Value | +|---|---| +| Enabled | `` | +| Index artifact/version/digest | `` | +| Allowed retrieval corpus | `` | +| Split-isolation receipt | `` | +| Neighbour exclusion rules | `` | +| Maximum neighbours/search budget | `` | +| Behaviour when index is absent/stale | `` | + +Report identical-dataset ablations for no retrieval and retrieval. If these do +not exist, state `UNMEASURED`. + +## RuVLLM integration + +State whether RuVLLM consumes this artifact's signed forecast records. It may +explain results but must not rewrite point/quantile values or acquire action +authority from prose. + +| Control | Evidence | +|---|---| +| Numeric outputs originate only from signed forecast record | `` | +| Explanation cites artifact and forecast receipt | `` | +| No model promotion, spending, actuation, or safety-critical authority | `` | +| Prompt/data retention and tenant policy | `` | + +## Independent-development record + +| Record | Digest/approval | +|---|---| +| Approved source allowlist | `` | +| Specification digest | `` | +| Contributor exposure manifest | `` | +| Contributor attestation bundle | `` | +| AI-tool prompt/session manifest | `` | +| Prohibited-artifact scan | `` | +| Source-similarity review | `` | +| Clean-room custodian approval | `` | +| Legal release approval | `` | + +Declaration: + +`` + +Prior exposure disclosures and dispositions, without private details: + +`` + +## Training data + +Do not list a dataset until its licensing/privacy gate passes. + +| Dataset ID/version | Role | Origin/owner | License or contract ID | Commercial ML | Privacy class | Records/windows | Source/transform/split digests | +|---|---|---|---|---|---|---:|---| +| `` | train/validation/calibration | `` | `` | `YES` required | `` | `` | `` | + +### Excluded data + +Explicitly state that TimesFM 3 weights, outputs, activations, generated labels, +and derivatives were excluded, then list all other exclusions: + +`` + +### Collection, consent, minimization, and retention + +`` + +### Synthetic data + +| Generator/version | Seed-data rights | Provider/output terms | Seeds/config digest | Proportion | Use | +|---|---|---|---|---:|---| +| `` | `` | `` | `` | `` | `` | + +## Split and leakage protocol + +| Split | Sites | Subjects | Sessions | Devices | Time blocks | Windows | Manifest digest | +|---|---:|---:|---:|---:|---:|---:|---| +| Train | `` | `` | `` | `` | `` | `` | `` | +| Validation | `` | `` | `` | `` | `` | `` | `` | +| Calibration | `` | `` | `` | `` | `` | `` | `` | +| Test | `` | `` | `` | `` | `` | `` | `` | + +Required leakage checks: + +- parent raw record and derived-window overlap; +- contiguous sequence/session overlap; +- subject/site/device/calibration leakage; +- target-horizon overlap; +- fitted preprocessing or threshold leakage; +- RuVector neighbour/index leakage; +- duplicate and near-duplicate leakage; +- test-informed architecture or hyperparameter changes. + +Report and digest: + +`` + +## Training procedure + +| Field | Value | +|---|---| +| Random initialization | `true` required for this model family | +| Parent checkpoint | `NONE` or approved RuView artifact and digest | +| Source commit | `` | +| Cargo.lock digest | `` | +| Rust toolchain | `` | +| Build/training container | `` | +| Training config digest | `` | +| Seeds | `` | +| Optimizer/schedule/loss | `` | +| Batch/epochs/stopping | `` | +| Hardware | `` | +| Provider | `` | +| Provider job ID | `` | +| Wall time | `` | +| Peak host/GPU memory | `` | +| Estimated/actual cost | `` | +| Emitted checkpoint digests | `` | +| Training receipt digest | `` | + +If local and hosted runs are compared, list numerical-determinism differences +and prove that governed code, data, config, and parent identities match: + +`` + +## Evaluation + +### Baselines and ablations + +Every row uses the same frozen examples and metric implementation. + +| Model | Artifact/version | Retrieval | Parameters | Evidence label | Notes | +|---|---|---|---:|---|---| +| Last value | `` | no | n/a | `` | deterministic baseline | +| Seasonal naive | `` | no | n/a | `` | `` | +| Small classical/recurrent baseline | `` | no | `` | `` | `` | +| RuView Forecast | `` | no | `` | `` | required ablation | +| RuView Forecast | `` | yes | `` | `` | required if retrieval is enabled | + +Do not use TimesFM 3 as an implementation oracle, label source, tuning signal, +or required acceptance baseline. + +### Forecast metrics + +Report per horizon and per site/device/interference regime, plus pooled values. + +| Metric/domain/horizon | Result | 95% interval | Evidence label | Reproducer | +|---|---:|---:|---|---| +| Weighted quantile loss | `` | `` | `` | `` | +| MAE or scaled error | `` | `` | `` | `` | +| Nominal 80% interval coverage | `` | `` | `` | `` | +| Interval width | `` | `` | `` | `` | +| Quantile crossing before/after policy | `` | `` | `` | `` | +| Abstention coverage/selective risk | `` | `` | `` | `` | + +ADR-348 G3 targets are at least 10% weighted-quantile-loss improvement over +seasonal naive and 75%-85% measured coverage for a nominal 80% interval. These +remain `UNMEASURED targets` until populated by qualifying evidence. + +### RuView shadow outcomes + +| Metric | Baseline | Candidate | Delta/CI | Evidence label | Reproducer | +|---|---:|---:|---:|---|---| +| Empty-room false alerts | `` | `` | `` | `` | `` | +| Occupied-room recall | `` | `` | `` | `` | `` | +| Abstention by deployment | `` | `` | `` | `` | `` | +| Drift/OOD rate | `` | `` | `` | `` | `` | + +ADR-348 G4 targets at least 50% relative empty-room false-alert reduction with +no more than 2 percentage points occupied-room recall loss over at least 14 +shadow days. These are `UNMEASURED targets`, not current capabilities. + +### Runtime measurements + +| Platform | Build/features | Batch/streams | Context/horizon | p50/p95/p99 | Peak RSS | Evidence label | Reproducer | +|---|---|---:|---|---|---:|---|---| +| `` | `` | `` | `` | `` | `` | `` | `` | +| `` | `` | `` | `` | `` | `` | `` | `` | + +ADR-348 G5 CPU targets are at most 1 second p95 for 32 declared streams and at +most 4 GiB peak process memory. They are `UNMEASURED targets` until this table +contains a qualifying named-platform reproducer. + +## Calibration, OOD, and abstention + +| Control | Fit data | Frozen parameters/digest | Test result | Evidence label | +|---|---|---|---|---| +| Input normalization | `` | `` | `` | `` | +| Quantile calibration | `` | `` | `` | `` | +| OOD threshold | `` | `` | `` | `` | +| Minimum validity/context | `` | `` | `` | `` | +| Drift threshold | `` | `` | `` | `` | + +List all typed abstention reasons and demonstrate that unknown schema, missing +calibration, stale data, insufficient history, invalid masks, non-finite input, +OOD state, and artifact failure do not produce an authoritative forecast: + +`` + +## Robustness and failure analysis + +Report at minimum: + +- unseen site and unseen device; +- empty room and low-motion occupancy; +- burst loss, cadence change, clock reset, and sensor restart; +- interference, channel change, and calibration drift; +- long missing runs and adversarial non-finite/range inputs; +- corrupted/truncated/oversized model and input artifacts; +- absent or stale RuVector index; +- concurrent-load resource caps and timeout; +- false confidence from narrow intervals during distribution shift. + +| Scenario | Expected safe behaviour | Observed result | Evidence label | Open risk/owner | +|---|---|---|---|---| +| `` | `` | `` | `` | `` | + +## Security and privacy + +### Threat model summary + +| Threat | Control | Evidence | Residual risk/owner | +|---|---|---|---| +| Malicious/corrupt model artifact | Signature, digest, schema and size verification; no embedded code | `` | `` | +| Oversized/malformed request | Dimension, allocation, horizon, context, batch and deadline caps | `` | `` | +| Poisoned data or split leakage | Approved manifests, immutable transforms, lineage and overlap checks | `` | `` | +| Cross-tenant analogue retrieval | Tenant and split-scoped RuVector indexes | `` | `` | +| Routine/location inference | Minimization, purpose, retention, deletion, access and audit | `` | `` | +| Model extraction/membership inference | Rate/access control and privacy evaluation | `` | `` | +| Hosted-worker data retention | Approved provider terms, least data, deletion receipt | `` | `` | +| LLM numeric mutation or overclaim | Signed numeric record and capability policy | `` | `` | + +### Data handling + +`` + +## Deployment + +| Field | Value | +|---|---| +| Supported mode | `` | +| Supported platform/CPU/GPU | `` | +| Minimum RAM/storage | `` | +| Offline artifact source | `` | +| Signature/trust root | `` | +| Configuration/calibration artifact | `` | +| RuVector index requirement | `` | +| Startup self-test | `` | +| Health/drift metrics | `` | +| Timeout/backpressure policy | `` | +| Rollback artifact/mode | `` | + +No deployment may fetch an unpinned model at runtime. Describe exact startup +failure and fail-closed behaviour: + +`` + +## Monitoring, rollback, and retirement + +| Signal | Threshold | Window | Response | Owner | +|---|---:|---|---|---| +| Calibration/coverage drift | `` | `` | shadow/off | `` | +| OOD/abstention rate | `` | `` | `` | `` | +| Latency/resource regression | `` | `` | `` | `` | +| Empty/occupied outcome regression | `` | `` | `` | `` | +| Provenance/signature failure | any | immediate | disable artifact | `` | +| Security/privacy incident | any material incident | immediate | isolate, preserve evidence, disable | `` | + +Rollback drill receipt and result: + +`` + +Retirement and data/index/checkpoint deletion policy: + +`` + +## Limitations and largest uncertainty + +`` + +## Cost, energy, and operational burden + +| Item | Value | Evidence label | Method | +|---|---:|---|---| +| Training accelerator hours | `` | `` | `` | +| Training cost | `` | `` | `` | +| Training energy/emissions | `` | `` | `` | +| CPU inference cost/energy | `` | `` | `` | +| Storage/index overhead | `` | `` | `` | +| Operator/calibration burden | `` | `` | `` | + +## Licenses and notices + +| Surface | Exact license/terms | URL/file | Obligations | Approval | +|---|---|---|---|---| +| Rust source | `` | `` | `` | `` | +| Weights | `` | `` | `` | `` | +| Each dataset | `` | `` | `` | `` | +| Dependencies/SBOM | `` | `` | `` | `` | +| Training provider | `` | `` | `` | `` | + +Trademark clearance ID and scope: + +`` + +Patent freedom-to-operate disposition and launch jurisdictions: + +`` + +## Provenance and reproducibility + +| Artifact | Digest/signature/location | +|---|---| +| Source allowlist | `` | +| Specification | `` | +| Source commit | `` | +| Cargo.lock | `` | +| SBOM | `` | +| Dataset manifest | `` | +| Split/leakage report | `` | +| Training config | `` | +| Training receipt | `` | +| Evaluation report | `` | +| Clean-room report | `` | +| Model weights | `` | +| RVF signature | `` | +| Reproducer | `` | + +Reproduction commands must use pinned artifacts and must not contain secrets or +download restricted material: + +```bash + +``` + +## ADR-348 gate traceability + +| Gate | Requirements | Evidence in this card/bundle | Result | +|---|---|---|---| +| G0 independent-authoring boundary | RF-001, RF-002 | allowlist, exposure, attestation, scans, similarity review | `` | +| G1 data and lineage | RF-002, RF-003, RF-010 | dataset approvals, lineage, local/hosted receipts | `` | +| G2 bounded Rust contract | RF-004, RF-005, RF-006 | contract/fuzz/replay/resource/dependency reports | `` | +| G3 leakage-free model evidence | RF-007, RF-008, RF-011 | frozen split, baselines, ablations, calibration, reproducers | `` | +| G4 RuView shadow value | RF-006, RF-007, RF-008, RF-009 | 14-day outcomes, drift, abstention, no action authority | `` | +| G5 deployment fitness | RF-004, RF-009, RF-010, RF-011, RF-012 | runtime, approvals, signed bundle, rollback | `` | + +Highest permitted deployment mode from these results: + +`` + +## Approvals + +Signatures cover this exact model-card digest and weight digest. + +| Role | Approver/receipt | Decision | Timestamp | +|---|---|---|---| +| Model owner | `` | `` | `` | +| Clean-room custodian | `` | `` | `` | +| Data steward/privacy | `` | `` | `` | +| Security owner | `` | `` | `` | +| Runtime owner | `` | `` | `` | +| Legal release owner | `` | `` | `` | + +## Citation + +Use the final public model name, exact version, weight digest, repository commit, +and model-card URL: + +```bibtex +@software{, + title = {}, + author = {}, + year = {}, + version = {}, + url = {}, + note = {Weights SHA-256: ; source commit: } +} +``` + +## Change history + +| Card version | Weight digest | Change | Gate impact | Date | +|---|---|---|---|---| +| `` | `` | Initial candidate | All gates evaluated independently | `` | diff --git a/docs/releases/v0.8.8-esp32.md b/docs/releases/v0.8.8-esp32.md new file mode 100644 index 000000000..772879028 --- /dev/null +++ b/docs/releases/v0.8.8-esp32.md @@ -0,0 +1,111 @@ +# RuView ESP32 firmware 0.8.8 + +Firmware 0.8.8 is a reliability and correctness release for ESP32-S3 and +ESP32-C6 RuView nodes. It makes the timing used by signal processing explicit, +prevents contradictory occupancy output, and improves update diagnostics. + +## What changed + +### Empty means zero people + +Older firmware could report `presence=false` and a nonzero person count in the +same edge packet. That was internally contradictory and could contaminate an +empty-room calibration. Firmware 0.8.8 clears the count whenever the presence +gate is closed. The sensing server repeats the same check when it receives data +from older nodes. + +This is a consistency fix, not proof that the heuristic can count multiple +people accurately. See +[ADR 346](../adr/ADR-346-fail-closed-edge-occupancy-evidence.md). + +### Stable time scale on ESP32-C6 + +Raw CSI and on-device signal processing now have separate clocks. The C6 keeps +raw CSI moving over the network while its Tier 2 filters process a stable 8 Hz +sample stream. The S3 retains its 20 Hz DSP default. A phase-preserving sampler +keeps callback jitter from shifting those clocks. + +The result is a correct time base for motion and vital-band features. It does +not by itself prove that heartbeat, respiration, gesture, or pose estimates are +more accurate. See +[ADR 347](../adr/ADR-347-rate-aware-esp32-temporal-sensing.md). + +### Better diagnostics and safer updates + +The one-second controller log now shows both raw callback yield and DSP rate. +The OTA status endpoint reports the actual selected application partition size +instead of a fixed 900 KB assumption. Firmware upload remains fail closed when +the node has no provisioned OTA signing secret. + +## Measured hardware validation + +All results below are physical measurements from 2026-08-31. They are not +simulator claims. + +| Board | Duration | Raw CSI mean | DSP clock | Live coverage | Steady-state transport errors | +|-------|---------:|-------------:|----------:|--------------:|------------------------------:| +| ESP32-C6 node 4 | 300.64 s | 34.92 pps | 8.00 Hz | 97.62% | 0 | +| ESP32-C6 node 7 | 300.70 s | 36.32 pps | 8.00 Hz | 97.40% | 0 | +| ESP32-S3 node 1 | 300 s | 28.03 pps | Tier 0 | 100.00% | 0 | + +The first C6 empty-room qualification observed 61 absent packets with zero +nonzero counts. The second C6 was transport-qualified in an occupied room and +still needs its own controlled empty-room sequence. Full evidence is recorded +in: + +1. [C6 timing and transport](../validation/2026-08-31-esp32-c6-rate-aware-sensing.md) +2. [C6 occupancy integrity](../validation/2026-08-31-esp32-c6-occupancy-integrity.md) +3. [Second C6 timing and transport](../validation/2026-08-31-esp32-c6-node7-rate-aware-sensing.md) +4. [S3 transport](../validation/2026-08-31-esp32-s3-rate-aware-transport.md) + +## Choose the correct download + +| Release file | Target | +|--------------|--------| +| `esp32-csi-node-v0.8.8-s3-8mb-flash-bundle.zip` | ESP32-S3 with 8 MB flash | +| `esp32-csi-node-v0.8.8-s3-4mb-flash-bundle.zip` | ESP32-S3 with 4 MB flash | +| `esp32-csi-node-v0.8.8-c6-4mb-flash-bundle.zip` | ESP32-C6 using the supported 4 MB partition layout | +| `esp32-csi-node-v0.8.8-s3-8mb.bin` | S3 8 MB application only | +| `esp32-csi-node-v0.8.8-s3-4mb.bin` | S3 4 MB application only | +| `esp32-csi-node-v0.8.8-c6-4mb.bin` | C6 application only | + +Never mix S3 and C6 images. Confirm the chip and physical flash before writing. + +## Install or update + +For a fresh installation, extract the matching bundle and follow its included +`FLASHING.md`. The standard offsets are: + +| Image | Offset | +|-------|-------:| +| Bootloader | `0x0000` | +| Partition table | `0x8000` | +| OTA metadata | `0xf000` | +| Application | `0x20000` | + +For an existing provisioned node: + +1. Back up the current application partition. +2. Confirm the exact chip, flash layout, logical node, and serial port. +3. Read `http://DEVICE_IP:8032/ota/status`. +4. Use an application-only serial update at `0x20000` only when the running + partition is `ota_0` and the image matches the board. +5. Reboot and confirm version 0.8.8, the preserved node identity, channel, and + sensing-server target. +6. Run a five-minute burn-in before returning the node to calibration duty. + +The full bundle does not contain an NVS image. A four-offset install therefore +preserves the existing WiFi and node settings, but operators should still keep +a backup before changing firmware. + +## What this release does not prove + +Firmware 0.8.8 does not prove medical-grade vital signs, accurate person +counting, identity, dense pose, through-wall video, or room separation. Those +claims require synchronized references and leakage-free held-out sequences. + +The practical next acceptance test is a controlled empty-room capture with at +least 30 absent edge packets per updated node, zero absent packets carrying a +nonzero count, and zero transport or parser errors. Accuracy evaluation then +needs held-out occupied, movement, heartbeat-reference, and adjacent-room +sequences. diff --git a/docs/security/ruview-forecast-clean-room.md b/docs/security/ruview-forecast-clean-room.md new file mode 100644 index 000000000..4e6d5bee0 --- /dev/null +++ b/docs/security/ruview-forecast-clean-room.md @@ -0,0 +1,511 @@ +# RuView Forecast clean-room, data, and release protocol + +**Status:** Proposed and mandatory for any artifact claiming independent +development under ADR-348. + +**Evidence status:** This document defines controls. It does not assert that the +controls have passed, that a trained model exists, or that any forecasting +capability is measured. Every ADR-348 gate remains **OPEN / UNMEASURED** until a +signed evidence bundle proves otherwise. + +## 1. Purpose and authority + +This protocol governs the specification, implementation, training, evaluation, +hosting, and release of the independent Rust multivariate forecaster defined by +[ADR-348](../adr/ADR-348-independent-rust-multivariate-forecasting.md). +It applies to source, issue and review text, prompts, dependencies, datasets, +intermediate tensors, checkpoints, containers, CI caches, RuVector indexes, +fal.ai jobs, benchmark results, model cards, and published artifacts. + +The protocol is intentionally stricter than the minimum conditions for using +Apache-licensed source. Its purpose is to preserve evidence that the RuView +implementation and weights were independently created. It is not a legal +opinion. The legal release owner must approve the current licenses, contributor +exposure record, patent review, trademark review, datasets, and intended launch +jurisdictions before production. + +## 2. License boundary as of 2026-09-01 + +The authoritative public materials state two different license surfaces: + +1. The [Google TimesFM repository](https://github.com/google-research/timesfm) + identifies its source code as Apache-2.0. +2. The [TimesFM 3 checkpoint license](https://huggingface.co/google/timesfm-3.0-pytorch/blob/main/LICENSE) + permits non-commercial, non-production use and restricts commercial use, + distribution, and use to train, fine-tune, or distill another commercial + model. + +The checkpoint license defines a derivative broadly enough that relying on its +logic, parameters, or model-generated material creates avoidable contractual +risk. No contributor may accept gated model terms on behalf of the project or +its owner without written authorization. + +The [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0) permits +source reproduction and derivative works subject to its conditions, including +license delivery, change notices, retention of applicable notices, and NOTICE +handling. Apache also provides a limited contributor patent grant and no general +trademark grant. If maintainers ever choose an Apache-derived port, it must use +a separate branch, attribution path, artifact name, and superseding ADR. It +must not be described as the clean-room implementation. + +Publicly described ideas, procedures, processes, systems, and methods are not +the same as copied expression. United States copyright law states the +idea-expression boundary in [17 U.S.C. section 102(b)](https://www.copyright.gov/title17/92chap1.html). +That does not settle contract, patent, trademark, database-right, trade-secret, +or non-U.S. law questions. + +## 3. Non-mixing rule + +Only one of these paths may govern a source tree: + +| Path | Source basis | Required representation | +|---|---|---| +| Independent implementation | Approved abstract specification, public papers/articles, general literature, RuView requirements, approved data | "Independently developed and independently trained" after all gates pass | +| Apache-derived port | Apache-licensed Google source with full license and notice compliance | "Apache-derived Rust port" with attribution | + +There is no hybrid path. A permissive source license makes porting possible; it +does not make a port independent. If exposure cannot be bounded, maintainers +must either quarantine and rewrite the affected component or reclassify the +whole affected component under the Apache-derived path. + +## 4. Roles and separation + +| Role | May access | Must not access or do | +|---|---|---| +| Clean-room custodian | Exposure records, allowlist, scan reports, quarantined evidence | Contribute core model implementation after reviewing prohibited source fragments | +| Specification reviewer | Frozen public-paper/article allowlist, RuView requirements, generic literature | Google TimesFM implementation, tests, configs, checkpoint files, model outputs; contribute implementation | +| Rust implementer | Approved independent specification, RuView schemas, approved generic dependencies | TimesFM source, tests, configs, weights, outputs, line-by-line/API translation, reference differential testing | +| Data steward | Dataset source, contracts, consent, privacy and transformation records | Approve unknown or incompatible rights; infer that a software license covers data | +| Training operator | Signed code/container/config and approved dataset manifests | Change code/data/config outside a new receipt; add an unapproved parent checkpoint | +| Independent evaluator | Frozen candidate, frozen evaluation manifests, approved baselines | Return reference predictions or implementation hints to developers; tune against the test set | +| Legal release owner | Complete evidence bundle and private legal analysis | Treat clean-room checks as patent clearance or dataset approval by implication | + +One person may hold multiple roles only where the forbidden-access rules remain +true. The strongest separation uses different people for public specification +and implementation. A specification reviewer who has inspected prohibited +source cannot become a core implementer for the independent path. + +## 5. Source classification + +### 5.1 Allowed + +- The public TimesFM research articles and peer-reviewed papers, frozen by URL, + retrieval date, and SHA-256 digest. +- General time-series, transformer, probabilistic forecasting, normalization, + missing-data, and calibration literature. +- RuView requirements, independently written feature schemas, and existing + RuView contracts whose provenance already satisfies repository policy. +- Generic Rust dependencies whose license, source, and purpose are recorded and + approved. +- Public benchmark specifications and datasets after the dataset gate passes. +- Published aggregate competitor results used only as contextual background. + +### 5.2 Conditional + +- Code implementing generic algorithms may be used only after license review + and must be declared as a dependency or attributed source. It cannot be used + to claim that every line was independently authored. +- TimesFM versions up to 2.5 may have permissively licensed code or weights, but + they remain outside the independent implementation and training path. An + evaluator may use an approved permissive baseline only after the candidate + is frozen, and its output cannot become training data or implementation + feedback. +- A contributor with prior exposure to TimesFM code must disclose the exact + material and date. The custodian and legal owner decide whether the person is + limited to non-implementation work or the affected component must be + reclassified. +- Synthetic data is allowed only when source data, generator code/model, + provider terms, output rights, and intended commercial use all pass review. + +### 5.3 Prohibited + +- TimesFM 3 weights, parameters, checkpoints, tensor dumps, checkpoint + configuration, hidden activations, embeddings, gradients, or derived + fingerprints. +- TimesFM 3 forecasts, quantiles, scores, probabilities, recommendations, or + synthetic labels in development, training, tuning, or release evidence. +- Fine-tuning, distillation, imitation, teacher-student learning, differential + testing, behavioural cloning, or hyperparameter search against TimesFM 3. +- Manual, mechanical, or AI-assisted translation of Google source, tests, + comments, constants, APIs, file layout, or diagrams. +- Third-party code, datasets, or checkpoints whose origin may contain copied or + distilled TimesFM material without documented permission. +- Model, dataset, or service terms labelled non-commercial, research-only, + evaluation-only, no-derivatives, no-ML, no-production, or unknown. +- Confidential third-party information or material obtained under an agreement + that does not authorize this implementation and commercial use. + +## 6. Exposure intake and contributor attestation + +Before receiving implementation access, every contributor completes an +exposure intake covering: + +- TimesFM repositories, model cards, gated checkpoint files, local caches, + notebooks, issues, tutorials, and generated outputs viewed or downloaded; +- whether gated terms were accepted and for which individual or entity; +- source or output material placed in an LLM, IDE assistant, retrieval index, + prompt, or code-generation session; +- prior work for Google, a competitor, customer, or other party involving + confidential forecasting implementation; +- datasets, pretrained models, and external code expected in the contribution. + +Each pull request also carries a signed attestation: + +```text +RuView Forecast clean-room attestation v1 + +Contributor: +Employer or represented entity: +Role: +Covered commits: +Prior TimesFM exposure: NONE or complete disclosure +Approved sources used: +Datasets introduced: +AI tools used: +Prompt/session manifest digest: + +I attest that the covered contribution was created from the approved +specification and listed sources. I did not access, copy, translate, decompile, +query, distill, or use TimesFM 3 implementation material, configuration, +weights, parameters, outputs, or internal representations. I did not place +prohibited material in an AI tool context. I disclosed all contrary facts +above. Every dependency and dataset I introduced has documented rights for its +stated use. + +Signature: +Timestamp: +``` + +A DCO `Signed-off-by` line does not replace this attestation. The attestation +digest, not private identity material, is referenced from the public release +manifest. + +## 7. Specification controls + +The custodian freezes an allowlist before implementation begins. Every entry +records title, canonical URL or DOI, publication date, retrieval time, content +digest, license or access terms, reviewer, and the abstract requirement it +supports. + +The specification may state mathematical functions, tensor roles, input/output +invariants, resource bounds, and RuView-specific requirements. It must not +include copied source, comments, tests, distinctive identifiers, constants, +checkpoint dimensions extracted from files, API compatibility requirements, or +Google diagrams. RuView API names must be derived from the local domain. + +Changes to the specification after test evaluation receive a new version and +must not encode held-out answers. A material architecture revision consumes a +new untouched holdout or remains labelled exploratory. + +## 8. AI coding-tool controls + +An AI assistant can unintentionally defeat source separation by retrieving or +reproducing reference code. For sessions contributing implementation: + +1. Disable web retrieval and repository indexing outside the approved local + tree where the tool supports it. +2. If a user describes the goal as a port, clone, reproduction, or emulation, + stop and restate the task as independently authored RuView functional + requirements. Record that wording in the exposure manifest; do not use it + as permission to retrieve reference implementation material or pursue + behavioral equivalence. +3. Do not attach Google code, checkpoint metadata, outputs, screenshots, or + detailed third-party implementation summaries. +4. State the independent specification and prohibited-source boundary in the + session instructions. +5. Retain a bounded prompt and tool-source manifest digest without committing + raw private transcripts. +6. Record the model/tool version and whether retrieval was enabled. +7. Treat unexplained code that resembles a prohibited implementation as an + incident, not as a harmless generated suggestion. + +AI-generated code receives the same authorship, license, security, and +similarity review as human-authored code. + +## 9. Dataset licensing and privacy gate + +No dataset enters preprocessing, a RuVector index, training, calibration, or +evaluation until a data steward records affirmative answers to every applicable +gate: + +| Gate | Required evidence | Automatic rejection examples | +|---|---|---| +| Origin | Named owner/provider, acquisition method, immutable source digest | Scrape or file of unknown origin | +| Training rights | Written right to use for ML training and the intended commercial purpose | Research-only, NC, no-ML, evaluation-only | +| Derivatives | Right to transform, derive windows/features, and create model artifacts | No-derivatives or ambiguous custom terms | +| Redistribution | Whether raw, transformed, manifests, and weights may be redistributed separately | Assumption that public access means redistribution | +| Attribution | Exact attribution and notice obligations | Missing author/source/version | +| Database rights | Jurisdiction and database-right review where applicable | Unreviewed EU database extraction | +| Consent and contract | Collection authority, participant/customer consent, purpose, controller/processor roles | Customer telemetry without explicit model-training authority | +| Sensitive inference | Classification of occupancy, routines, location, health-adjacent and biometric implications | Unbounded identity or health inference | +| Minimization | Required fields only, pseudonymization, retention and deletion schedule | Raw CSI/person identifiers retained without need | +| Split integrity | Site/subject/session/device/time-block lineage and overlap report | Contiguous or derived-window leakage across splits | +| Synthetic lineage | Generator, seed data, service terms, output rights, seeds/config digest | Restricted teacher or prohibited source data | + +Typical disposition guidance: + +- Owned data, CC0/public-domain data, and data under an explicit commercial ML + contract may pass after privacy and provenance review. +- CC BY data requires attribution plus database and downstream-weight review. +- MIT and Apache are software licenses and do not automatically license nearby + data. +- NC, ND, research-only, evaluation-only, no-ML, no-production, and unknown + terms fail unless a separate written commercial grant is obtained. + +Every transform is content-addressed. Normalizers, feature selectors, +calibrators, thresholds, and RuVector indexes fit training data only. Raw +sequence overlap and derived-window overlap are both checked. A parent record +in one split makes all overlapping descendants ineligible for another split. + +## 10. Hosted training boundary + +The Linux machine and fal.ai are execution environments, not sources of +authority. A hosted job is accepted only when: + +- the exact source commit, Cargo lockfile, compiler, container digest, data + manifests, configuration, seeds, and parent checkpoint digest are signed + before upload; +- provider terms, retention, region, subprocessors, logs, cache deletion, + confidentiality, output ownership, and provider-training reuse have been + reviewed for the data classification; +- credentials are short-lived, least-privilege, excluded from images and logs, + and rotated after suspected exposure; +- customer-derived data is not uploaded before controller/processor and + transfer requirements pass; +- emitted checkpoints and logs are hashed immediately and compared with the job + receipt; +- the downloaded artifact is scanned before entering the trusted release + boundary; +- a hosted provider cannot promote, sign, or activate a model. + +Hosted and local runs are comparable only when their governed input identities +match. Numerical nondeterminism must be documented; it does not permit an +unrecorded dependency, data, or configuration change. + +## 11. Provenance manifest + +The release bundle contains a machine-readable manifest with at least: + +```yaml +schema_version: +artifact: + id: + version: + git_commit: + source_spec_digest: + cargo_lock_digest: + rust_toolchain: + target_triple: + build_container_digest: + sbom_digest: + +contributors: + - pseudonymous_id: + role: + exposure_class: + attestation_digest: + signed_at: + +references: + - title: + canonical_uri: <url-or-doi> + publication_date: <date> + retrieved_at: <rfc3339> + content_digest: <sha256> + license_or_terms: <identifier> + allowed_use: <purpose> + reviewer: <id> + +datasets: + - dataset_id: <id> + version: <version> + owner: <owner> + origin: <uri-or-contract-id> + license_or_contract: <identifier> + commercial_ml_allowed: <true> + redistribution_allowed: <true|false> + consent_basis: <identifier> + privacy_class: <class> + jurisdictions: [<jurisdiction>] + retention_policy: <policy-id> + source_digest: <sha256-or-merkle-root> + transform_digest: <sha256> + split_digest: <sha256> + record_count: <count> + +training: + random_initialization: true + parent_checkpoint: null + code_commit: <sha> + config_digest: <sha256> + rng_seeds: [<seed>] + hardware: <inventory> + provider: <local-linux|approved-provider> + provider_job_id: <non-secret-id> + environment_digest: <sha256> + started_at: <rfc3339> + ended_at: <rfc3339> + forbidden_artifact_scan_digest: <sha256> + leakage_scan_digest: <sha256> + +evaluation: + frozen_manifest_digest: <sha256> + baseline_artifacts: [<id-and-digest>] + metric_schema: <version> + report_digest: <sha256> + calibration_report_digest: <sha256> + domain_holdouts: [<site|subject|session|device>] + +release: + weights_digest: <sha256> + model_card_digest: <sha256> + clean_room_report_digest: <sha256> + signature_key_id: <public-key-id> + rvf_signature: <signature> + source_license: <spdx-expression> + weights_license: <exact-identifier> + data_steward_approval: <signed-receipt> + security_approval: <signed-receipt> + legal_approval: <signed-receipt> +``` + +Secrets, raw personal identifiers, contract text, raw customer data, and +private contributor identities stay outside the public manifest. The manifest +references controlled records by digest or approval ID. + +## 12. Source, dependency, and artifact checks + +The custodian records tool versions, rules, timestamps, and complete findings. +At minimum: + +- search the working tree and Git history for prohibited model IDs, URLs, + filenames, license strings, binary signatures, and unexpected large files; +- scan source similarity against the prohibited implementation in an isolated + custodian environment; implementation contributors do not receive reference + fragments from the report; +- manually adjudicate every material similarity match and record independent + origin, generic necessity, rewrite, or reclassification; +- run dependency license, source, duplicate-version, and vulnerability policy + checks and generate a CycloneDX or SPDX SBOM; +- inspect container layers, CI and provider caches, mounted volumes, model/data + buckets, notebooks, logs, and local tool indexes for restricted artifacts; +- verify that every checkpoint parent is an approved RuView artifact or the + declared random initialization; +- verify that model loading performs no network access and accepts no embedded + executable operator or arbitrary path; +- verify that public packages, endpoints, docs, and metadata do not use Google + trademarks as a product identity. + +Keyword scans are a tripwire, not proof of independence. References in this +governance document are expected and must be path allowlisted. The final result +depends on provenance, exposure records, review, and the absence of prohibited +material in implementation/training paths. + +## 13. Model card and claim gate + +Every candidate copies and completes +[`../huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md`](../huggingface/RUVIEW_FORECAST_MODEL_CARD_TEMPLATE.md). +No placeholder, `UNMEASURED`, unknown license, missing digest, or missing +approval may be silently deleted. It must instead be resolved or retained as an +explicit release blocker. + +All accuracy, latency, memory, power, cost, and generalization values are +labelled `MEASURED`, `SYNTHETIC`, `CLAIMED`, or `UNMEASURED` under repository +policy. `MEASURED` requires a reproducer, immutable inputs, exact artifact, and +named hardware/environment. A model-card benchmark does not create authority +for a medical, emergency, security, or autonomous-action claim. + +## 14. Naming, trademark, and comparative statements + +Approved project identities include `RuView Forecast` and crate names derived +from the RuView domain. Do not use `TimesFM`, `Google`, or a confusingly similar +mark in crate names, binaries, model slugs, endpoints, logos, icons, or product +headlines. Do not copy diagrams or visual branding. + +Comparative documentation may accurately identify an external model and its +version when necessary, with a statement that RuView Forecast is independently +developed and not affiliated with or endorsed by Google. Comparative claims +need identical datasets, metric definitions, permitted model use, and evidence +labels. + +Before public naming or commercial release, complete a professional trademark +clearance that includes federal, state, common-law, domain, and relevant +international sources. The [USPTO clearance guidance](https://www.uspto.gov/trademarks/search/comprehensive-clearance-search-similar-trademarks) +is a starting point, not a complete legal opinion. + +## 15. Residual patent and jurisdiction risk + +Clean-room evidence does not prevent patent infringement. Before production, +patent counsel performs a claim-level freedom-to-operate review covering at +least temporal patching, multivariate/variate attention or mixing, masked +horizon prediction, known-future covariates, probabilistic heads, normalization, +retrieval augmentation, and relevant training procedures. Searches include +assignees, inventors, continuations, unpublished timing uncertainty, and launch +jurisdictions. The [USPTO Patent Public Search](https://www.uspto.gov/patents/search/patent-public-search) +supports preliminary searching but is not a freedom-to-operate opinion. + +The Apache patent grant applies only within its stated scope. An independent +implementation must not assume it inherits that grant. Public papers may be +prior art yet still coexist with earlier, pending, territorial, or narrower +claims. + +## 16. Contamination incident response + +Treat any prohibited source, output, model, data, or unreviewed exposure as a +provenance incident: + +1. Stop affected implementation, training, evaluation feedback, and release. +2. Preserve hashes, timestamps, actors, locations, access records, and affected + lineage. Do not erase the audit trail. +3. Quarantine the material and revoke it from build, data, model, cache, and + retrieval paths. +4. Identify every affected commit, specification revision, dataset transform, + RuVector index, checkpoint, benchmark, and descendant artifact. +5. Rotate credentials if a provider, cache, or secret may be exposed. +6. Have the custodian and legal owner choose one disposition: proven + non-impact, clean rewrite by unexposed contributors, full retraining from the + last clean ancestor, or explicit Apache-derived reclassification. +7. Repeat all affected ADR-348 gates and record the incident and closure + receipts. + +If prohibited output becomes a label or tuning signal, all descendant weights +are affected. Code deletion alone cannot repair model lineage; retraining from +an approved clean initialization is required. + +## 17. Requirement-mapped acceptance checklist + +| Check | ADR-348 requirements | Gate | Acceptance condition | +|---|---|---|---| +| CR-01 source allowlist | RF-001, RF-002 | G0 | 100% of specification sources frozen, hashed, licensed/termed, and approved | +| CR-02 contributor exposure | RF-001, RF-002 | G0 | 100% of contributors have current intake and signed attestation; every disclosure has a disposition | +| CR-03 prohibited-artifact scan | RF-001 | G0 | Zero unresolved prohibited artifacts or outputs across source, history, caches, containers, jobs, data, indexes, and checkpoints | +| CR-04 similarity review | RF-001, RF-002 | G0 | Every material match adjudicated without exposing implementers to reference fragments | +| CR-05 dependency/SBOM | RF-002, RF-004 | G1, G2 | Zero unknown or denied dependency licenses; vulnerability policy passes; signed SBOM exists | +| CR-06 dataset rights | RF-003 | G1 | 100% of bytes map to approved manifests; zero NC, ND, research-only, no-ML, no-production, or unknown terms | +| CR-07 privacy | RF-003, RF-009 | G1, G5 | Consent/contract, minimization, tenant, retention, deletion, transfer, and sensitive-inference review approved | +| CR-08 checkpoint lineage | RF-001, RF-002, RF-010 | G1 | Every checkpoint reaches approved random initialization with no prohibited parent or label | +| CR-09 split isolation | RF-007, RF-008 | G3 | Zero raw/derived overlap across frozen site/subject/session/device/time splits; split-scoped RuVector indexes verified | +| CR-10 hosted parity | RF-010 | G1, G5 | Local/hosted receipts bind identical governed inputs; provider terms and deletion evidence approved | +| CR-11 model card | RF-002, RF-007, RF-011 | G3-G5 | Template complete; every value evidence-labelled and reproducible; all blockers explicit | +| CR-12 trademark and patent | RF-001, RF-011 | G5 | Naming clearance and claim-level freedom-to-operate disposition signed for launch jurisdictions | +| CR-13 rollback | RF-012 | G5 | Contamination and runtime rollback drills preserve observations and evidence while disabling forecast authority | +| CR-14 bounded forecast contract | RF-004, RF-005, RF-006 | G2 | Property/fuzz/replay evidence proves finite bounded inputs, abstention, offline execution, immutable observation linkage, and derived-only evidence labels | +| CR-15 downstream authority | RF-006, RF-009, RF-012 | G4, G5 | No sensing-server hook in the initial PR; any later bridge proves forecast/LLM output cannot mutate observations or acquire prohibited action authority | +| CR-16 claims and rollout | RF-011, RF-012 | G3-G5 | Every claim has an allowed evidence label and reproducer; mode transitions cannot outrun the highest passed gate | + +Any failed or missing check blocks the mapped gate. A maintainer waiver cannot +convert a prohibited license into permission or an unmeasured capability into a +measured claim. + +## 18. Release record + +The legal, data, security, model, and runtime owners sign the same immutable +release digest. Their approval covers the exact source, data, configuration, +weights, model card, SBOM, intended use, deployment mode, and jurisdictions. +Changing any governed input creates a new candidate and invalidates inherited +approval. + +The first production review must also answer one explicit question: does the +business value of the measured forecast exceed the added privacy, operational, +compute, and legal burden compared with deterministic baselines? If not, the +correct outcome is to keep forecasting offline. diff --git a/docs/security/ruview-forecast-threat-model.md b/docs/security/ruview-forecast-threat-model.md new file mode 100644 index 000000000..05552ba20 --- /dev/null +++ b/docs/security/ruview-forecast-threat-model.md @@ -0,0 +1,1027 @@ +# RuView Forecast security and privacy threat model + +- **Status:** normative implementation and release gate +- **Date:** 2026-09-01 +- **Scope:** `ruview-forecast-core`, `ruview-forecast-model`, + `ruview-forecast-train`, RuView API integration, RuVector retrieval, and hosted + training on fal +- **Extends:** ADR-260, ADR-262, ADR-271, ADR-295, ADR-299, ADR-304, ADR-305, + ADR-319, and ADR-348 +- **Evidence grade:** design review plus repository inspection; no production + forecast artifact or hosted training run was inspected + +## Security decision + +Forecasting may be implemented only as a bounded, advisory, derived-evidence +subsystem. Real RF data remains subject to RuView's person-data rules even when +it is converted into a time series. Hosted training is an external trust +boundary, not an extension of the RuView edge. + +The initial release MUST enforce these rules: + +1. **Hosted v1 is synthetic-pretraining only.** No real RuView RF-derived data + of any privacy class, external dataset path/bytes, `DataPolicy`, tenant/site/ + device/session identifier, split membership, or RuVector record may be sent + to fal. All real-data training runs stay on the operator-controlled Linux + host. +2. Fal receives only a bounded, server-authored synthetic generator recipe, + seed, model/optimizer profile, clean-room identity, and budget. Conditional + P1/P2 export requires a future privacy/DPA design decision and implementation; + it is ADR-only and MUST fail closed in v1 even when a receipt field is + present. The deployment build context is part of this egress boundary: it + MUST be produced from an exact-file/three-Rust-source-tree clean tracked-source archive and MUST + NOT upload the operator's working tree, untracked files, local requests, + datasets, credentials, or unrelated RuView fixtures. +3. A fal result is untrusted input. It MUST be downloaded into quarantine, + size-bounded, hashed, schema-checked, evaluated locally, and signed by a + RuView release key that never leaves RuView control before activation. +4. Forecasts MUST remain `DERIVED_FORECAST` evidence. They MUST NOT overwrite + observations, become `MEASURED`, raise the evidence level of an unauthenticated + RF source, or independently authorize a life-safety or physical action. +5. Every mutating route, including submit, cancel, delete, retrain, stage, + promote, activate, and rollback, requires `sensing:admin` by default. +6. Invalid or ambiguous security state fails closed. Missing history, stale or + OOD input, invalid model output, and unavailable retrieval cause abstention; + they never cause a synthetic/live fallback or reuse of a prior forecast as a + current observation. + +## Review and clean-room exposure statement + +This review inspected RuView repository policy, privacy, authentication, +attestation, evidence, witness, artifact-verification, dataset-ingest, and CI +code, together with fal's public operational documentation. It did **not** +inspect, copy, download, execute, or query Google TimesFM source code, +configuration, tests, checkpoints, weights, activations, or outputs. No Google +model output or similarity oracle was used. ADR-348's independently authored +RuView requirements are the forecasting specification for this review. + +The review was read-only until this document was requested. The checked base +state was commit `e04f269f1a76eb6ed4e8cd33e2ccfeda6fd391a3`; the reviewed +`v2/Cargo.lock` SHA-256 was +`ae160f76b1a0bfb2dd1917543c40003f7a2abd3599eee2340d92e143dca35c2c`. +No forecast crate existed in that base commit. New, uncommitted forecast files +appeared during the review and received a follow-up read-only pass. They were an +in-progress working-tree snapshot without a stable commit identity. The +implementation findings below must be rechecked against the final diff; no gate +is claimed to pass merely because a draft type or test exists. + +## In-progress implementation findings + +| ID | Severity | Snapshot evidence | Required closure | +|---|---|---|---| +| RFSEC-001 | **Critical** | `ruview-forecast-core/src/series.rs:147-210` carries evidence state but no ADR-260 privacy/tenant policy; `ruview-forecast-train/src/config.rs:218-359` binds dataset path/size/hash but no privacy class, tenant/account/workspace, purpose, consent/DPA, retention or export decision. | Bind real data to a validated local policy, but make hosted v1 a separate synthetic-recipe-only DTO. Reject every real/external dataset, including P1/P2, before network I/O. Evidence class is not privacy class. | +| RFSEC-002 | **Critical** | `ruview-forecast-model/src/artifact.rs:42-105,145-175` checks a BLAKE3 weights digest declared inside the same unsigned manifest. An attacker able to replace both weights and manifest satisfies that check. | Treat it only as an untrusted candidate, then require a domain-separated canonical manifest signature, local Ed25519 signer allowlist, compatibility/finite checks and a nonconstructible activated-artifact wrapper. Fal never receives the release key. | +| RFSEC-003 | **High** | `ruview-forecast-model/src/config.rs:126-217` has no hard dimension/activation caps, and expressions such as `3 * patch_len`, `6 * d`, `2 * d`, `3 * d`, `q * r` and `q * horizon` can overflow before the checked helpers. Patch/layer allocation is not bounded by parameter count alone. | Add per-dimension, parameter, patch/token, activation-byte and total-work caps. Use checked arithmetic for every intermediate before model allocation. | +| RFSEC-004 | **High** | `ruview-forecast-model/src/network.rs:43-60,278-361` validates tensor shapes but not batch/total cells, finite values, binary masks, bounded ages or finite outputs. | Require a validated input wrapper or adapter gate and post-inference finite/order validation; malformed data must return an error/abstention before backend panic or propagation. | +| RFSEC-005 | **High** | `ruview-forecast-train/src/artifact.rs:117-146,245-264` canonicalizes a path and later opens it; existing final paths are reopened without no-follow semantics. Directory or final symlinks can race the check. | Use capability/open-at/no-follow handling, verify the opened handle, and keep root/job directories mode `0700` with files `0600`. Do not trust a lexical/canonical path checked before open. | +| RFSEC-006 | **High** | `ruview-forecast-train/src/config.rs:396-421` checks path metadata and then calls `std::fs::read`, permitting replacement or growth between the check and unbounded read. | Open once, inspect the handle, read through `take(MAX+1)`, and reject any over-limit/trailing byte before parsing. | +| RFSEC-007 | **High** | `ruview-forecast-train/src/artifact.rs:77-90` directly deserializes an absolute `provider_path`, size and digest with public fields. | Return a bounded opaque artifact ID or validated provider-relative handle. Treat all fal descriptors as wire DTOs and revalidate before URL construction or file access. | +| RFSEC-008 | **Medium** | `ruview-forecast-train/src/config.rs:12-16,297-339` permits a 512 GiB dataset, 10,000 epochs and batch 8,192 without a tenant budget, wall-time or total-work estimate. | Apply deployment-lower byte limits plus checked work/GPU-hour/currency, attempt, wall-time and concurrency quotas before submission. | +| RFSEC-009 | **High** | `.github/workflows/ruforecast-ci.yml:206-232` exercises `cpu`/`cli`, while train's `server` and `fal-client` feature surfaces are not compiled, tested or linted. | Add mock-network jobs for `server,fal-client`, security-contract tests, blocking secret/privacy gates, and fuzz/property jobs before merging external integration. | + +### Closure re-review ledger + +The table above records the first implementation snapshot. The final PR pass +has the following disposition. “Addressed” means the reviewed software path is +present; it is not production approval and does not replace an operational +test on the exact release artifact. + +- **RFSEC-001 is addressed for the v1 software boundary.** Local series and + requests carry a constructor-validated `DataPolicy`. The hosted request is a + separate closed synthetic-only DTO that cannot serialize a policy, path, + dataset bytes, split, or tenant/site/device/session identity. Provider + retention, account isolation, and deletion remain operational release gates. +- **RFSEC-002 is partially addressed.** Candidate bytes have no activation + authority. Activation requires a domain-separated Ed25519 envelope, local + signer allowlist, compatibility checks, and nonconstructible activated type. + The current public activation policy still accepts caller-supplied epoch, + runtime, clock and schema values. Production remains blocked on a + service-owned policy constructor that binds the compiled runtime, trusted + clock and selected schema, plus a durable atomic store for + `{release_epoch, same_epoch_digest}` and the last-known-good model that + survives crash/restart tests. +- **RFSEC-003 is addressed for the two activatable v1 profiles.** Dimensions, + patch count, parameters, activation cells, input cells, and checked forward + multiply-adds are bounded. Only exact `tiny_ci` and `large_linux` profiles + validate; a low-parameter/high-attention adversarial configuration is + rejected. Batch work is checked before training allocation and again at the + model boundary. A future profile is a security-reviewed code change. +- **RFSEC-004 is partially addressed.** `ModelInput` has private fields and + validates batch, shapes, cells, finite values, masks, ages, validity rows, + activation work, and output finiteness/order. Local JSONL is byte- and + shape-gated before tensor creation. The library still accepts already-created + Burn tensors, so a production network adapter must add a bounded DTO-to-tensor + gate before exposing inference to untrusted input. No such route ships here. +- **RFSEC-005 and RFSEC-006 are addressed for local files.** Requests and + datasets use no-follow/nonblocking capability opens, regular/single-link + checks, bounded streaming reads, exact size/digest checks, private modes, and + atomic fixed-name publication. Every JSONL epoch re-hashes the exact bytes + consumed; publication failure removes the complete candidate set. +- **RFSEC-007 is addressed.** Provider responses carry only an opaque validated + job/kind identity and four fixed descriptors. URLs and local paths are built + from configured roots plus those types, then size/hash checked in quarantine. + The hosted receipt body currently receives only a minimal request-digest and + unsigned/untrusted projection check; strict full receipt schema and lineage + binding to the job, build, schema, manifest, candidate, checkpoint, terminal + status and reconciled cost remain a production import gate. This PR has no + promotion or import path, so provider bytes retain no activation authority. +- **RFSEC-008 is partially addressed.** Dataset, optimizer, activation, memory, + artifact, checkpoint, wall-time, billable-time, and request spend fields are + bounded; hosted execution is one-slot and does not automatically resubmit an + ambiguous submit. `max_micro_usd` is not provider-enforced, provider billing + is not reconciled, and account-wide concurrency/attempt reservation is an + external control. Hosted production remains blocked. +- **RFSEC-009 is addressed for PR CI.** The workflow separately compiles, + tests, and lints backend-free, CPU, RuVector, hosted `server,fal-client`, and + compile-only CUDA/WGPU surfaces, with clean-room, secret, and hosted-wire + scans. Fuzzing, container scanning, live Fal, GPU runtime, and measured + resource evidence remain release gates. + +The local policy retention timestamp is an admission and execution deadline, +not a deletion scheduler. Local JSONL, candidates, checkpoints, manifests and +receipts persist until the operator's storage policy removes them. A production +service remains blocked on authenticated lifecycle ownership, crash/restart +deletion tests and an auditable deletion receipt. + +Positive snapshot patterns worth preserving include private fields plus +constructor-validating custom `Deserialize` in forecast core, finite/shape +validation in `TimeSeries`, canonical domain-separated SHA-256 digests, +`deny_unknown_fields` on training requests, fixed typed devices/model profiles, +create-new temporary artifact files, streamed dataset hashing, fixed artifact +filenames, and the test that rejects a dataset symlink escaping its root. These +reduce risk but do not close the findings above. + +### RuVector feature release gate + +The optional RuVector adapter remains **blocked from production activation**, +but the evaluation-only construction path now pins `ruvector-core = 2.3.0` +with default features disabled and `memory-only,hnsw` enabled. It accepts at +most 1,024 elements and rejects an index before `VectorDB::new` unless checked +accounting for embedding copies, HNSW links/layers, forecast patches, record +metadata, and bounded search scratch fits a 128 MiB ceiling: + +```text +embedding bytes + HNSW links/levels + forecast-patch bytes + + opaque record/digest metadata + search scratch +``` + +Search fixes `ef_search=64`, validates bounded `k`, deduplicates provider +results, and rechecks finite distances and split scope. The constructor is +`pub(crate)`, so no downstream production service can instantiate the adapter +in this change. A production adapter still needs authenticated scope creation, +concurrency/deadline control, measured allocator/RSS evidence, and a deployment +memory reservation. + +Record identifiers MUST be fixed opaque digests (for v1, exactly 64 lowercase +hex SHA-256 characters) generated from a domain-separated local identifier. +Arbitrary caller strings such as names, email addresses, device IDs, or room +labels must never enter the vector database or an `AnalogMatch`. A public query +must not supply its own scope authority: the service derives tenant/account/ +workspace and split from an authenticated principal plus a locally selected +split, then hands the adapter a nonconstructible verified scope. + +The adapter no longer uses the unsafe `":memory:"` string. It creates a private +mode-0700 temporary directory and mode-0600 empty probe file, and fails closed +if the memory-only backend writes it. `clear()` drops the entire HNSW database +before rebuilding, because per-record removal retains vectors in the pinned +provider. Explicit close/drop removes the capability. This proves the reviewed +feature path did not write the probe; it does not promise memory zeroization or +crash cleanup. Persistent or production use still requires tenant-scoped +retention, deletion, audit, and residual-data tests. + +### Training runner release gate + +The runner output remains candidate evidence, not a release. Provider JSON is +decoded into a private strict wire and reconstructed against the configured +app plus expected request, job, worker build, build manifest, expiry, and four +artifact kinds. `production_signed` must be false. Downloads enter a +capability-confined quarantine and are re-hashed; no signing/promotion command +is included in this change. + +Preflight uses checked parameter, activation, forward-work, decoded-shuffle, +serialization, and overhead estimates, then enforces optimizer-step, +checkpoint, cumulative artifact, retention, and whole-invocation wall bounds. +The multiplier remains a conservative engineering estimate, not measured +allocator proof. Retention and wall bounds are checked around every durable +publication. Normal final publication also requires cancellation to remain +clear; an observed cooperative cancellation may intentionally publish only its +bounded checkpoint. A returned partial failure rolls back the fixed outputs, +while crash-atomic four-file publication remains a production gate. Peak RSS +and large-profile throughput remain unmeasured release evidence. + +The canonical run identity binds optimizer, checkpoint, model, device, budget, +source commit, lockfile, toolchain/target, container, data/generator, split, and +feature-schema identities. Hosted build values are compiled into the worker and +must match the request. Missing build environment values remain visibly +`UNVERIFIED`; they are not fabricated. A hosted candidate has no release epoch +or signing authority. + +## Repository security anchors + +The forecast implementation MUST preserve these existing RuView contracts: + +| Contract | Repository evidence | Forecast consequence | +|---|---|---| +| Raw CSI is person data | `docs/adr/ADR-299-csi-data-incident-repo-controls.md:10-17` records that raw CSI encodes breathing, movement, and presence. | Raw capture is never a normal fixture, log, hosted-training payload, or release artifact. | +| Only synthetic or expressly consented minimal CSI fixtures may be committed | `ADR-299:23-28`, `.github/workflows/csi-data-policy.yml:36-42`, and `scripts/csi-data-policy-check.sh:31-38,150-184`. | Forecast tests generate data in code or use an approved minimal fixture. | +| Privacy is semantic, not only a file format | `docs/adr/ADR-260-rufield-mfs.md:160-175` defines P0-P5 and requires provenance. | Derived windows are classified by information content and intended use, not merely relabelled P1. | +| Live egress is fail-closed | `docs/adr/ADR-262-rufield-ruview-integration.md:26-41` maps derived biometric content to P4/P5 and permits only P1/P2 on its unattended surface. | The feature exporter must reject a field whose effective privacy exceeds its destination policy. | +| The current live UDP source is not authenticated | `v2/crates/wifi-densepose-sensing-server/SECURITY.md:5-14,32-49`. | A capture from the current data plane is `LiveUnverified`; forecasting cannot mint L5 provenance from it. | +| Mutations are admin by default | `v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs:433-470` documents the prior training-route scope hole and now defaults every mutation to `sensing:admin`. | New forecast routes inherit this polarity; no route-prefix denylist is permitted. | +| Audience and scope are independent | `v2/crates/ruview-auth/src/verify.rs:40-53,165-246`; scopes are exact in `v2/crates/ruview-auth/src/principal.rs:78-86`. | Require a configured nonempty client allowlist, exact scope, account, and workspace binding. | +| Verified values should be constructed only after verification | `v2/crates/ruview-auth/src/principal.rs:28-30` and `v2/crates/ruview-attest/src/lib.rs:420-476`. | Remote DTOs must not deserialize directly into trusted forecast, artifact, attestation, or evidence types. | +| Artifacts load only after verification | `v2/crates/wifi-densepose-physics/src/learned/model.rs:58-66`. | Parse model tensors only after byte, signer, schema, and compatibility gates. | +| Production signature policy rejects unsigned input | `v2/crates/homecore-plugins/src/verify.rs:9-35,95-190`. | Forecast production has no `AllowUnsigned` path. | +| Parsers are bounded and panic-free | `v2/crates/wifi-densepose-train/src/dataset/widar.rs:8-17,105-116,175-250`. | All remote formats get byte, record, shape, and allocation caps before decoding. | + +## Assets and security objectives + +| Asset | Objective | +|---|---| +| Raw and derived RF windows | Confidentiality, tenant isolation, consent, minimization, bounded retention, lineage | +| Labels and future covariates | Integrity, rights provenance, no future leakage, no subject crossover | +| Fal API credential | Confidentiality, least scope, rotation, no client/log/artifact exposure | +| RuView release signing key | Local-only custody, non-exportability where practical, separate dev/prod keys | +| Training configuration and code | Immutable identity, clean-room provenance, reproducibility | +| Checkpoint and release model | Byte integrity, signer authenticity, schema compatibility, anti-rollback | +| Forecast and quantiles | Finite/bounded values, calibration identity, source/evidence honesty | +| RuVector analogue index | Split isolation, tenant isolation, deletion, neighbour-ID confidentiality | +| Job and promotion records | Attribution, idempotency, cost limits, append-only audit | + +## Data classification and permitted destinations + +Classification follows ADR-260 and is monotonic: a transform does not lower a +class merely because the output has fewer columns. A lower class requires a +documented privacy-reduction transform and a test demonstrating that prohibited +information is absent. When inputs differ, use the most restrictive applicable +source, semantic, consent, and destination class. + +| Data | Minimum class | Local Linux | Fal | Repository / CI artifact | +|---|---:|---:|---:|---:| +| Raw CSI, phase/amplitude frames, radar cube | P0 | Temporary, encrypted, authorized | **DENY** | **DENY** | +| Derived non-identity radio statistics with identifiers and precise time removed | P1 | Allow | **DENY in v1** | Synthetic/minimal only | +| Occupancy, motion, bed-exit, room routine series | P2 | Allow | **DENY in v1** | Synthetic/minimal only | +| Anonymous aggregates | P3 | Allow | **DENY in v1** | Aggregate reports only | +| Breathing, heart rate, gait, sleep, fall/health target | P4 | Explicit consent; restricted | **DENY by default** | **DENY** | +| Named/persistent person, face/re-ID/subject-linked series | P5 | Identity binding and audit | **DENY** | **DENY** | +| Hash/HMAC/pseudonym derived from a tenant, site, room, device, session, subject or local job label | At least source class | Policy-scoped only | **DENY in v1** | **DENY** unless an approved aggregate | +| RuVector neighbour IDs/distances | At least source class | Tenant/split scoped | **DENY by default** | Hashed aggregate metrics only | +| Model trained on customer-derived data | At least Restricted until leakage review | Quarantine/release store | **DENY in v1** | No weight blob in ordinary CI | +| Forecast of a P4/P5 target | P4/P5 | Restricted | Not a hosted-training response payload | No raw values in telemetry | +| Synthetic generated windows and labels | Content-dependent privacy; L0 evidence | Allow | Allow | Minimal deterministic fixtures allowed | + +There is no real-data fal exporter in v1. Local dataset manifests MUST retain +privacy, tenant/account, consent/purpose, schema, time-range, and provenance +bindings, but none of those records or dataset shards cross the hosted boundary. +The separate fal DTO carries only a server-approved synthetic generator ID and +version, bounded generator parameters, seed, model/optimizer profile, clean-room +identity and budget. It has no generic path, byte blob, URL, policy, source, +tenant or split field. + +Model weights can memorize training data. Until membership-inference, +canary-extraction, nearest-neighbour, and subject-linkage checks pass, a model +trained on customer-derived data inherits a restricted handling policy even if +its tensor format contains no explicit identifier. + +## Trust boundaries + +```mermaid +flowchart TD + S["RF sensor / capture"] --> I["RuView ingest + privacy gate"] + I --> L["Local dataset + Linux trainer"] + G["Synthetic recipe"] --> F["fal external processor"] + C["Clean source archive"] --> F + F --> Q["RuView quarantine + verification"] + L --> Q + Q --> R["Signed local runtime"] + R --> D["Evidence / RuVector / RuVLLM consumers"] +``` + +| Boundary | Untrusted side | Required gate | +|---|---|---| +| TB1 sensor to ingest | UDP frame/source | Source state, frame bounds, device attestation when available, replay/freshness; otherwise `LiveUnverified` | +| TB2 ingest to dataset | Feature producer and labels | Schema, privacy/consent, tenant, timestamp, finiteness, split and provenance validation | +| TB3 RuView to fal | Hosted provider, network, shared account resources | Synthetic-only DTO, approved app, least-scope key, storage/ACL/retention controls, cost authorization | +| TB4 fal webhook/result to RuView | HTTP headers/body/URLs/provider state | Raw-body signature, timestamp, expected request/user, replay/idempotency, body cap, fixed JWKS | +| TB5 model bytes to parser/runtime | Downloaded bytes and manifest | Streaming length/hash, canonical Ed25519 signature, signer allowlist, schema/tensor/finite checks | +| TB6 client to forecast/admin API | Bearer token and request | ES256 verifier, exact client/scope, tenant binding, rate/quota, strict DTO validation | +| TB7 forecast to downstream systems | Model output and optional explanation | Derived-evidence label, uncertainty/abstention, no numeric mutation, no autonomous action | +| TB8 API/service to RuVector | Caller identity, query, record ID, index capacity | Principal-derived verified scope, opaque digest IDs, pre-allocation byte reservation, bounded search | +| TB9 source tree to fal image builder | Docker context, package manager, base image and build logs | Clean tracked-file allowlist, no local data/secrets, digest-pinned inputs, SBOM/provenance, secret and CSI policy gates before upload | + +## STRIDE analysis + +| STRIDE | Threat | Boundary | Impact | Required control | +|---|---|---|---|---| +| Spoofing | Forged live CSI creates a plausible training trajectory | TB1 | Poisoned model and false provenance | Integrate ADR-305 attestation; until then retain `LiveUnverified`, device/network allowlist is not authentication | +| Spoofing | Forged fal callback or result for another job/account | TB4 | Attacker-selected artifact, cost/audit confusion | Verify fal Ed25519 raw-body signature, ±300 s timestamp, exact expected user/request ID and one-time nonce; idempotency ledger | +| Spoofing | Token minted for another Cognitum client or tenant | TB6 | Cross-product or cross-tenant access | Nonempty exact client allowlist; bind account/workspace from verified `Principal`, never from request JSON | +| Spoofing | Unknown artifact publisher | TB5 | Malicious model activation | Local release signer allowlist; no caller-constructible verification receipt | +| Tampering | Dataset, labels, split manifest, config, or seed changes | TB2/TB3 | Poisoning, leakage, irreproducible claims | Content-address every input; canonical signed run manifest; verify before training and again in the receipt | +| Tampering | Model or normalization is changed independently of manifest | TB5 | Silent wrong inference or parser attack | Signature covers canonical manifest and hashes of all payloads; verify actual bytes before decode | +| Tampering | Witness/evidence JSON forges a higher evidence level | TB4/TB7 | False assurance | Deserialize bounded wire DTOs and reconstruct via validated constructors; never trust serialized `VerifiedMeasurement`/ledger objects | +| Repudiation | User disputes a costly run, cancellation, or promotion | TB3/TB6 | Spend and governance dispute | Append actor `sub/account/workspace/jti`, request/idempotency IDs, code/data/config digests, provider job ID, state transition, time and reason | +| Repudiation | Provider result cannot be linked to submitted inputs | TB3/TB4 | Unverifiable checkpoint | Signed training receipt binds request ID, source commit, lock/container, data/config hashes, hardware and output digests | +| Information disclosure | Real RF-derived data or identifiers reach request history, fal CDN, logs, or shared `/data` | TB3 | Person/health/identity data incident | Synthetic-only v1 DTO; private app auth; no input upload or corpus in `/data`; bounded synthetic metadata; redaction; candidate quarantine and deletion reconciliation | +| Information disclosure | Broad Docker context uploads untracked local data, requests, credentials, or approved-but-real fixtures | TB9 | Hosted data incident before the runtime request exists | Build from a generated clean tracked-source allowlist or a locally built digest-pinned image; enumerate and scan the exact context before upload | +| Information disclosure | Forecasts reveal routines or RuVector neighbour identities | TB6/TB7/TB8 | Occupancy/health inference and tenant leak | `sensing:read`, principal-derived tenant/split scope, fixed opaque neighbour digests, field allowlist, aggregate telemetry only | +| Information disclosure | Artifact memorizes individual samples | TB5/TB7 | Model inversion/membership inference | Restricted artifact policy, privacy tests, canaries, aggregation, no raw-output debug endpoint | +| Denial of service | Huge context/horizon/series/quantile/shape values | TB2/TB5/TB6 | OOM, overflow, CPU starvation | Hard caps, checked products/byte counts before allocation, bounded worker pool, deadlines and quotas | +| Denial of service | NPY/safetensors/archive length or decompression bomb | TB2/TB5 | OOM/disk exhaustion | Header/file/entry/ratio caps; streaming parser; reject archives by default | +| Denial of service | Webhook retry storm or bogus JWKS key IDs | TB4 | Request/egress amplification | Body cap, verify before parse, idempotent `2xx` after durable terminal state, cached fixed JWKS with fetch floor/timeouts | +| Denial of service | Unlimited hosted jobs/retries | TB3/TB6 | Financial exhaustion | Per-tenant concurrency, daily GPU-hour/currency cap, explicit max attempts, no automatic resubmit from unknown state | +| Elevation of privilege | Read token calls train/promote/delete route | TB6 | Model replacement or spend | All non-GET/HEAD/OPTIONS admin by default, with no forecast mutation allowlist | +| Elevation of privilege | User controls path, URL, operator, or deserialized trusted type | TB3/TB5/TB6 | SSRF, arbitrary overwrite, verification bypass | Server-selected endpoints/paths/operators; capability directory; bounded DTO; no embedded code or pickle | +| Elevation of privilege | Release key exists on fal worker | TB3 | Provider/job compromise can sign production model | Fal emits an unsigned candidate; local promotion service alone owns release signing capability | + +## API and in-process validation limits + +The first implementation SHOULD use the following hard ceilings. Deployment +configuration may lower them. Raising them is a security-sensitive change that +requires memory/latency measurements and review. All products are computed with +`checked_mul`/`checked_add` before reserving or allocating. + +| Field / resource | Initial hard limit | Validation | +|---|---:|---| +| HTTP JSON request body | 1 MiB | Reject at transport before buffering/deserialization | +| Fal Direct Server synthetic body | 64 KiB | Body layer rejects before `serde_json`; the closed DTO is revalidated before work | +| Fal queue/status/result JSON | 1 MiB | Stream chunks into a bounded buffer; provider error bodies are discarded | +| Local training request file | 1 MiB | Open once with no-follow/nonblocking flags, require a regular file, read through `take(MAX+1)` | +| Local JSONL shard | 8 GiB | Size and SHA-256 bind the already-open capability; no whole-shard buffering | +| One JSONL window | 8 MiB and 2,000,000 value/target cells plus equal-shape binary masks | Bound the aggregate line before serde, then validate exact model-derived shapes, masks, finite values and train split | +| In-memory JSONL shuffle | 64 decoded windows | Include worst-case decoded allocation in the request memory reservation | +| Webhook raw body | 256 KiB | Reject before hashing/parsing; fal artifact bytes are fetched separately | +| Activation/local manifest or metadata document | 1 MiB | UTF-8 plus strict schema and `deny_unknown_fields`; the quarantined hosted receipt's full strict schema/lineage validation remains an open production import gate | +| Series/targets | 256 | Unique bounded identifiers; release target is smaller (ADR-348 G5 uses 32) | +| Context steps | 16,384 | Strictly monotonic timestamps, exact cadence contract or explicit missing mask | +| Forecast horizon | 4,096 | Nonzero and supported by the artifact/model card | +| Past/future covariates | 256 each | Allowlisted schema fields; future source and availability receipt required | +| Quantiles | 32 | Finite, strictly increasing, unique, each strictly inside `(0,1)` | +| Batch size | 64 in-process; 1 for remote interactive API initially | Enforced before tensor creation | +| Transport/API aggregate numeric cells (future inference adapter) | 4,194,304 | A production DTO-to-tensor route must count values, masks, covariates, retrieval additions, and outputs together before allocation; this PR ships no such untrusted route and its libraries retain per-object caps | +| Identifier | 128 UTF-8 bytes | Reject empty security IDs; normalize only where the schema specifies it | +| RuVector record ID | 64 lowercase hexadecimal bytes | Domain-separated opaque SHA-256 digest only; never a subject, tenant, device, room, or path string | +| Free-text reason/label | 1,024 UTF-8 bytes | Never interpreted as path, URL, format string, or log template | +| Candidate model bytes | 256 MiB | Stream to quarantine with a byte ceiling and expected length | +| Optimizer updates / wall time | 2,000,000 / 24 hours | Deployment lowers both; check at every batch and before checkpoint/serialization | +| Local training memory request | 96 GiB maximum | Conservative checked estimate must cover parameters, optimizer, autodiff, decoded shuffle, tensors and serialization; measured RSS must stay below it | +| Tensor count | 4,096 | Unique canonical names; exact expected set for production model | +| Tensor rank / dimension | 8 / 1,048,576 | Shape product and byte range checked; no overlap or out-of-bounds offsets | +| Concurrent local inference | bounded pool, default `min(physical_cores, 8)` | Queue cap and rejection/backpressure, not unbounded task creation | +| Concurrent hosted training | 1 per tenant, deployment-configured global cap | Idempotency key and budget reservation before submission | +| Redirects for dataset/artifact fetch | 0 | A new URL is a new authorization decision; default is no redirects | + +Signed envelopes are length-gated and signature-checked through a borrowed +candidate slice. The implementation does not copy or parse the candidate model +record until the signer is allowlisted and the Ed25519 signature succeeds. + +Additional validation is mandatory: + +- Reject NaN, positive/negative infinity, denormals if the model contract + disallows them, and values outside per-feature physical/normalization ranges. + If `-0.0` and `+0.0` are semantically equal, canonicalize them before hashing; + otherwise reject the noncanonical representation. +- Reject duplicate, descending, or far-future timestamps; integer arithmetic + must not wrap duration or index calculations. +- Reject duplicate target/covariate names, target/covariate aliasing that leaks + a future target, and masks whose shape differs from their values. +- Reject unsupported schema/model/runtime versions rather than coercing them. +- Do not silently skip malformed training rows. A configured small error budget + may quarantine bad records, but exceeding it fails the job and the receipt + records every exclusion. +- Treat empty, stale, low-coverage, OOD, or provenance-incomplete context as an + abstention state. Do not auto-fill with simulated sensing. +- Validate quantile ordering after inference. A documented crossing correction + may run, but non-finite or structurally invalid output is an abstention. +- Bound every string, vector, map, log field, metric label cardinality, and + RuVector result count before allocation. + +These limits are deliberately much tighter than the dimension-only guards in +`v2/crates/wifi-densepose-train/src/config.rs:35-69`, whose individually large maxima can +still produce unsafe products. The existing `from_json` at `config.rs:256-271` +also reads the whole file before parsing; the forecast boundary must cap file +metadata and body bytes first. + +## Authentication, authorization, tenancy, and spend + +### RuView callers + +- Reuse `ruview-auth`; do not add a second JWT implementation. +- Fix ES256 as the accepted algorithm and require an access token, matching + `v2/crates/ruview-auth/src/verify.rs:10-25,174-217`. +- `allowed_client_ids` MUST be nonempty in a production forecast deployment. +- Exact `sensing:read` authorizes reading a tenant-scoped forecast and job + status. It does not authorize exporting a dataset or seeing raw provider logs. +- Exact `sensing:admin` authorizes dataset export, job submit/cancel/delete, + candidate download, evaluation, stage, promote, activate, and rollback. +- Account/workspace comes only from the verified principal. If a resource ID + resolves to another account/workspace, return a flat not-found/unauthorized + response and do not disclose its existence. +- A new mutating endpoint is admin-gated automatically. This preserves the + fail-closed route policy documented in `bearer_auth.rs:433-470`. + +### Fal credentials and budget + +- Use a dedicated fal account/app for forecast training and a dedicated + **API-scoped** `FAL_KEY` for run submission. Fal documents that API scope can + call models/deployed endpoints, while ADMIN additionally controls deployments + and apps. Keep a separate short-lived ADMIN key only in the deployment + workflow. +- Load `FAL_KEY` from a secret manager/environment into the server process. Do + not accept it in API input, put it in a command line, serialize it, print it, + attach it to an error, or send it to a browser. +- Redact authorization headers, presigned query strings, CDN tokens, JWTs, + cookies, and key-like environment values in `Debug`, tracing, panic, and CI + output. +- Reserve a tenant budget before submission. Bind currency/GPU-hour ceiling, + machine class, maximum wall time, maximum attempts, and expected data/artifact + bytes into the authorized run manifest. +- Cancellation that cannot be confirmed enters `CancellationUnknown`; it is not + automatically resubmitted and does not release the full budget reservation + until provider state is reconciled. + +## Fal privacy, storage, and callback policy + +Fal is an untrusted processor even when its cryptographic callback verifies. +The provider signature authenticates delivery; it does not approve data use, +prove training correctness, or sign a RuView release artifact. + +Hosted v1 deliberately retains only the bounded synthetic request and result +metadata needed by Fal's queue/status/result protocol. It does not set +`X-Fal-Store-IO: 0`, because doing so would remove the stored result that the +typed reconciliation flow must retrieve. No customer-derived data or identity +is permitted in that metadata. Every submit MUST instead set +`X-Fal-No-Retry: 1` and a bounded request timeout, and a connection loss during +that one send becomes `REMOTE_UNKNOWN` rather than an automatic resubmit. + +Both ephemeral and deployed Direct Server commands MUST select the exact +`fal_app.py::run_server` symbol and pass `--auth private`. The checked-in Fal +app configuration also declares private auth. A live acceptance test MUST +prove an unauthenticated request receives 401/403 before hosted use is approved. +The launcher enforces these argv values locally, but no live Fal probe was run +for this PR. + +Hosted v1 performs no input upload. The synthetic corpus is generated inside +the worker from the bounded recipe. Any future input-upload design requires a +new ADR and must apply private ACL and expiry **to the upload itself**; fal's +request lifecycle header does not retroactively cover a previously uploaded +input. + +Training datasets MUST NOT use `/data` as ordinary working storage. Fal +documents `/data` as shared across all apps/runners in the account and persistent +until manual deletion. Hosted v1 generates its corpus in bounded process memory +and writes only four fixed candidate outputs below a fresh opaque `/data` +namespace. The worker schedules best-effort deletion at the digest-bound +expiry, but a crash can defeat it. The candidate must be copied into RuView +quarantine before expiry, and provider-side lifecycle/deletion reconciliation +is a blocking production gate. + +The deployed Direct Server has a 3,600-second provider timeout, while the +closed hosted DTO permits at most 3,300 seconds of wall and billable work. The +remaining 300 seconds is reserved for response handoff and best-effort cleanup; +it is not available to the optimizer. + +Customer-derived P1/P2 upload is unimplemented and denied in v1. A future ADR +must resolve the applicable contract/DPA, region, subprocessors, +retention/deletion capability, incident channel, data-steward approval, +purpose/consent authority, and an authenticated non-caller-constructible export +decision before any implementation work begins. + +Webhook handling MUST: + +1. Read no more than 256 KiB of raw body. +2. Require all four fal webhook headers. +3. Reject timestamps outside ±300 seconds. +4. Hash the exact raw body with SHA-256 and verify the documented newline-bound + message with Ed25519 against the fixed + `https://rest.fal.ai/.well-known/jwks.json` key set. +5. Require the expected fal user, submitted request ID, tenant-bound local job, + and current non-terminal state. +6. Atomically record the request ID/body hash as processed before returning a + terminal `2xx`; duplicate deliveries return the already recorded outcome. +7. Treat URL strings and artifact metadata inside the signed body as untrusted + values requiring their own validation. + +Fal currently documents retrying failed/non-2xx deliveries with backoff, up to +31 retries while the result remains stored. Idempotency is therefore a required +correctness and availability property, not an optimization. + +Polling the fixed queue endpoint is acceptable for an initial deployment and +reduces inbound surface. It does not remove result validation, tenant binding, +idempotency, or artifact quarantine requirements. + +## SSRF and outbound network controls + +The safest API accepts no caller-provided URL. A request contains an opaque +dataset/artifact ID, and the server resolves it to a configured object store or +known fal endpoint. + +If URL fetching remains necessary, enforce all of the following before opening +a socket: + +- exact `https` scheme, exact allowlisted ASCII host, port 443, no username, + password, fragment, embedded credentials, `data:`, `file:`, or alternate + scheme; +- no suffix matching such as `ends_with("fal.ai")`; `fal.ai.attacker.example` + is not fal; +- resolve every address and reject loopback, private, link-local, multicast, + unspecified, reserved, carrier-grade NAT, and cloud metadata destinations for + IPv4 and IPv6; +- no redirects by default. If enabled later, apply the full authorization check + to every hop and cap hop count; +- protect against DNS rebinding by connecting only to an address from the + validated resolution while preserving TLS hostname verification; +- fixed connect/read/total timeouts, response-header cap, declared and streamed + byte cap, and no transparent decompression unless separately bounded; +- stream into an application-created quarantine file while hashing; never load + a remote artifact body fully into memory; +- do not expose `ruview-auth`'s generic JWKS URL constructor to API input. The + current `UreqFetcher` performs a GET against the supplied URL at + `v2/crates/ruview-auth/src/jwks.rs:265-267,342-350`; forecast JWKS endpoints are fixed + operator configuration with scheme/host validation. + +## Filesystem and path traversal controls + +- Requests contain logical IDs, not dataset roots, checkpoint paths, output + names, archive entry paths, or temporary directories. +- Create a private per-job directory under a configured capability root. Use a + random server-generated name, mode `0700`, files mode `0600`, create-new + semantics, and no symlink following. +- Write to a new temporary file, `fsync` when durability is required, verify, + then atomically rename to an immutable content-addressed path. Never activate + or parse the remote download in place. +- Reject absolute paths, `..`, root/prefix components, NUL, platform device + names, alternate data streams, symlinks, hardlinks, sockets, FIFOs and device + files. Canonicalize the existing parent and prove it remains below the + capability root. +- Do not extract archives in the first release. If an archive is later required, + cap entries, per-entry and total bytes, compression ratio and nesting; reject + duplicate normalized names, links and special files before writing anything. +- Dataset discovery must not repeat the permissive pattern in + `v2/crates/wifi-densepose-train/src/dataset.rs:337-432`, which accepts a caller + path and lacks explicit canonical-root/symlink and checked-prefix-sum controls. + +## Deserialization and trusted-type controls + +Serde derives do not enforce Rust constructors. This matters at a remote +boundary: + +- `ruview-attest::VerifiedMeasurement` derives `Deserialize` and has public + fields at `v2/crates/ruview-attest/src/lib.rs:258-274`; deserializing it + directly would bypass signature, payload, sequence, and freshness checks + performed at `:420-476`. +- `ruview-evidence::EvidenceContext` and `AccuracyMetrics` derive `Deserialize` + even though constructors/validation bound IDs and finite ranges at + `v2/crates/ruview-evidence/src/lib.rs:85-145,157-213`. +- `EvidenceLedger` derives `Deserialize` at + `v2/crates/ruview-evidence/src/lib.rs:368-377`, so direct decoding can bypass + append-time sequence/capacity discipline and allocate an attacker-sized + record vector. +- `WitnessChain` derives `Deserialize` at + `v2/crates/ruview-witness/src/lib.rs:673-685`; `verify()` at `:773-821` checks + hash links and order, not the original RF signature behind a deserialized + `VerifiedMeasurement`. + +Forecast code MUST therefore use small untrusted wire structs with +`#[serde(deny_unknown_fields)]`, bounded strings/collections, and no security +defaults. Apply the transport byte cap before `serde_json`; deserialize, then +reconstruct domain objects through validating constructors and cryptographic +verification. A type named `Verified*`, `Trusted*`, `Principal`, `Evidence*`, or +`Activated*` must not implement a public untrusted decode path that can create +the trusted state directly. + +Model payloads MUST be data-only. Reject pickle, Python objects, executable +plugins, arbitrary operator graphs, shell snippets and provider-supplied code. +Safetensors or another non-executable tensor container is still untrusted: cap +its header before allocation, require the exact tensor set, reject duplicate or +overlapping ranges, check dtype/rank/shape/product/offset, and reject non-finite +parameters before construction. + +## Security-relevant Rust crates + +Prefer workspace-pinned dependencies with `default-features = false` and only +the features required by the owning crate. Every addition expands the advisory, +license, source and build-script surface and therefore passes cargo-deny review. + +| Crate | Security role and constraint | +|---|---| +| Existing `ruview-auth` | Cognitum ES256 access-token verification and `Principal`; do not duplicate JWT policy in forecast crates | +| Existing `ruview-attest`, `ruview-evidence`, `ruview-witness` | Provenance/evidence integration after local validation; do not deserialize their trusted domain objects from fal/API input | +| `sha2` | SHA-256 for streamed artifact bytes, webhook body and interoperable manifest digests | +| `ed25519-dalek` | Fal webhook and local release-signature verification; use strict fixed-width key/signature parsing and a signer allowlist | +| `blake3` | Optional internal content/witness identity where the existing RuView contract already uses BLAKE3; do not silently substitute it for a declared SHA-256 field | +| `subtle` | Constant-time comparison for secret-dependent tags where an upstream verification API does not already provide it | +| `secrecy`, `zeroize` | Reduce accidental `FAL_KEY` exposure in `Debug` and memory; this does not guarantee erasure of copies held by HTTP/env libraries | +| `url`, `ipnet` | Parse once and enforce exact scheme/host/port plus IPv4/IPv6 deny ranges; URL parsing alone is not SSRF protection | +| Repository-approved HTTP client with `rustls` | Fixed endpoints, no redirects, timeouts, streamed byte ceilings and explicit proxy policy; avoid adding a second client stack without justification | +| `tempfile`, optionally `cap-std` | Private create-new quarantine files and capability-scoped filesystem access; still validate links and final location | +| `serde`, `serde_json`, `serde_path_to_error` | Small `deny_unknown_fields` wire DTOs and diagnosable rejection after a transport byte cap; serde is not an allocation bound by itself | +| `safetensors` | Non-executable tensor data; wrap it with independent header, tensor-count, range, shape, finite-value and total-byte validation | +| `tokio` synchronization/time primitives | Bounded semaphores, queue backpressure and deadlines; do not spawn unbounded blocking inference/training work | +| `proptest`, `cargo-fuzz` (dev tooling) | Boundary/state/overflow properties and parser fuzzing with synthetic-only corpora | + +Use a custom, versioned canonical manifest encoder like the length-prefixed +canonical bytes in `v2/crates/ruview-attest/src/lib.rs:188-223`. Do not add a +general canonicalization package merely to sign arbitrary input JSON. + +## Artifact integrity, authenticity, and promotion + +### Canonical manifest + +The signed release manifest MUST contain at least: + +- manifest/schema version and a domain string; +- artifact byte length and SHA-256 of the actual model payload; +- model ID/version, architecture/config digest, runtime compatibility range, + and monotonic release epoch; +- feature schema, cadence, context, horizon, target, quantile, normalization, + calibration and OOD-policy digests; +- exact tensor names, dtypes, shapes, offsets, lengths and optional per-tensor + hashes; +- training source commit, `Cargo.lock` digest, compiler/toolchain, container + digest, target/hardware, seeds, dataset/split/transform manifest digests, and + privacy/consent decision digest; +- local/fal run identifier, candidate hash, evaluation report/reproducer digest, + clean-room attestation digest, license/SPDX inventory, and SBOM digest; +- signer key ID, issued-at, optional expiry, and signature algorithm/version. + +Use one deterministic canonical encoding. The Ed25519 signature covers a +domain-separated message such as: + +```text +"ruview.forecast.artifact.v1\0" || SHA256(model_bytes) || canonical_manifest_without_signature +``` + +The manifest fields and payload digest must both be signed; a signature over +only the weight hash permits a compatible-looking manifest substitution. Hash +the actual streamed bytes and compare in constant time where the library makes +that available. Sign with a local release key after local evaluation. Fal never +receives that private key and never produces the production release signature. + +The strongest in-repository precedent is +`v2/crates/homecore-plugins/src/verify.rs:95-190`: hash actual bytes, verify an +Ed25519 signature, then apply a publisher-key allowlist, rejecting unsigned or +incomplete inputs in production. The forecast loader should also preserve +`v2/crates/wifi-densepose-physics/src/learned/model.rs:58-66`, which deserializes only a +previously verified artifact. + +`v2/crates/wifi-densepose-physics/src/learned/artifact.rs:41-105` is useful for size, +manifest and byte-hash compatibility, but its `SignatureVerification` is only a +receipt supplied by the caller; it is not itself cryptographic verification. +Do not expose the same caller-constructible gap at the remote forecast boundary. + +### Verification and activation order + +1. Authorize the local job/tenant and reserve byte budget. +2. Fetch from an allowlisted source into quarantine with no redirects. +3. Enforce streamed size and expected length; compute SHA-256. +4. Parse only the bounded manifest envelope. +5. Verify domain, manifest canonical form, Ed25519 signature and signer + allowlist. +6. Compare model and auxiliary-file hashes and lengths. +7. Enforce anti-rollback epoch and runtime/schema compatibility. +8. Parse the tensor header and validate exact tensor table/ranges/products. +9. Validate every parameter is finite and run deterministic load/self-tests. +10. Run the frozen local held-out evaluation and evidence/reproducer gates. +11. Sign the accepted release manifest locally. +12. Stage by content hash; atomically switch the active pointer while retaining + the last known-good artifact. + +Any failure leaves the candidate quarantined/rejected and the previous active +model unchanged. Startup with no valid artifact disables forecasting but does +not disable observed RuView sensing. + +## Fail-closed state machines + +### Hosted training + +```text +DRAFT -> AUTHORIZED -> SUBMITTED -> RUNNING -> RETURNED_UNTRUSTED + -> VERIFIED_CANDIDATE -> LOCALLY_EVALUATED -> LOCALLY_SIGNED -> STAGED + -> ACTIVE +``` + +Invalid transitions are errors. Authentication, privacy, budget or manifest +failure before submission returns to `DRAFT/DENIED`. Provider ambiguity enters +`REMOTE_UNKNOWN`; it does not auto-submit. Verification/evaluation failure +enters `QUARANTINED`. Only an admin action with the recorded gates transitions +`STAGED` to `ACTIVE`. + +### Runtime inference + +| Condition | Fail-closed result | +|---|---| +| No active, valid artifact | Forecast subsystem `OFF`; sensing continues | +| Unsupported schema, shape, horizon, cadence | Typed rejection; no allocation beyond bounded error | +| Missing/stale/low-coverage/OOD context | Signed abstention/unknown derived record | +| Retrieval unavailable or tenant/split receipt invalid | Run without retrieval only if the model card authorizes that ablation; otherwise abstain | +| Panic/cancel/timeout/non-finite model output | Abstain, increment bounded metric, keep observation untouched | +| Signature/hash/compatibility/anti-rollback failure | Reject candidate and preserve last known-good active artifact | +| Auth verifier unavailable before any trusted key | Deny request | +| Stale cached auth key after a prior successful fetch | Follow existing bounded `ruview-auth` cache policy; do not accept an unknown key | +| Evidence or provenance incomplete | Cap at the supported lower grade or reject; never synthesize a higher grade | +| Downstream RuVLLM unavailable | Numeric signed forecast remains unchanged; no generated substitute | + +## Required tests + +The test names below are contract IDs. The owning crate may choose idiomatic +Rust test function names, but every contract must be traceable in CI. + +The experimental PR's hosted boundary regressions use descriptive names rather +than these IDs. Their presence in CI is not contract closure: every ID remains +`OPEN` in the requirements-evidence matrix until the complete semantics and +retained machine evidence specified below exist. + +### Privacy and tenancy + +- `PRIV-001`: P0 raw CSI export to fal is rejected. +- `PRIV-002`: every real RF-derived P1-P5 feature, label, forecast, aggregate, + dataset path, or dataset byte payload is rejected by hosted v1. +- `PRIV-003`: a P1/P2 receipt cannot enable hosted export in v1; the + conditional future design remains ADR-only and unimplemented. +- `PRIV-004`: a transform cannot lower privacy by changing an enum alone; + malformed privacy payload fails rather than being relabelled and forwarded. +- `PRIV-005`: tenant A cannot address tenant B dataset, job, artifact, + RuVector neighbour, forecast, deletion, or provider log. +- `PRIV-006`: logs/metrics/errors contain no raw feature arrays, precise room or + person ID, FAL key, bearer token, presigned query, CDN token, or signature + private material. +- `PRIV-007`: tracked-file policy passes with only synthetic minimal fixtures; + test the existing `scripts/csi-data-policy-check.sh` self-test and tracked + check. +- `PRIV-008`: caller-controlled `synthetic`, `deidentified`, privacy-class, or + opaque receipt fields cannot select the hosted path. The exact serialized fal + payload contains no external dataset URL/path/bytes, tenant/account/workspace, + site/room/device/session identifier, `DataPolicy`, raw split membership, or + other field absent from the synthetic recipe allowlist. +- `PRIV-009`: a hosted run ID is a fresh synthetic nonce allocated by the local + reservation ledger. Changing any caller-supplied tenant/site/device/session, + subject, or local job label cannot change any hosted byte; a plain digest of a + low-entropy local identifier is still pseudonymous data and is rejected. + +### Authentication and state + +- `AUTH-001`: every forecast mutation defaults to `sensing:admin`; a newly + added POST route cannot fall through to read. +- `AUTH-002`: empty/other client ID, wrong scope, missing account, setup/workload + token, wrong algorithm, malformed token and unknown key all fail closed. +- `AUTH-003`: principal account/workspace overrides or conflicts in request JSON + are rejected. +- `AUTH-004`: a request ID or other caller-chosen correlation header is not + authentication. Direct Server train/cancel routes reject a valid-looking + request ID without the independently authenticated provider/service identity; + wrong, missing, replayed, and cross-app credentials all fail closed. +- `AUTH-005`: the hosted worker enforces an in-process live-job semaphore and + deployment-lower work/memory limits after authentication but before spawning + blocking work; retained terminal-state count is not treated as a concurrency + limit. +- `STATE-001`: every illegal job transition fails and is audit-recorded. +- `STATE-002`: duplicate submit idempotency key creates one remote job and one + budget reservation. +- `STATE-003`: ambiguous cancellation becomes `REMOTE_UNKNOWN` and does not + trigger resubmit/promotion. +- `STATE-004`: activation failure leaves the previous model active; rollback is + atomic and produces an audit record. + +### API and resource bounds + +- `BOUND-001`: property-test every dimension at `0`, max, max+1, and + `usize::MAX`; every product uses checked arithmetic and fails before + allocation. +- `BOUND-002`: reject NaN/Inf, bad ranges, duplicate/descending/future + timestamps, shape/mask mismatch, duplicate targets, invalid quantiles, and + unsupported versions. +- `BOUND-003`: a 24-hour accelerated replay has bounded RSS, queue, cache, + RuVector result count, log cardinality, and latency with no panic. +- `BOUND-004`: fuzz API JSON, manifest, safetensors header, webhook headers/body, + and any retained dataset parser. The invariant is no panic, hang, excessive + allocation, path escape, or partial trusted object. +- `BOUND-005`: malformed training rows exceed the configured error budget and + fail the run instead of silently producing a smaller biased dataset. +- `BOUND-006`: the accepted training configuration's measured peak stays within + its conservative preflight memory estimate; parameters, gradients, optimizer + state, all saved layer activations, batch tensors and serialization scratch + are included, and max+1 is rejected before backend allocation. +- `BOUND-007`: cumulative model/checkpoint/manifest/receipt bytes and checkpoint + count never exceed the request budget, including cancellation and partial + failure paths. + +### RuVector retrieval + +- `RVEC-001`: maximum dimension and element count whose product exceeds the + embedding-byte reservation is rejected before `VectorDB::new`; the estimate + also includes configured HNSW graph, patch, metadata and search overhead. +- `RVEC-002`: overflow at every intermediate in the index byte/work estimate + fails closed before allocation; boundary tests cover `0`, max, max+1 and + `usize::MAX`. +- `RVEC-003`: only exactly 64 lowercase hexadecimal record digests are accepted; + names, email addresses, tenant/device/room labels, whitespace, uppercase, + path-like values and control characters are rejected and never returned. +- `RVEC-004`: a caller cannot construct or deserialize scope authority. Tenant, + account and workspace come from the authenticated principal and split comes + from server state; cross-principal and cross-split queries fail before search. +- `RVEC-005`: search enforces bounded `k`, `ef_search`, concurrent work and a + deadline/cancellation path. Timeout or poisoned/unavailable index causes the + documented no-retrieval ablation or abstention, never cross-scope fallback. +- `RVEC-006`: production configuration rejects any adapter merely claimed to be + memory-only until non-persistence is proved; no persistent index can be + enabled without tenant-scoped expiry, deletion and audit tests. A provider + string such as `":memory:"` is not accepted as evidence: the test asserts no + file is created in the working directory or any other path and scans the + private temp root for residual record bytes after clear/drop. + +### Fal, SSRF, and filesystem + +- `FAL-001`: hosted submit carries only the closed bounded synthetic DTO, sets + `X-Fal-No-Retry: 1` plus the reviewed timeout, and intentionally leaves + `X-Fal-Store-IO` absent so queue result reconciliation works. Any real-data or + identity field is a local pre-submit failure. +- `FAL-002`: hosted v1 performs no input upload and writes no generated corpus + to shared persistent `/data`; a request containing an input URL/path/blob is + locally rejected before network I/O. +- `FAL-003`: webhook signature covers exact raw body; altered whitespace/body, + header, user, request ID, timestamp, or signature fails. +- `FAL-004`: timestamps at the documented boundary are tested; stale/future and + replayed callbacks fail or return the stored idempotent result. +- `FAL-005`: 31 duplicate deliveries yield one state transition, one artifact + fetch and no duplicate charge/promotion. +- `FAL-006`: a caller-supplied or merely syntax-valid fal owner/app is rejected; + submission is bound to a server-configured approved app and API-scoped key. + Result metadata and every artifact download stage fail closed at the exact + digest-bound expiry; status and cancellation remain available afterward only + for reconciliation/cleanup and cannot restore result authority. +- `FAL-007`: a submit timeout or connection loss enters `REMOTE_UNKNOWN`, + consumes one attempt reservation, and cannot be retried until the idempotency + ledger reconciles the original logical job. +- `FAL-008`: provider error JSON is discarded without storing or formatting its + body; only a bounded allowlisted error class may reach logs or API output. +- `FAL-009`: provider JSON cannot deserialize a trusted training outcome or set + `production_signed`; app/request/job/kind substitution and missing/extra + artifacts fail before download or promotion. +- `FAL-010`: the exact hosted image context is enumerated before upload and + contains only the three crate manifests/source trees, lockfile/build script, + and exact Fal launcher/Dockerfile inputs; tests, benches, documentation, an + untracked request, dataset, `.env`, credential, raw/minimal CSI fixture, or + unrelated repository path cannot enter the archive. +- `FAL-011`: worker build identity is selected from immutable deployment + configuration and verified by the worker against its own embedded identity; + request text cannot supply or encode that value. +- `FAL-012`: `fal run` and `fal deploy` select the exact Direct Server symbol + and private auth; an unauthenticated live probe receives 401/403 before the + hosted operational gate may close. +- `SSRF-001`: reject HTTP, file/data schemes, credentials, non-443 ports, + suffix-confusion hosts, redirect hops, loopback/private/link-local/metadata, + IPv4-in-IPv6, integer/encoded IPs and DNS rebinding. +- `PATH-001`: reject absolute paths, `..`, mixed separators, NUL, symlink/hardlink + escapes, special files, duplicate normalized archive entries, and races. +- `PATH-002`: truncated/oversized download leaves no activatable partial file; + cleanup remains below the per-job capability directory. +- `PATH-003`: artifact download accepts only a capability-scoped quarantine + handle and fixed filename; absolute/caller destination paths, symlink parents, + FIFOs, devices, sockets and cross-job descriptor substitution are rejected. + +### Artifact, deserialization, and evidence + +- `ART-001`: one changed model byte, changed manifest field, wrong payload + length/hash, unknown signer, bad signature, missing signature, wrong domain, + expired manifest and rollback epoch all reject. +- `ART-002`: reject missing/extra/duplicate tensor, wrong dtype/rank/shape, + overflowed product, overlapping/out-of-range offset, huge header, truncation, + NaN/Inf parameter, and unsupported runtime version. +- `ART-003`: fal can produce a candidate but cannot produce an activatable + production signature; assert the release private key is absent from fal and + ordinary PR CI configuration. +- `ART-004`: crash at every verification/rename/activation boundary preserves + either the previous valid state or the new fully verified state, never a + partial state. +- `ART-005`: candidate generation cannot select a production release epoch; + local promotion atomically allocates the next persisted epoch and binds the + same-epoch digest across restart. +- `DE-001`: direct JSON cannot mint `Principal`, `VerifiedMeasurement`, trusted + artifact, active model, measured evidence, or an arbitrary-capacity evidence + ledger. +- `EVID-001`: hosted completion is `CLAIMED` candidate provenance; only the + frozen local reproducer can attach measured evaluation, and an unauthenticated + RF root caps downstream evidence. +- `EVID-002`: forecast and RuVLLM explanation cannot mutate numeric observation, + forecast, quantiles, provenance hash, evidence level, or action authority. + +## CI and release gates + +Keep checks scoped to the new packages where the repository has inherited +format/advisory debt; do not claim unrelated workspace debt is green. + +```bash +cd v2 + +cargo +1.89.0 fmt \ + -p ruview-forecast-core -p ruview-forecast-model -p ruview-forecast-train \ + -- --check + +cargo +1.89.0 test --locked -p ruview-forecast-core \ + --no-default-features --lib --tests +cargo +1.89.0 test --locked -p ruview-forecast-model \ + --no-default-features --features ruvector --lib --tests +cargo +1.89.0 clippy --locked -p ruview-forecast-model \ + --no-default-features --features ruvector --all-targets -- -D warnings + +FAL_KEY='' cargo +1.89.0 test --locked -p ruview-forecast-train \ + --no-default-features --features cli,server,fal-client --lib --bins --tests +FAL_KEY='' cargo +1.89.0 clippy --locked -p ruview-forecast-train \ + --no-default-features --features cli,server,fal-client --all-targets -- -D warnings + +cargo +1.92.0 test --locked -p ruview-forecast-model \ + --no-default-features --features cpu --lib --tests +cargo +1.92.0 test --locked -p ruview-forecast-train \ + --no-default-features --features cpu,cli,server,fal-client --lib --bins --tests +cargo +1.92.0 clippy --locked -p ruview-forecast-model \ + --no-default-features --features cpu --all-targets -- -D warnings +cargo +1.92.0 clippy --locked -p ruview-forecast-train \ + --no-default-features --features cpu,cli,server,fal-client \ + --all-targets -- -D warnings +``` + +Do not replace this matrix with `--all-features`: that would conflate mutually +different backend/toolchain prerequisites and could imply CUDA validation on a +runner without the pinned CUDA 12 build environment. The dedicated CUDA job +must compile and test only after its image, toolkit, driver floor, and GPU +capability are pinned. + +Dependency and repository policy gates: + +```bash +cargo audit --file v2/Cargo.lock --json +cargo deny --manifest-path v2/Cargo.toml --config v2/deny.toml \ + check advisories bans licenses sources +bash scripts/csi-data-policy-check.sh --self-test +bash scripts/csi-data-policy-check.sh --tracked +``` + +`.github/workflows/security-scan.yml:17-42` already hard-gates the checked-in +lockfile with pinned cargo-audit `0.22.2`. Secret scanners at `:277-314` and the +Python license scan at `:316-351` are currently `continue-on-error`; forecast +paths need a blocking changed-files secret gate for `FAL_KEY`, private keys, +JWTs, CDN tokens and presigned URLs. The repository also records inherited +advisory/yanked dependency debt, so cargo-deny MUST use exact, reviewed baseline +exceptions with owner, reason and expiry, while blocking every new advisory, +unapproved source and unapproved license. + +Add these release jobs: + +- libFuzzer/cargo-fuzz targets for request, manifest, tensor header, webhook and + any archive/dataset parser; preserve crashing inputs as non-person synthetic + regression fixtures; +- proptest state machines for dimensions/products, job transitions, tenant + isolation, quantile order, path normalization and artifact crash consistency; +- container scan for the pinned fal/local training image, locked Python worker + dependencies if any, CycloneDX SBOM, and signed SLSA-style provenance; +- artifact negative-test matrix (`ART-*`) before any release-signing job; +- a clean-room manifest/contributor/source scan and assertion that no prohibited + source, weight, output or derived artifact entered code, tests, training or + evaluation; +- a data-egress test that captures the complete fal request/upload and proves + prohibited privacy classes, identifiers, tokens and missing retention/ACL + headers never cross TB3; +- a secret-isolation assertion that the release signing key is unavailable to + fal, untrusted fork PRs, test logs and artifact-upload jobs; +- image and dependency digests pinned rather than mutable tags/ranges, with + SBOM/license/advisory reports retained beside the signed release manifest. + +Production promotion is blocked unless every privacy, auth, SSRF/path, +deserialization, artifact, evidence, clean-room and CI contract above is green. +Passing synthetic tests remains `SYNTHETIC`; it is not a measured security or +accuracy claim for real RF deployments. + +## Validation performed for this review + +The deterministic repository guard was executed against the reviewed tree: + +```text +scripts/csi-data-policy-check.sh --self-test -> 6 passed, 0 failed +scripts/csi-data-policy-check.sh --tracked -> OK, no violations +``` + +The validated Ruflo CLI `3.25.6` ran read-only `--depth deep --type all` scans +against each of the three forecast crates and a separate `--type deps` scan +against `v2`; all four runs reported zero signals. Its secret command reported +zero findings but also reported scanning only four training-crate files, so +that result is coverage-limited and does not replace the PR's explicit tracked +secret/privacy scans. Ruflo did not expose an advisory-feed timestamp. The +final `v2/Cargo.lock` SHA-256 for these runs was +`78c223d4db3bfd43f0748c7baddce45df9af1357cabe5912d9cb62a2213c1957`. +Scanner output was treated as an untrusted signal and no active probe or exploit +was run. `cargo-audit`, `cargo-deny`, and `gitleaks` were not installed locally; +therefore no fresh ecosystem vulnerability result is claimed and the release +dependency gate remains open. + +## External operational references + +Fal documentation was read on 2026-09-01; provider behavior must be rechecked +before production because retention, retries, hosts and APIs can change. + +- [Fal data retention and storage](https://fal.ai/docs/documentation/model-apis/media-expiration) +- [Fal file access controls](https://fal.ai/docs/documentation/model-apis/file-access-controls) +- [Fal persistent `/data` storage](https://fal.ai/docs/documentation/development/use-persistent-storage) +- [Fal working with files](https://fal.ai/docs/documentation/development/working-with-files) +- [Fal webhook verification and retry policy](https://fal.ai/docs/documentation/model-apis/inference/webhooks) +- [Fal API key scopes](https://fal.ai/docs/documentation/setting-up/authentication) diff --git a/docs/validation/2026-08-31-esp32-c6-node7-rate-aware-sensing.md b/docs/validation/2026-08-31-esp32-c6-node7-rate-aware-sensing.md new file mode 100644 index 000000000..0b39148d0 --- /dev/null +++ b/docs/validation/2026-08-31-esp32-c6-node7-rate-aware-sensing.md @@ -0,0 +1,120 @@ +# ESP32 C6 node 7 rate aware sensing qualification + +## Scope + +This record qualifies the firmware 0.8.8 timing and transport path on a second +physically attached ESP32 C6. It measures raw callback cadence, edge DSP +cadence, process stability, and end to end sensing delivery. It does not +qualify heartbeat, respiration, pose, identity, room separation, or person +count accuracy against labelled ground truth. + +## Hardware and firmware + +| Field | Measured value | +|---|---| +| Board | ESP32 C6 QFN40 revision 0.2 | +| Logical node | 7 | +| Firmware before | 0.8.4 | +| Firmware after | 0.8.8 development build | +| App image | 1,051,552 bytes | +| App SHA 256 | `eab7561d65e302dc33e9331ac591763a46f92fb3fa9f824fef0e9b541daddb3f` | +| OTA slot size | 1,900,544 bytes | +| OTA headroom | 848,992 bytes, 45 percent | + +Only the application partition at offset `0x20000` was flashed. WiFi +credentials, logical node identity, sensing server target, channel, edge tier, +bootloader, partition table, OTA metadata, and NVS were preserved. The pre +update application was copied to a private recovery file outside the +repository. Its SHA 256 is +`f5ebc5e0142425adae16e9180bf298ef444ea8862ba0d8809c310a39fe45721d`. +The device partition table was also read before the update and had SHA 256 +`0a8d2f192a8fff209d6c75ab639fcf8aa2f43c64abb732c6e74596fbd6971dca`. + +The post update boot log reported firmware 0.8.8, node 7, channel 10, Tier 2, +an 8 Hz edge DSP cadence, and the preserved sensing server target. The OTA +status endpoint reported firmware 0.8.8 running from `ota_0`, with `ota_1` as +the next partition and the correct 1,900,544 byte limit. The sensing server +health endpoint remained ready with ESP32 input. + +## Before and after + +The pre update baseline was a 20 second observation on the same attached board. +The post update observation was a five minute steady state run after boot. + +| Observation | Before 0.8.4 | After 0.8.8 | +|---|---:|---:| +| Raw callback mean | 39.05 pps | 36.32 pps | +| Raw callback range | 35 through 42 pps | 24 through 42 pps | +| Server CSI FPS mean | 46.23 Hz | 48.88 Hz | +| WebSocket parser errors | 0 | 0 | +| WebSocket reconnects | 0 | 0 | + +Raw callback mean changed by negative 7.0 percent while the server CSI FPS +estimate changed by positive 5.7 percent. Both remain above the 20 pps +transport floor. The result is transport neutral rather than an accuracy lift; +the room and WiFi traffic were not controlled between the two windows. + +Firmware 0.8.4 did not expose the edge DSP cadence used by the temporal +filters. Firmware 0.8.8 held that separately governed clock at exactly 8.0 Hz +for every controller sample while preserving the higher rate raw network path. + +## Five minute physical result + +MEASURED on 2026 08 31 after flashing firmware 0.8.8: + +| Device observation | Result | +|---|---:| +| Duration | 300.70 seconds | +| Controller samples | 300 | +| Raw callback mean | 36.32 pps | +| Raw callback range | 24 through 42 pps | +| Edge DSP mean | 8.00 Hz | +| Edge DSP range | 8.00 through 8.00 Hz | +| ENOMEM events | 0 | +| UDP send failures | 0 | +| ESP NOW nonzero failure lines | 0 | +| Other steady state errors | 0 | +| Watchdogs, panics, or reboots | 0 | + +| End to end WebSocket observation | Result | +|---|---:| +| Duration | 300.06 seconds | +| Sensing frames | 36,254 | +| JSON parse errors | 0 | +| WebSocket errors or reconnects | 0 | +| Frames containing node 7 | 35,313 | +| Node 7 frame coverage | 97.40 percent | +| Node 7 stale frames | 0 | +| Maximum node 7 staleness | 972 ms | +| Maximum node 7 inference age | 483 ms | +| Maximum WebSocket frame gap | 108 ms | +| Nodes per frame | 0 through 5 | +| Fused presence count contradictions | 0 | + +One ENOMEM backoff occurred during startup and recovered in 210 ms. No memory +backoff or send failure recurred in the separate five minute steady state +window. The boot log also reported the documented fail closed OTA behavior: +the status service was available, but image upload remained rejected because +this node has no provisioned OTA signing secret. + +## Result and limitation + +The node 7 timing and transport update passes. Its configuration survived, the +edge DSP clock remained phase stable at the measured sustainable C6 rate, and +the live service received fresh node 7 data throughout the run. This does not +complete the ADR 346 occupancy qualification for node 7 because the room was +not held empty and the live aggregate did not expose 30 absent edge packets. + +The largest uncertainty remains inference accuracy. Timing stability cannot +prove better vital, motion, room separation, or multi person estimates without +synchronized held out labels and a controlled empty room sequence. + +## Acceptance test + +Repeat this five minute procedure after timing, WiFi, filter, or scheduling +changes. Pass transport only when raw callback yield remains at least 20 pps, +DSP cadence stays within one hertz of the configured target, the device has +zero steady state memory backoff, send failure, watchdog, panic, and reboot +events, the server has zero parse failures and reconnects, and the updated node +stays fresh. Complete occupancy qualification separately with at least 30 +absent edge packets and zero absent packets carrying a nonzero person count. diff --git a/docs/validation/2026-08-31-esp32-c6-occupancy-integrity.md b/docs/validation/2026-08-31-esp32-c6-occupancy-integrity.md new file mode 100644 index 000000000..9446bd944 --- /dev/null +++ b/docs/validation/2026-08-31-esp32-c6-occupancy-integrity.md @@ -0,0 +1,60 @@ +# ESP32 C6 occupancy evidence qualification + +## Scope + +This record qualifies the fail closed person count invariant in ADR 346 on one physically attached ESP32 C6. It does not qualify person counting accuracy, identity, pose, room separation, or vital sign accuracy. + +## Hardware and firmware + +| Field | Measured value | +|---|---| +| Board | ESP32 C6 QFN40 revision 0.2 | +| Logical node | 4 | +| Firmware before | 0.7.0 | +| Firmware after | 0.8.4 development build | +| App image | 1,051,168 bytes | +| App SHA 256 | `f9470a31b82612f1740f0cf0943ddb78917cd58784ba16d2e9d57cc8fb39364c` | +| OTA slot size | 1,900,544 bytes | +| CSI stream target | Preserved from NVS | + +The device partition table was read before the update. NVS, OTA metadata, bootloader, and partition table were not overwritten. A private recovery copy was created outside the repository and excluded from version control. + +## Software gates + +| Gate | Result | +|---|---| +| Firmware host tests | PASS, 54 assertions across encoding, vital evidence, and mmWave detection | +| Rust sensing server package | PASS, 532 library tests plus all package integration and documentation tests | +| Mobile Jest suite | PASS, 164 suites and 1,223 tests | +| Mobile TypeScript | PASS | +| Mobile ESLint | PASS | +| Mobile security verifier | PASS | +| Repository wide Rust formatting | PREEXISTING DRIFT outside this change; changed code builds and package tests pass | + +## Physical result + +MEASURED on 2026 08 31 from the live local sensing WebSocket for 300 seconds: + +| Node | Firmware state | Edge packets | Absent packets | Absent with nonzero count | Result | +|---|---|---:|---:|---:|---| +| 4 | Updated | 242 | 61 | 0 | PASS | +| 3 | Unupdated control | 216 | 216 | 216 | Expected control failure | +| 7 | Unupdated control | 280 | 278 | 278 | Expected control failure | + +Node 4 reduced the targeted logical contradiction from observed to zero, a 100 percent reduction for this invariant during this run. This is not a person count accuracy result. + +The WebSocket run had zero JSON parse errors and one expected client close at completion. The sensing server remained ready with `engine_error_count=0`. A separate 45 second serial observation recorded 120 log lines, 17 CSI callback markers, zero ENOMEM backoffs, and zero other error lines. + +The updated OTA status endpoint reports the selected 1,900,544 byte partition rather than the stale 921,600 byte constant. The 1,051,168 byte image therefore fits with 849,376 bytes of partition headroom. + +## Remaining qualification + +Nodes 3 and 7 still demonstrate the old contradictory behavior and must be upgraded only after their network identity, OTA credential, and rollback path are verified. The current run had no labelled ground truth, so multi person fidelity and adjacent room rejection remain unmeasured. + +## Subsequent node 7 status + +Later on 2026 08 31, node 7 was separately identified, backed up, upgraded to firmware 0.8.8, and transport qualified for five minutes. That later occupied room run had zero fused presence count contradictions but did not produce the 30 absent edge packets required to supersede the historical control result above. See `docs/validation/2026-08-31-esp32-c6-node7-rate-aware-sensing.md`. + +## Acceptance test + +Repeat a five minute capture after every firmware change. Pass only when every updated node has at least 30 absent packets, zero packets where `presence=false` and `n_persons>0`, zero parser errors, and a ready sensing server with zero engine errors. diff --git a/docs/validation/2026-08-31-esp32-c6-rate-aware-sensing.md b/docs/validation/2026-08-31-esp32-c6-rate-aware-sensing.md new file mode 100644 index 000000000..ac394d694 --- /dev/null +++ b/docs/validation/2026-08-31-esp32-c6-rate-aware-sensing.md @@ -0,0 +1,124 @@ +# ESP32 C6 rate aware sensing qualification + +## Scope + +This record qualifies ADR 347 on one physically attached ESP32 C6 and verifies +that the same source compiles for ESP32 S3. It measures transport cadence, edge +DSP cadence, process stability, and end to end sensing delivery. It does not +qualify heartbeat, respiration, gesture, pose, identity, or person count +accuracy against labelled ground truth. + +## Hardware and firmware + +| Field | Measured value | +|---|---| +| Board | ESP32 C6 QFN40 revision 0.2 | +| Logical node | 4 | +| Firmware before | 0.8.4 | +| Firmware after | 0.8.8 development build | +| C6 app image | 1,051,552 bytes | +| C6 app SHA 256 | `f2ea422c9b99ec13c7a168afc2b019229642769ffabfd8f29a85978770236e87` | +| OTA slot size | 1,900,544 bytes | +| OTA headroom | 848,992 bytes, 45 percent | +| S3 compile image | 1,127,104 bytes | +| S3 compile SHA 256 | `63e4f0c484d79e7dd37eb28275951c8beb924e6908942f7fec0b90d92109129c` | + +Only the application partition at offset `0x20000` was flashed. WiFi +credentials, node identity, sensing server target, bootloader, partition table, +OTA metadata, and NVS were preserved. The pre update OTA application was read +to a private recovery file outside the repository. Its SHA 256 is +`a2e503f1622b2f3f9c1cfce0a07ba34b9fc5d6a413b6346311622af1fe18a6d8`. + +The OTA status endpoint reported firmware 0.8.8 running from `ota_0` after the +update. The sensing server health endpoint remained ready with ESP32 input. + +## Software gates + +| Gate | Result | +|---|---| +| Rate estimator and occupancy host tests | PASS, 30 assertions | +| ADR 110 encoding host tests | PASS, 21 assertions | +| mmWave frame predicate host tests | PASS, 8 assertions | +| ESP32 C6 IDF 5.4 ARM64 build | PASS | +| ESP32 S3 IDF 5.4 ARM64 build | PASS, compile only | +| Image checksum and validation hash | PASS | +| Repository diff whitespace check | PASS | +| Local libFuzzer aggregate | NOT RUN, local Xcode toolchain lacks `libclang_rt.fuzzer_osx.a` | + +For this C6 record, the S3 result was source and toolchain validation only and +no S3 runtime claim is made here. The later physical S3 Tier 0 transport run is +recorded separately in +`docs/validation/2026-08-31-esp32-s3-rate-aware-transport.md`. + +## Measured rate correction + +The pre update 20 second C6 baseline delivered a mean 34.05 raw callbacks per +second, median 34.5, and range 28 through 37. An intermediate 0.8.7 physical +run requested 10 Hz edge DSP but converged to 8.0 through 8.4 Hz while raw CSI +remained 30 through 40 packets per second. This proved that C6 Tier 2 compute, +not the raw transport, was the limiting path. + +Firmware 0.8.8 therefore keeps the 50 Hz probe and independent raw network +path, but sets the C6 Tier 2 DSP clock to its measured sustainable 8 Hz. The +phase preserving sampler prevents callback jitter from shifting the configured +clock, and the filter estimator follows processed timestamps rather than raw +probe intent. + +## Five minute physical result + +MEASURED on 2026 08 31 after flashing firmware 0.8.8: + +| Device observation | Result | +|---|---:| +| Duration | 300.64 seconds | +| Controller ticks | 300 | +| Raw callback mean | 34.92 pps | +| Raw callback range | 22 through 41 pps | +| Edge DSP mean | 8.00 Hz | +| Edge DSP range | 8.00 through 8.00 Hz | +| ENOMEM events | 0 | +| UDP send failures | 0 | +| Other steady state errors | 0 | +| Watchdogs, panics, or reboots | 0 | + +| End to end WebSocket observation | Result | +|---|---:| +| Duration | 300.01 seconds | +| Sensing frames | 26,786 | +| JSON parse errors | 0 | +| Reconnects | 0 | +| Frames containing node 4 | 26,148 | +| Node 4 frame coverage | 97.62 percent | +| Node 4 stale frames | 0 | +| Maximum node 4 inference age | 176 ms | +| Maximum WebSocket frame gap | 110 ms | +| Nodes per frame | 0 through 4 | +| Fused `presence=false` with nonzero count contradictions | 0 | + +The boot log emitted one expected iTWT negotiation error because the access +point rejected the requested target wake time parameters. Firmware immediately +selected its documented opportunistic CSI fallback. No iTWT or other error +recurred during the five minute steady state window. + +## Result and limitation + +ADR 347 timing and transport acceptance passes on the attached C6. Raw +throughput did not regress relative to the short baseline, the edge clock now +matches the rate the temporal filters actually receive, and node 4 was never +stale when present in the live sensing service. The separate occupancy +qualification recorded 61 absent node 4 packets with zero contradictions for +the unchanged fail closed invariant. This run did not repeat an empty room +sequence because the room was occupied during qualification. + +The largest remaining uncertainty is inference accuracy. Stable timing removes +one source of feature distortion but cannot prove better heartbeat, respiration, +gesture, or multi person classification without synchronized held out labels. + +## Acceptance test + +Repeat this five minute procedure after any timing, WiFi, filter, or task +scheduling change. Pass only when raw callback yield remains at least 20 pps, +DSP cadence remains within one hertz of the configured target, the device has +zero steady state ENOMEM, send failure, watchdog, panic, and reboot events, the +server has zero parse failures and reconnects, node 4 stays fresh, and fused +presence count contradictions remain zero. diff --git a/docs/validation/2026-08-31-esp32-s3-rate-aware-transport.md b/docs/validation/2026-08-31-esp32-s3-rate-aware-transport.md new file mode 100644 index 000000000..4899f1dbc --- /dev/null +++ b/docs/validation/2026-08-31-esp32-s3-rate-aware-transport.md @@ -0,0 +1,126 @@ +# ESP32 S3 rate aware transport qualification + +## Scope + +This record qualifies the firmware 0.8.8 raw transport path on one physically +attached ESP32 S3. The node retained its existing Tier 0 configuration, so this +run does not qualify the S3 edge DSP rate, temporal filters, heartbeat, +respiration, gesture, pose, identity, person count, or localization accuracy. + +## Hardware and firmware + +| Field | Measured value | +|---|---| +| Board | ESP32 S3 QFN56 revision 0.2 with 2 MB embedded PSRAM | +| Logical node | 1 | +| Firmware before | 0.8.4 | +| Firmware after | 0.8.8 development build | +| Edge tier | 0, raw passthrough | +| App image | 1,127,104 bytes | +| App SHA 256 | `b531c76900c07d0d6f6e864a5f28afff3e71f124777af97358bb405b34e339a2` | +| OTA slot size | 2,097,152 bytes | +| OTA headroom | 970,048 bytes, 46 percent | + +The production partition table was read from the device before the update. +Only the application partition at offset `0x20000` was flashed. WiFi +credentials, logical node identity, channel, sensing server target, bootloader, +partition table, OTA metadata, and NVS were preserved. A private recovery copy +of the prior 2 MB application partition was saved outside the repository. Its +SHA 256 is +`14e72c060c4f1a465f739949b873f6aade5b8899a8909f6c5bb591ee49837c1b`. + +The post update boot log reported firmware 0.8.8, logical node 1, channel 4, +the preserved UDP target, Tier 0 raw passthrough, and successful CSI streaming. +The OTA status endpoint reported firmware 0.8.8 running from `ota_0` with the +correct 2,097,152 byte update limit. The sensing server health endpoint remained +ready with ESP32 input. + +## Software and image gates + +| Gate | Result | +|---|---| +| Firmware encoding, vitals, occupancy, and mmWave host tests | PASS, 59 assertions | +| Firmware provisioning Python tests | PASS, 14 tests | +| ESP32 S3 IDF 5.4 ARM64 clean build | PASS | +| Image target detection | PASS, ESP32 S3 | +| Image checksum | PASS | +| Image validation hash | PASS | +| Application partition fit | PASS, 46 percent free | +| Physical flash write verification | PASS | +| Preserved runtime configuration | PASS | + +The build excluded the optional WASM3 source because it was not present in the +firmware checkout. The boot log therefore reported WASM Tier 3 disabled. That +is not a regression introduced by this update and is outside this transport +qualification. + +## Before and after comparison + +The pre update baseline was a 20 second serial and WebSocket capture on firmware +0.8.4. The post update stability observation was 300 seconds on firmware 0.8.8. + +| Observation | Before 0.8.4 | After 0.8.8 | Change | +|---|---:|---:|---:| +| Raw CSI yield mean | 27.80 pps | 28.03 pps | plus 0.83 percent | +| Server CSI FPS mean | 39.58 | 39.13 | minus 1.15 percent | +| Node frame coverage | 100 percent | 100 percent | unchanged | +| Maximum node staleness | 1,571 ms | 1,565 ms | minus 0.38 percent | +| Maximum WebSocket frame gap | 111 ms | 112 ms | plus 0.90 percent | +| Device or parser errors | 0 | 0 | unchanged | + +These small movements are operationally neutral and within uncontrolled room +and WiFi variation. Firmware 0.8.8 did not regress the raw transport. Because +Tier 0 bypasses the DSP task, this run provides no evidence that temporal +features or inference accuracy improved. + +## Five minute physical result + +MEASURED on 2026 08 31 after flashing firmware 0.8.8: + +| Device observation | Result | +|---|---:| +| Duration | 300 seconds | +| Controller yield samples | 300 | +| Raw CSI yield mean | 28.03 pps | +| Raw CSI yield range | 22 through 34 pps | +| ENOMEM or stack errors | 0 | +| UDP send failures | 0 | +| ESP NOW send failures | 0 | +| Unexpected resets | 0 | +| Watchdogs or panics | 0 | + +| End to end WebSocket observation | Result | +|---|---:| +| Duration | 300.03 seconds | +| Sensing frames | 10,559 | +| Frames containing node 1 | 10,559 | +| Node 1 frame coverage | 100 percent | +| Source offline frames | 0 | +| JSON parse errors | 0 | +| WebSocket errors | 0 | +| Early closes | 0 | +| Maximum node staleness | 1,565 ms | +| Maximum WebSocket frame gap | 112 ms | + +## Result and limitation + +The ESP32 S3 raw transport acceptance passes. Firmware 0.8.8 booted from the +existing slot, retained the installation configuration, sustained the prior raw +CSI delivery rate, and completed the burn with zero transport or runtime +errors. The result extends ADR 347 physical coverage to the S3 transport path. + +The largest remaining uncertainty is S3 Tier 2 behavior and inference accuracy. +The log line reporting the configured 20 Hz DSP cadence is not proof that DSP +ran because the preserved Tier 0 setting explicitly disables the DSP task. A +separate, rollback protected Tier 2 qualification with synchronized held out +labels is required before making heartbeat, respiration, motion, or accuracy +claims. + +## Acceptance test + +Repeat this five minute procedure after any S3 timing, WiFi, transport, or task +scheduling change. Pass only when raw callback yield remains at least 20 pps, +node frame coverage remains at least 99 percent, and the device and server have +zero send failures, offline frames, parser errors, WebSocket errors, watchdogs, +panics, and unexpected resets. Qualify Tier 2 separately and require its +measured DSP cadence to stay within one hertz of the configured target. diff --git a/docs/validation/ruforecast-requirements-evidence.md b/docs/validation/ruforecast-requirements-evidence.md new file mode 100644 index 000000000..f8f6a09e9 --- /dev/null +++ b/docs/validation/ruforecast-requirements-evidence.md @@ -0,0 +1,206 @@ +# RuForecast requirement to evidence matrix + +## Authority and current state + +This matrix operationalizes ADR-348 and its ADR-349/350 children without +changing them. The ADRs own the requirements and gates. Rust tests, immutable +reports, signed receipts, and explicit human approvals supply evidence. A +checked box or green build is not permission to deploy, publish a checkpoint, +upload customer-derived data, or advance the rollout mode. + +Current state: all release and operational gates are open. No benchmark result, +trained checkpoint, external training receipt, or production authority is +recorded by this document. + +## Focused verification commands + +The feature-off contract remains on the workspace Rust 1.89 line: + +```bash +cd v2 +cargo +1.89.0 test --locked -p ruview-forecast-core --no-default-features --lib --tests +cargo +1.89.0 test --locked -p ruview-forecast-model --no-default-features --lib --tests +cargo +1.89.0 check --locked -p ruview-forecast-model -p ruview-forecast-train --no-default-features --all-targets +cargo +1.89.0 test --locked -p ruview-forecast-model --no-default-features --features ruvector --lib --tests +cargo +1.89.0 clippy --locked -p ruview-forecast-core -p ruview-forecast-model -p ruview-forecast-train --no-default-features --all-targets -- -D warnings +cargo +1.89.0 clippy --locked -p ruview-forecast-model --no-default-features --features ruvector --all-targets -- -D warnings +``` + +Burn 0.21 CPU activation uses the explicitly separate Rust 1.92 line: + +```bash +cd v2 +cargo +1.92.0 test --locked -p ruview-forecast-model --no-default-features --features cpu --lib --tests +cargo +1.92.0 test --locked -p ruview-forecast-train --no-default-features --features cpu,cli --lib --bins +cargo +1.92.0 test --locked -p ruview-forecast-train --no-default-features --features cpu --test local_jsonl_smoke -- --exact local_hash_addressed_jsonl_executes_one_real_optimizer_step +cargo +1.92.0 test --locked -p ruview-forecast-train --no-default-features --features cpu,cli --test cli_smoke -- --exact cli_smoke_trains_and_writes_the_complete_candidate_set +cargo +1.92.0 test --locked -p ruview-forecast-train --no-default-features --features cpu,cli,server,fal-client --lib --bins +cargo +1.92.0 clippy --locked -p ruview-forecast-model --no-default-features --features cpu --all-targets -- -D warnings +cargo +1.92.0 clippy --locked -p ruview-forecast-train --no-default-features --features cpu,cli,server,fal-client --all-targets -- -D warnings +``` + +The CUDA line is compile-only. It proves that the explicitly gated types build +on Rust 1.92; it is not a GPU execution, training, latency, or compatibility +claim: + +```bash +cd v2 +cargo +1.92.0 check --locked -p ruview-forecast-model --no-default-features --features cuda --lib +cargo +1.92.0 check --locked -p ruview-forecast-train --no-default-features --features cuda,cli,server --lib --bins +``` + +The hosted boundary stays backend-free and is exercised on Rust 1.89 with no +provider credential. Tests must use local mocks; compiling these features does +not authorize network use or a hosted training run: + +```bash +cd v2 +cargo +1.89.0 tree --locked -e normal,build -p ruview-forecast-train --no-default-features --features cli,server,fal-client > forecast-hosted-feature-tree.txt +! grep -Eiq '(^|[[:space:]])(burn|cubecl)(-|[[:space:]])' forecast-hosted-feature-tree.txt +FAL_KEY='' cargo +1.89.0 test --locked -p ruview-forecast-train --no-default-features --features cli,server,fal-client --lib --bins --tests +FAL_KEY='' cargo +1.89.0 test --locked -p ruview-forecast-train --no-default-features --features cli,server,fal-client privacy_external_dataset_payload_is_denied +FAL_KEY='' cargo +1.89.0 clippy --locked -p ruview-forecast-train --no-default-features --features cli,server,fal-client --all-targets -- -D warnings +``` + +The broader repository gate remains: + +```bash +cd v2 +cargo +1.89.0 test --locked --workspace --no-default-features +cargo +1.89.0 bench --locked --workspace --no-default-features --no-run +``` + +No CUDA runtime result is implied by these commands. CUDA execution needs a +separately identified GPU, driver, toolkit, source commit, container digest, +and signed training or inference receipt. + +The repository-wide security workflow separately hard-gates the checked-in +lockfile with `cargo audit --file v2/Cargo.lock --json`. The forecast workflow +also runs both modes of `scripts/csi-data-policy-check.sh`, rejects forbidden +clean-room source/import names and model blobs, checks the hosted DTO field +surface, and blocks common literal private-key, FAL-key, JWT, cloud-key and +presigned-URL patterns. These focused checks do not close dependency-license or +complete secret-scanning evidence: the repository has no committed +`v2/deny.toml`, inherited advisory/yanked-version debt needs an +owner/date/expiry baseline, and the existing general secret scanners are +non-blocking. Until dedicated retained reports exist, those parts of G0, G1, +and G5 remain open. + +### Supply-chain and secret release prerequisites + +| Check | Reproducer | Present authority | +|---|---|---| +| Advisory database | `cargo audit --file v2/Cargo.lock --json` | Existing blocking workflow; report retained | +| Advisory warnings | `cargo audit --file v2/Cargo.lock --deny warnings` | Not green by assertion; inherited warning debt needs an expiring reviewed baseline | +| Rust sources/licenses/bans | `cargo deny --manifest-path v2/Cargo.toml --config v2/deny.toml check advisories bans licenses sources` | Blocked until a reviewed `v2/deny.toml` is committed and CI pins `cargo-deny` | +| Forecast-path secrets | `gitleaks detect --no-banner --redact --source .` | Focused CI blocks common literal patterns; the broader GitLeaks job/report is still non-blocking | +| Tracked sensing data | `bash scripts/csi-data-policy-check.sh --self-test && bash scripts/csi-data-policy-check.sh --tracked` | Blocking in the focused hosted-boundary job | + +`cargo vet check` becomes a release prerequisite only if the project commits a +vet policy and review process; listing it without that policy would be a false +supply-chain claim. + +### Existing baseline debt and scope + +- [`../benchmarks/physics-pose-refinement.md`](../benchmarks/physics-pose-refinement.md) + records unrelated workspace rustfmt/warning debt, an incomplete full-workspace + test run on that authoring host, existing RustSec debt, and yanked + `spin 0.9.8` in the optional Burn graph. Forecast CI therefore uses focused + warnings-denied gates and does not describe the whole workspace as green. +- [`.github/workflows/bench-regression.yml`](../../.github/workflows/bench-regression.yml) + records an upstream stable-Rust failure in optional `ruvector-crv`; the + focused forecast benchmarks neither enable nor inherit a CRV claim. +- The current ADR corpus contains pre-existing duplicate ADR-263 and ADR-264 + numbers. The focused contract rejects a duplicate ADR-348, ADR-349 or ADR-350 + without falsely asserting that the historic corpus already passes a global + uniqueness lint. +- Shared GitHub-hosted timing is noisy by repository policy. Only benchmark + compilation gates the PR; timing logs are informational until repeated on a + named, controlled host. + +## Requirements + +| Requirement | Machine evidence | Human or external evidence | Acceptance authority | Current state | +|---|---|---|---|---| +| RF-001 | Forecast-path source and artifact scan; forbidden model/blob fixture scan; clean-room manifest schema and tamper tests | Current contributor attestations and clean-room custodian adjudication | ADR-348 G0 | OPEN | +| RF-002 | Canonical receipt round trip; digest/signature tamper negatives; source, config, dataset and checkpoint identifiers required | Reviewer verifies every dependency, reference, job and checkpoint lineage | ADR-348 G0 and G1 | OPEN | +| RF-003 | Dataset manifest rejects missing license, incompatible use, unknown privacy class and unresolved bytes | Data steward and legal approval for every immutable dataset digest | ADR-348 G1 | OPEN | +| RF-004 | Feature-off dependency boundary excludes Burn/CubeCL; fixed artifact/input CPU replay; offline load tests; bounded property and fuzz corpus | Named platform-class review and 24-hour accelerated replay | ADR-348 G2 | OPEN | +| RF-005 | Shape/product overflow, finite-value, timestamp, mask, schema, horizon, quantile and resource-limit tests | Review of production input caps and abstention thresholds | ADR-348 G2 | OPEN | +| RF-006 | Forecast evidence label is immutable; observation hash round trips; confidence cannot increase; derived values cannot overwrite observations | Evidence-engine and downstream schema review | ADR-348 G2 and G4 | OPEN | +| RF-007 | Frozen split manifest; train-only normalization/calibration; quantile order; interval coverage and loss by horizon/domain | Independent evaluation report on untouched site/session/device holdouts | ADR-348 G3 | OPEN | +| RF-008 | Per-split RuVector index isolation; no overlapping horizon or holdout neighbour; paired retrieval-off/retrieval-on report | Reviewer verifies index manifest and ablation comparability | ADR-348 G3 | OPEN | +| RF-009 | Capability tests prove the forecast and RuVLLM explanation have no actuator, spending, access-control, emergency or model-promotion authority | Downstream policy owner approves any advisory consumer | ADR-348 G4 | OPEN | +| RF-010 | Local and hosted receipts compare source, lock, container, data, config and initial-weight digests; provider result is untrusted until verified | Provider retention, log, credential and data-processing review | ADR-348 G1 and G5 | OPEN | +| RF-011 | Benchmark report binds commit, lock, toolchain, host, backend, config, corpus, command and evidence label | Maintainer adjudicates claim label and model card language | ADR-348 G3 through G5 | OPEN | +| RF-012 | Mode transition and rollback state-machine tests; failed activation retains the prior artifact; raw sensing continues | Signed rollback drill and authorized mode-transition record | ADR-348 G5 | OPEN | + +## Governed training requirements + +These rows trace ADR-349. A mock-backed test can close a software subcondition, +but cannot close the hosted operational evidence named in the ADR. + +| Requirement | Machine evidence | Human or external evidence | Acceptance authority | Current state | +|---|---|---|---|---| +| FT-001 | Synthetic hosted DTO and reconstructed local request bind the same generator, model, optimizer and build identities; external manifests are rejected | Reviewer compares immutable local and provider receipts | ADR-349 and G1 | OPEN | +| FT-002 | Unknown-field and arbitrary command/image/URL/environment/path negatives | Endpoint capability and worker-image review | ADR-349 and G2 | OPEN | +| FT-003 | Concurrent/retry/lost-response idempotency state-machine tests | Real provider ambiguous-retry drill and cost reconciliation | ADR-349 and G1/G5 | OPEN | +| FT-004 | Budget boundary and over-budget rejection tests | Provider price/bill fixture review and bounded real-run receipt | ADR-349 and G5 | OPEN | +| FT-005 | Authenticated cancellation, checkpoint and terminal-state property tests | Real hosted cancellation and orphan-job drill | ADR-349 and G2/G5 | OPEN | +| FT-006 | Missing/duplicate/truncated/tampered fixed-kind export matrix | Quarantine/export receipt review | ADR-349 and G1/G2 | OPEN | +| FT-007 | Hosted-signing denial, local verification and atomic promotion/rollback tests | Release-key isolation approval and rollback drill | ADR-349 and G1/G5 | OPEN | +| FT-008 | Captured egress and log-redaction tests for credentials/privacy/retention fields | Provider DPA, region, retention, reuse and deletion review | ADR-349 and G1/G5 | OPEN | + +## Predictive-memory and explanation requirements + +These rows trace ADR-350. This PR creates no sensing-server, RuVector or RuVLLM +runtime authority; the rows remain open until a separately reviewed bridge +provides the evidence. + +| Requirement | Machine evidence | Human or external evidence | Acceptance authority | Current state | +|---|---|---|---|---| +| PM-001 | Cross-tenant/split/version/time-scope query negatives | Signed index-manifest and tenancy review | ADR-350 and G3/G5 | OPEN | +| PM-002 | Property tests reject holdout identities and overlapping contexts/horizons | Frozen leakage-report review | ADR-350 and G3 | OPEN | +| PM-003 | Bounded zero/error/success retrieval receipts and tamper tests | Retrieval receipt schema approval | ADR-350 and G2/G3 | OPEN | +| PM-004 | Same-example, same-artifact paired retrieval-off/on evaluator | Frozen paired ablation and overhead report | ADR-350 and G3 | OPEN | +| PM-005 | Envelope signature mutation, expiry, tenant, key and replay negatives | Local signing/key-rotation receipt | ADR-350 and G2/G5 | OPEN | +| PM-006 | Evidence-monotonicity property tests reject measured-observation promotion | Evidence-engine owner review | ADR-350 and G2/G4 | OPEN | +| PM-007 | Numeric/unit/provenance mutation corpus fails closed | RuVLLM adapter review | ADR-350 and G4 | OPEN | +| PM-008 | Default-deny capability and attempted-escalation tests | Downstream policy/capability review | ADR-350 and G4/G5 | OPEN | +| PM-009 | Tenant deletion, retention and access-audit tests | Privacy and membership/extraction-risk approval | ADR-350 and G5 | OPEN | + +## Threat-model contract coverage + +The IDs below are defined by +[`../security/ruview-forecast-threat-model.md`](../security/ruview-forecast-threat-model.md). +The feature job compiles and runs the current mock-backed suite, but an ID stays +open until the named negative test and its retained CI report exist. + +| Contract IDs | Required machine evidence | Current state | +|---|---|---| +| PRIV-001 through PRIV-009 | Egress-denial, tenant-isolation, log-redaction, nonce-isolation and tracked-data-policy negatives | OPEN | +| AUTH-001 through AUTH-005; STATE-001 through STATE-004 | Fail-closed identity/scope/service-auth tests and property-tested idempotent transition machine | OPEN | +| BOUND-001 through BOUND-007 | Boundary/property cases, parser fuzzing, cumulative resource limits and bounded accelerated replay | OPEN | +| RVEC-001 through RVEC-006 | Scope-authority construction denial, identifier/privacy bounds, isolation, cancellation and non-persistence evidence | OPEN | +| FAL-001 through FAL-012; SSRF-001; PATH-001 through PATH-003 | Mock request capture, exact-body webhook/replay cases, app/build/expiry binding, URL/DNS/redirect denial and filesystem escape negatives | OPEN | +| ART-001 through ART-005; DE-001; EVID-001 through EVID-002 | Artifact tamper/rollback/crash matrix, trusted-type construction denial and evidence-authority invariants | OPEN | + +## Gate evidence bundles + +| Gate | Minimum bundle before review | Status | +|---|---|---| +| G0 | Clean-room manifest, contributor attestations, repository/artifact scan report, adjudication log | OPEN | +| G1 | Dataset rights manifests, byte-level lineage report, random-initialization receipt, local/hosted digest comparison | OPEN | +| G2 | Focused tests, property/fuzz reports, deterministic replay hash, dependency-boundary tree, 24-hour replay report | OPEN | +| G3 | Frozen split manifest, baseline/model/retrieval rows, weighted quantile loss, interval calibration, reproducer and report digests | OPEN | +| G4 | Fourteen-day shadow report, empty-room/occupied-room confusion matrices, drift/abstention slices, authority audit | OPEN | +| G5 | Dedicated-host latency and process-RSS report, signed model card, SBOM/provenance, rollback drill, security/privacy/legal approvals | OPEN | + +## Evidence record template + +Copy one row per immutable evidence artifact. Never replace a failed result. + +| Evidence ID | Requirement/gate | Commit | Lock/config/data digest | Environment | Command | Result | Evidence label | Artifact digest | Reviewer/date | +|---|---|---|---|---|---|---|---|---|---| + +No evidence artifacts have been accepted. diff --git a/firmware/esp32-csi-node/README.md b/firmware/esp32-csi-node/README.md index b2dda8ab0..ebdddc2d0 100644 --- a/firmware/esp32-csi-node/README.md +++ b/firmware/esp32-csi-node/README.md @@ -7,40 +7,81 @@ This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (pr [![ESP-IDF v5.4](https://img.shields.io/badge/ESP--IDF-v5.4-blue.svg)](https://docs.espressif.com/projects/esp-idf/en/v5.4/) [![Target: ESP32-S3 / ESP32-C6](https://img.shields.io/badge/target-ESP32--S3%20%7C%20ESP32--C6-purple.svg)](https://www.espressif.com/en/products/socs/esp32-s3) [![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-green.svg)](../../LICENSE) -[![Binary: ~943 KB](https://img.shields.io/badge/binary-~943%20KB-orange.svg)](#memory-budget) +[![Binary: up to 1.1 MB](https://img.shields.io/badge/binary-up%20to%201.1%20MB-orange.svg)](#memory-budget) [![CI: Docker Build](https://img.shields.io/badge/CI-Docker%20Build-brightgreen.svg)](../../.github/workflows/firmware-ci.yml) -> | Capability | Method | Performance | -> |------------|--------|-------------| -> | **CSI streaming** | Per-subcarrier I/Q capture over UDP | ~20 Hz, ADR-018 binary format | -> | **Breathing detection** | Bandpass 0.1-0.5 Hz, zero-crossing BPM | 6-30 BPM | -> | **Heart rate** | Bandpass 0.8-2.0 Hz, zero-crossing BPM | 40-120 BPM | -> | **Presence indicator** (heuristic) | Phase variance + adaptive threshold (60 s ambient learning) | < 1 ms latency, false-positives under strong RF interference — see [Tier 2 caveats](#what-this-firmware-does-not-do-tier-2-caveats) | +> | Capability | Method | Current contract | +> |------------|--------|------------------| +> | **CSI streaming** | Per-subcarrier I/Q capture over UDP | Radio-dependent cadence with a 20 packets-per-second hardware acceptance floor, ADR-018 binary format | +> | **Breathing estimate** | Bandpass 0.1-0.5 Hz, zero-crossing BPM | Experimental 6-30 BPM output; calibrate against a reference before use | +> | **Heart-rate estimate** | Bandpass 0.8-2.0 Hz, zero-crossing BPM | Experimental 40-120 BPM output; not a medical measurement | +> | **Presence indicator** (heuristic) | Phase variance + adaptive threshold (60 s ambient learning) | Fast local indicator; strong RF interference can cause false positives — see [Tier 2 caveats](#what-this-firmware-does-not-do-tier-2-caveats) | > | **Fall detection** | Phase acceleration threshold | Configurable sensitivity | > | **Programmable sensing** | WASM modules loaded over HTTP | Hot-swap, no reflash | +## Firmware 0.8.8 in plain language + +Release 0.8.8 makes the sensing stream more internally consistent and easier +to diagnose: + +1. An empty-room decision can no longer carry a nonzero person count. Older + firmware could expose those two contradictory values at the same time. +2. ESP32-C6 signal processing now uses a stable 8 Hz clock while raw CSI keeps + streaming at the faster radio-dependent rate. This prevents temporal + filters from silently using the wrong time scale. +3. The one-second diagnostic reports both raw callback yield and the DSP rate, + making slow or overloaded nodes visible. +4. OTA reports the application slot selected by the board instead of assuming + a fixed 900 KB limit. + +Two ESP32-C6 boards and one ESP32-S3 completed five-minute physical transport +runs. The updated nodes had zero steady-state send failures, parser failures, +watchdogs, panics, or reboots. These results prove timing and transport +stability, not better heartbeat, pose, identity, or person-count accuracy. +See the [0.8.8 release notes](../../docs/releases/v0.8.8-esp32.md) and +[ADR 347](../../docs/adr/ADR-347-rate-aware-esp32-temporal-sensing.md) for the +measured evidence and limitations. + --- ## Quick Start For users who want to get running fast. Detailed explanations follow in later sections. -### 0. Pre-built binaries (v0.6.5 — skip the build step) +### 0. Download the 0.8.8 release -Pre-built binaries are in `firmware/esp32-csi-node/release_bins/` (version: see `release_bins/version.txt`). -Flash them directly: +Use the versioned source tag and binaries on the +[v0.8.8 ESP32 release page](https://github.com/ruvnet/RuView/releases/tag/v0.8.8-esp32). +Choose the package that names both your chip and flash size: + +| Package | Use it for | +|---------|------------| +| `esp32-csi-node-v0.8.8-s3-8mb-flash-bundle.zip` | Fresh ESP32-S3 installation with 8 MB flash | +| `esp32-csi-node-v0.8.8-s3-4mb-flash-bundle.zip` | Fresh ESP32-S3 installation with 4 MB flash | +| `esp32-csi-node-v0.8.8-c6-4mb-flash-bundle.zip` | Fresh ESP32-C6 installation using the supported 4 MB layout | + +Each bundle contains the matching bootloader, partition table, OTA metadata, +application, checksums, and a short flashing guide. Never flash an S3 bundle +onto a C6, or a C6 bundle onto an S3. + +Example for an 8 MB ESP32-S3 after extracting its bundle: ```bash python -m esptool --chip esp32s3 --port COM7 --baud 460800 \ write_flash --flash_mode dio --flash_size 8MB \ - 0x0 firmware/esp32-csi-node/release_bins/bootloader.bin \ - 0x8000 firmware/esp32-csi-node/release_bins/partition-table.bin \ - 0xf000 firmware/esp32-csi-node/release_bins/ota_data_initial.bin \ - 0x20000 firmware/esp32-csi-node/release_bins/esp32-csi-node.bin + 0x0 bootloader.bin \ + 0x8000 partition-table.bin \ + 0xf000 ota_data_initial.bin \ + 0x20000 esp32-csi-node.bin ``` -For 4 MB boards use `release_bins/esp32-csi-node-4mb.bin` and `release_bins/partition-table-4mb.bin` -with `--flash_size 4MB`. +For an existing provisioned node, back up its current application and inspect +`http://DEVICE_IP:8032/ota/status` before choosing an application-only update. +Writing only offset `0x20000` is safe only when the status endpoint reports +`running_partition` as `ota_0` and the downloaded image matches the board. +The full bundles do not include NVS, so the documented four-offset install +preserves WiFi and node configuration while replacing the boot and application +images. ### 1. Build (Docker -- the only reliable method) @@ -111,7 +152,7 @@ curl http://<ESP32_IP>:8032/wasm/list | **Recommended boards** | ESP32-S3-DevKitC-1, XIAO ESP32-S3 | Any ESP32-S3 with 8 MB flash works | | **Deployment** | 3-6 nodes per room | Multistatic mesh for 360-degree coverage | -> **Tip:** A single node provides presence and vital signs along its line of sight. Multiple nodes (3-6) create a multistatic mesh that resolves 3D pose with <30 mm jitter and zero identity swaps. +> **Tip:** A single node is mainly useful for presence and motion along one RF link. Three or more spatially separated links improve geometry and track separation. Location, pose, and multi-person accuracy still require room-specific calibration and held-out ground-truth evaluation. > **⚠️ Thermal warning — compact boards (ESP32-S3-Zero, SuperMini, other coin-sized clones):** This firmware runs the WiFi radio with modem sleep disabled (`WIFI_PS_NONE`, required for continuous CSI capture) plus a full edge-processing DSP pipeline on Core 1 (`edge_tier=2`) plus, on ADR-183 builds, a continuous 40 Hz onboard LED driver. That's sustained high current draw with no duty-cycling. Full-size dev boards (DevKitC-1, XIAO) have more copper pour and thermal mass around the regulator and tolerate this fine. Coin-sized clones with minimal PCB area and budget regulators may run hot to the touch during normal operation, and in at least one field report, boards that ran hot during a session failed to power on afterward (regulator damage suspected — see issue tracker). Give these boards airflow, don't stack or enclose them, and check them by touch during the first several minutes of a new deployment. If a board is uncomfortably hot (not just warm), power it down and let it cool before continuing. diff --git a/firmware/esp32-csi-node/main/Kconfig.projbuild b/firmware/esp32-csi-node/main/Kconfig.projbuild index ee1dff164..6f4f5bfd8 100644 --- a/firmware/esp32-csi-node/main/Kconfig.projbuild +++ b/firmware/esp32-csi-node/main/Kconfig.projbuild @@ -39,6 +39,17 @@ menu "CSI Node Configuration" help WiFi channel to listen on for CSI data. + config CSI_SELF_PING_HZ + int "Connected-STA CSI probe rate (Hz)" + default 50 + range 10 50 + help + Rate of the one-byte ICMP probes used to create a stable OFDM + CSI source on quiet networks. Fifty hertz is the measured safety + ceiling for the current ESP-IDF WiFi callback path. Higher rates + are intentionally rejected because sustained callback load above + 50 Hz has caused WiFi ISR and packet-buffer failures on S3 and C6. + endmenu menu "Edge Intelligence (ADR-039)" @@ -66,6 +77,18 @@ menu "Edge Intelligence (ADR-039)" help Number of highest-variance subcarriers to use for DSP. + config EDGE_DSP_SAMPLE_HZ + int "On-device edge DSP sample rate (Hz)" + default 8 if IDF_TARGET_ESP32C6 + default 20 + range 8 50 + help + Uniform rate at which CSI callbacks enter the Tier 1 and Tier 2 + edge DSP. Raw CSI transmission keeps its independent full-rate + path. Eight hertz is the hardware-measured sustainable C6 Tier 2 + setting and preserves a 4 Hz Nyquist limit for the 0.1-2.0 Hz + vital bands. + config EDGE_FALL_THRESH int "Fall detection threshold (x1000)" default 15000 diff --git a/firmware/esp32-csi-node/main/adaptive_controller.c b/firmware/esp32-csi-node/main/adaptive_controller.c index 99272ee90..fe4bf9d5b 100644 --- a/firmware/esp32-csi-node/main/adaptive_controller.c +++ b/firmware/esp32-csi-node/main/adaptive_controller.c @@ -248,9 +248,10 @@ static void medium_loop_cb(TimerHandle_t t) portEXIT_CRITICAL(&s_obs_lock); if (s_obs_valid) { - ESP_LOGI(TAG, "medium tick: state=%u yield=%upps motion=%.2f presence=%.2f rssi=%d", + ESP_LOGI(TAG, "medium tick: state=%u yield=%upps dsp=%.1fHz motion=%.2f presence=%.2f rssi=%d", (unsigned)s_state, (unsigned)obs.pkt_yield_per_sec, + (double)edge_get_sample_rate_hz(), (double)obs.motion_score, (double)obs.presence_score, (int)obs.rssi_median_dbm); diff --git a/firmware/esp32-csi-node/main/csi_collector.c b/firmware/esp32-csi-node/main/csi_collector.c index 9387a6a02..6a15443fa 100644 --- a/firmware/esp32-csi-node/main/csi_collector.c +++ b/firmware/esp32-csi-node/main/csi_collector.c @@ -63,6 +63,32 @@ static uint32_t s_send_ok = 0; static uint32_t s_send_fail = 0; static uint32_t s_rate_skip = 0; +#ifndef CONFIG_CSI_SELF_PING_HZ +#define CONFIG_CSI_SELF_PING_HZ 50 +#endif + +#if CONFIG_CSI_SELF_PING_HZ < 10 || CONFIG_CSI_SELF_PING_HZ > 50 +#error "CONFIG_CSI_SELF_PING_HZ must stay within the hardware-qualified 10-50 Hz range" +#endif + +#define CSI_SELF_PING_INTERVAL_MS (1000U / CONFIG_CSI_SELF_PING_HZ) + +#ifndef CONFIG_EDGE_DSP_SAMPLE_HZ +#if CONFIG_IDF_TARGET_ESP32C6 +#define CONFIG_EDGE_DSP_SAMPLE_HZ 8 +#else +#define CONFIG_EDGE_DSP_SAMPLE_HZ 20 +#endif +#endif + +#if CONFIG_EDGE_DSP_SAMPLE_HZ < 8 || CONFIG_EDGE_DSP_SAMPLE_HZ > 50 +#error "CONFIG_EDGE_DSP_SAMPLE_HZ must stay within the supported 8-50 Hz range" +#endif + +#define EDGE_DSP_MIN_INTERVAL_US (1000000U / CONFIG_EDGE_DSP_SAMPLE_HZ) +static int64_t s_next_edge_enqueue_us = 0; +static uint32_t s_edge_rate_skip = 0; + /** * Minimum interval between UDP sends in microseconds. * CSI callbacks can fire hundreds of times per second in promiscuous mode. @@ -300,10 +326,31 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info) } } - /* ADR-039: Enqueue raw I/Q into edge processing ring buffer. */ + /* ADR-039 / ADR-347: Raw CSI stays at the independent network cadence, + * while the on-device Tier 1/2 pipeline receives a uniform, sustainable + * stream. Enqueuing every burst frame overloaded the unicore C6 DSP and + * turned 30-40 callback pps into an irregular approximately 8 Hz subset. */ if (info->buf && info->len > 0) { - edge_enqueue_csi((const uint8_t *)info->buf, (uint16_t)info->len, - (int8_t)info->rx_ctrl.rssi, info->rx_ctrl.channel); + if (s_next_edge_enqueue_us == 0) { + s_next_edge_enqueue_us = now_us; + } + + if (now_us >= s_next_edge_enqueue_us) { + (void)edge_enqueue_csi((const uint8_t *)info->buf, (uint16_t)info->len, + (int8_t)info->rx_ctrl.rssi, info->rx_ctrl.channel); + + /* Preserve the configured sample clock instead of resetting it to + * each irregular callback. With roughly 35 raw callbacks per + * second, a last-seen 100 ms gate selected every fourth callback + * and drifted to roughly 8 Hz. Advancing the deadline by complete + * periods alternates the available callbacks around the configured + * phase and prevents both drift and catch-up bursts. */ + int64_t periods = ((now_us - s_next_edge_enqueue_us) / + EDGE_DSP_MIN_INTERVAL_US) + 1; + s_next_edge_enqueue_us += periods * EDGE_DSP_MIN_INTERVAL_US; + } else { + s_edge_rate_skip++; + } } /* ADR-110 §A0.11/§A0.12 — Emit a sync-packet every N CSI frames so the @@ -411,7 +458,7 @@ static void csi_start_self_ping(void) esp_ping_config_t cfg = ESP_PING_DEFAULT_CONFIG(); cfg.target_addr = target; cfg.count = ESP_PING_COUNT_INFINITE; - cfg.interval_ms = 20; /* 50 Hz -> ~50 received OFDM replies/sec */ + cfg.interval_ms = CSI_SELF_PING_INTERVAL_MS; cfg.data_size = 1; cfg.task_stack_size = 4096; @@ -424,7 +471,8 @@ static void csi_start_self_ping(void) if (esp_ping_new_session(&cfg, &cbs, &s_self_ping) == ESP_OK && s_self_ping != NULL) { esp_ping_start(s_self_ping); - ESP_LOGI(TAG, "self-ping started -> %s @50Hz (CSI OFDM source, fix #521/#954)", gw_str); + ESP_LOGI(TAG, "self-ping started -> %s @%dHz (CSI OFDM source, fix #521/#954)", + gw_str, CONFIG_CSI_SELF_PING_HZ); } else { ESP_LOGW(TAG, "self-ping: esp_ping_new_session failed"); s_self_ping = NULL; @@ -592,6 +640,8 @@ void csi_collector_init(void) ESP_LOGI(TAG, "CSI collection initialized (node_id=%u, channel=%u)", (unsigned)s_node_id, (unsigned)csi_channel); + ESP_LOGI(TAG, "edge DSP cadence=%dHz; raw CSI network cadence remains independent", + CONFIG_EDGE_DSP_SAMPLE_HZ); /* RuView#521/#954: start the connected-STA traffic source so the CSI engine * receives a guaranteed OFDM unicast floor even when promiscuous capture is diff --git a/firmware/esp32-csi-node/main/edge_processing.c b/firmware/esp32-csi-node/main/edge_processing.c index a92d0403d..da67c147b 100644 --- a/firmware/esp32-csi-node/main/edge_processing.c +++ b/firmware/esp32-csi-node/main/edge_processing.c @@ -38,6 +38,16 @@ extern nvs_config_t g_nvs_config; static const char *TAG = "edge_proc"; +#ifndef CONFIG_EDGE_DSP_SAMPLE_HZ +#if CONFIG_IDF_TARGET_ESP32C6 +#define CONFIG_EDGE_DSP_SAMPLE_HZ 8 +#else +#define CONFIG_EDGE_DSP_SAMPLE_HZ 20 +#endif +#endif + +#define EDGE_CONFIGURED_SAMPLE_RATE_HZ ((float)CONFIG_EDGE_DSP_SAMPLE_HZ) + /* ====================================================================== * SPSC Ring Buffer (lock-free, single-producer single-consumer) * ====================================================================== */ @@ -355,11 +365,12 @@ static float s_heartrate_filtered[EDGE_PHASE_HISTORY_LEN]; /** Measured CSI sample rate (Hz), smoothed from frame timestamps. * #985's self-ping raised the callback rate above the old ~10 Hz beacon - * assumption and made it variable (~13-19 Hz); a fixed rate scaled BPM wrong - * and made HR swing with CSI yield. See update in process_csi_frame(). */ -static float s_sample_rate_hz = 15.0f; -static float s_filter_design_fs = 20.0f; /* fs the biquads were last designed at */ -static uint32_t s_last_frame_ts_us = 0; + * assumption and made it variable. A fixed rate scales BPM and Doppler bins + * incorrectly. Start from the filter design rate, then follow measured time. */ +static float s_sample_rate_hz = EDGE_CONFIGURED_SAMPLE_RATE_HZ; +static float s_filter_design_fs = EDGE_CONFIGURED_SAMPLE_RATE_HZ; /* fs the biquads were last designed at */ +static uint32_t s_rate_window_start_us = 0; +static uint32_t s_rate_window_intervals = 0; /** Latest vitals state. */ static float s_breathing_bpm; @@ -409,6 +420,21 @@ static edge_biquad_t s_person_bq_hr[EDGE_MAX_PERSONS]; static float s_person_br_filt[EDGE_MAX_PERSONS][EDGE_PHASE_HISTORY_LEN]; static float s_person_hr_filt[EDGE_MAX_PERSONS][EDGE_PHASE_HISTORY_LEN]; +/** Clear person slots whenever the room-level presence gate is closed. */ +static void reset_person_count_state(void) +{ + s_person_count_candidate = 0; + s_person_count_streak = 0; + s_person_count_stable = 0; + for (uint8_t p = 0; p < EDGE_MAX_PERSONS; p++) { + s_persons[p].active = false; + s_persons[p].history_len = 0; + s_persons[p].history_idx = 0; + s_persons[p].breathing_bpm = 0.0f; + s_persons[p].heartrate_bpm = 0.0f; + } +} + /** Latest vitals packet (thread-safe via volatile copy). */ static volatile edge_vitals_pkt_t s_latest_pkt; static volatile bool s_pkt_valid; @@ -898,7 +924,10 @@ static void send_vitals_packet(void) for (uint8_t p = 0; p < EDGE_MAX_PERSONS; p++) { if (s_persons[p].active) n_active++; } - pkt.n_persons = n_active; + /* Fail closed: the slot heuristic cannot assert occupants while the + * debounced presence gate is false. The host repeats this invariant for + * backward compatibility with older firmware. */ + pkt.n_persons = edge_evidence_person_count(s_presence_detected, n_active); pkt.motion_energy = s_motion_energy; pkt.presence_score = s_presence_score; @@ -1038,20 +1067,27 @@ static void process_frame(const edge_ring_slot_t *slot) s_frame_count++; s_latest_rssi = slot->rssi; - /* Measure the REAL CSI sample rate from inter-frame timestamps. #985's - * self-ping made the callback rate variable (~13-19 Hz); the old fixed - * 10 Hz both scaled BPM wrong (true ~87 BPM read as ~45) and made HR swing - * as CSI yield fluctuated. EMA-smooth and clamp to a plausible band. */ - if (s_last_frame_ts_us != 0 && slot->timestamp_us > s_last_frame_ts_us) { - float dt = (float)(slot->timestamp_us - s_last_frame_ts_us) * 1e-6f; - if (dt > 0.02f && dt < 0.5f) { /* 2-50 Hz plausible; reject gaps/hops */ - float inst = 1.0f / dt; - s_sample_rate_hz += 0.05f * (inst - s_sample_rate_hz); - if (s_sample_rate_hz < 8.0f) s_sample_rate_hz = 8.0f; - if (s_sample_rate_hz > 30.0f) s_sample_rate_hz = 30.0f; + /* Measure the real CSI sample rate over one-second timestamp windows. WiFi + * replies arrive in bursts, so filtering individual short intervals made + * a 35 pps stream look like 12-16 Hz. Counting all processed intervals in + * the window preserves the clock actually seen by the temporal filters. */ + if (s_rate_window_start_us == 0) { + s_rate_window_start_us = slot->timestamp_us; + s_rate_window_intervals = 0; + } else if (slot->timestamp_us > s_rate_window_start_us) { + s_rate_window_intervals++; + uint32_t elapsed_us = slot->timestamp_us - s_rate_window_start_us; + if (elapsed_us >= EDGE_SAMPLE_RATE_WINDOW_MIN_US) { + s_sample_rate_hz = edge_sample_rate_window_update( + s_sample_rate_hz, s_rate_window_intervals, elapsed_us); + s_rate_window_start_us = slot->timestamp_us; + s_rate_window_intervals = 0; } + } else { + /* Timer wrap or reset. Start a fresh evidence window. */ + s_rate_window_start_us = slot->timestamp_us; + s_rate_window_intervals = 0; } - s_last_frame_ts_us = slot->timestamp_us; /* Re-tune the biquads if the measured rate has drifted from their design fs, * so the breathing (0.1-0.5 Hz) and HR (0.8-2.0 Hz) passbands stay in real @@ -1202,8 +1238,15 @@ static void process_frame(const edge_ring_slot_t *slot) } } - /* --- Step 11: Multi-person vitals --- */ - update_multi_person_vitals(slot->iq_data, n_subcarriers, sample_rate); + /* --- Step 11: Multi-person vitals --- + * Person slots are subordinate to the room presence gate. Processing or + * retaining slots while absent produced contradictory packets such as + * presence=false with n_persons=4. */ + if (s_presence_detected) { + update_multi_person_vitals(slot->iq_data, n_subcarriers, sample_rate); + } else { + reset_person_count_state(); + } /* Yield after multi-person DSP so IDLE1 can feed Core 1 watchdog (#683). */ if (s_cfg.tier >= 2) vTaskDelay(1); @@ -1314,6 +1357,11 @@ bool edge_get_vitals(edge_vitals_pkt_t *pkt) return true; } +float edge_get_sample_rate_hz(void) +{ + return s_sample_rate_hz; +} + void edge_get_multi_person(edge_person_vitals_t *persons, uint8_t *n_active) { uint8_t active = 0; @@ -1373,6 +1421,10 @@ esp_err_t edge_processing_init(const edge_config_t *cfg) s_fall_detected = false; s_latest_rssi = 0; s_frame_count = 0; + s_sample_rate_hz = EDGE_CONFIGURED_SAMPLE_RATE_HZ; + s_filter_design_fs = EDGE_CONFIGURED_SAMPLE_RATE_HZ; + s_rate_window_start_us = 0; + s_rate_window_intervals = 0; s_prev_phase_velocity = 0.0f; s_fall_consec_count = 0; s_fall_last_alert_us = 0; @@ -1397,9 +1449,9 @@ esp_err_t edge_processing_init(const edge_config_t *cfg) s_person_count_streak = 0; s_person_count_stable = 0; - /* Design biquad bandpass filters. - * Sampling rate ~20 Hz (typical ESP32 CSI callback rate). */ - const float fs = 20.0f; + /* Design biquad bandpass filters against the configured DSP clock. The + * measured timestamp estimator then follows sustained hardware drift. */ + const float fs = EDGE_CONFIGURED_SAMPLE_RATE_HZ; biquad_bandpass_design(&s_bq_breathing, fs, 0.1f, 0.5f); biquad_bandpass_design(&s_bq_heartrate, fs, 0.8f, 2.0f); diff --git a/firmware/esp32-csi-node/main/edge_processing.h b/firmware/esp32-csi-node/main/edge_processing.h index 826f080e3..cb53ad66c 100644 --- a/firmware/esp32-csi-node/main/edge_processing.h +++ b/firmware/esp32-csi-node/main/edge_processing.h @@ -35,9 +35,56 @@ #define EDGE_TOP_K 8 /**< Top-K subcarriers to track. */ #define EDGE_MAX_SUBCARRIERS 128 /**< Max subcarriers per frame. */ +/* ---- Measured sample-rate tracking ---- + * + * The connected-STA probe produces up to 50 CSI opportunities per second, + * while contention and callback gating make the delivered cadence variable. + * Temporal filters must follow measured time rather than a fixed frame-rate + * assumption. The 60 Hz estimator ceiling leaves jitter headroom above the + * qualified 50 Hz callback limit. A one-second frame-count window represents + * bursty but valid WiFi arrivals more accurately than averaging only selected + * inter-frame intervals. */ +#define EDGE_SAMPLE_RATE_MIN_HZ 8.0f +#define EDGE_SAMPLE_RATE_MAX_HZ 60.0f +#define EDGE_SAMPLE_RATE_EMA_ALPHA 0.25f +#define EDGE_SAMPLE_RATE_WINDOW_MIN_US 1000000U +#define EDGE_SAMPLE_RATE_WINDOW_MAX_US 3000000U + +static inline float edge_sample_rate_window_update(float current_hz, + uint32_t frame_intervals, + uint32_t elapsed_us) +{ + if (frame_intervals == 0 || elapsed_us < EDGE_SAMPLE_RATE_WINDOW_MIN_US || + elapsed_us > EDGE_SAMPLE_RATE_WINDOW_MAX_US) { + return current_hz; + } + + float instant_hz = (float)frame_intervals * 1000000.0f / (float)elapsed_us; + if (instant_hz < EDGE_SAMPLE_RATE_MIN_HZ) instant_hz = EDGE_SAMPLE_RATE_MIN_HZ; + if (instant_hz > EDGE_SAMPLE_RATE_MAX_HZ) instant_hz = EDGE_SAMPLE_RATE_MAX_HZ; + float next_hz = current_hz + EDGE_SAMPLE_RATE_EMA_ALPHA * (instant_hz - current_hz); + if (next_hz < EDGE_SAMPLE_RATE_MIN_HZ) return EDGE_SAMPLE_RATE_MIN_HZ; + if (next_hz > EDGE_SAMPLE_RATE_MAX_HZ) return EDGE_SAMPLE_RATE_MAX_HZ; + return next_hz; +} + /* ---- Multi-person ---- */ #define EDGE_MAX_PERSONS 4 /**< Max simultaneous persons. */ +/** + * Enforce the wire-level occupancy invariant. + * + * A subcarrier slot estimate is supporting evidence only. It cannot assert an + * occupant when the independently debounced presence gate is false. Keeping + * this helper in the public firmware header lets host tests exercise the exact + * function used by the device build. + */ +static inline uint8_t edge_evidence_person_count(bool presence, uint8_t active_count) +{ + if (!presence) return 0; + return active_count > EDGE_MAX_PERSONS ? EDGE_MAX_PERSONS : active_count; +} + /* ---- Multi-person counting gates (issue #998) ---- * * Over-counting root cause: the multi-person path used to split the top-K @@ -238,6 +285,12 @@ bool edge_enqueue_csi(const uint8_t *iq_data, uint16_t iq_len, */ bool edge_get_vitals(edge_vitals_pkt_t *pkt); +/** + * Return the timestamp-derived CSI cadence used to design temporal filters. + * This is diagnostic evidence, not the raw callback or network delivery rate. + */ +float edge_get_sample_rate_hz(void); + /** * Get multi-person vitals array. * diff --git a/firmware/esp32-csi-node/main/ota_update.c b/firmware/esp32-csi-node/main/ota_update.c index eed5c667f..bad144c26 100644 --- a/firmware/esp32-csi-node/main/ota_update.c +++ b/firmware/esp32-csi-node/main/ota_update.c @@ -23,9 +23,6 @@ static const char *TAG = "ota_update"; /** OTA HTTP server port. */ #define OTA_PORT 8032 -/** Maximum firmware size (900 KB — matches CI binary size gate). */ -#define OTA_MAX_SIZE (900 * 1024) - /** NVS namespace and key for the OTA pre-shared key. */ #define OTA_NVS_NAMESPACE "security" #define OTA_NVS_KEY "ota_psk" @@ -95,11 +92,11 @@ static esp_err_t ota_status_handler(httpd_req_t *req) int len = snprintf(response, sizeof(response), "{\"version\":\"%s\",\"date\":\"%s\",\"time\":\"%s\"," "\"running_partition\":\"%s\",\"next_partition\":\"%s\"," - "\"max_size\":%d}", + "\"max_size\":%lu}", app->version, app->date, app->time, running ? running->label : "unknown", update ? update->label : "none", - OTA_MAX_SIZE); + (unsigned long)(update ? update->size : 0)); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, response, len); @@ -121,12 +118,6 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) ESP_LOGI(TAG, "OTA update started, content_length=%d", req->content_len); - if (req->content_len <= 0 || req->content_len > OTA_MAX_SIZE) { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, - "Invalid firmware size (must be 1B - 900KB)"); - return ESP_FAIL; - } - const esp_partition_t *update_partition = esp_ota_get_next_update_partition(NULL); if (update_partition == NULL) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, @@ -134,6 +125,15 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) return ESP_FAIL; } + if (req->content_len <= 0 || (size_t)req->content_len > update_partition->size) { + ESP_LOGW(TAG, "OTA rejected: content_length=%d exceeds partition '%s' size=%lu", + req->content_len, update_partition->label, + (unsigned long)update_partition->size); + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, + "Invalid firmware size for OTA partition"); + return ESP_FAIL; + } + esp_ota_handle_t ota_handle; esp_err_t err = esp_ota_begin(update_partition, OTA_WITH_SEQUENTIAL_WRITES, &ota_handle); if (err != ESP_OK) { diff --git a/firmware/esp32-csi-node/sdkconfig.defaults.esp32c6 b/firmware/esp32-csi-node/sdkconfig.defaults.esp32c6 index b6bda708e..e67ee9ade 100644 --- a/firmware/esp32-csi-node/sdkconfig.defaults.esp32c6 +++ b/firmware/esp32-csi-node/sdkconfig.defaults.esp32c6 @@ -58,6 +58,10 @@ CONFIG_ULP_COPROC_RESERVE_MEM=8192 # CONFIG_DISPLAY_ENABLE is not set # CONFIG_WASM_ENABLE is not set +# Physical Tier 2 qualification on ESP32-C6 rev 0.2 converges at 8 Hz while +# leaving the raw network CSI stream independent at roughly 30-40 pps. +CONFIG_EDGE_DSP_SAMPLE_HZ=8 + # ── Compiler ── CONFIG_COMPILER_OPTIMIZATION_SIZE=y diff --git a/firmware/esp32-csi-node/test/test_vitals_count_presence.c b/firmware/esp32-csi-node/test/test_vitals_count_presence.c index 5c238e97e..69556302f 100644 --- a/firmware/esp32-csi-node/test/test_vitals_count_presence.c +++ b/firmware/esp32-csi-node/test/test_vitals_count_presence.c @@ -266,6 +266,19 @@ static void test_debounce_flapping_stays_stable(void) CHECK_EQ_U8("flapping count stays at 1", out, 1); } +/* The packet count is evidence subordinated to presence, never an independent + * occupancy assertion. This guards the field failure where a node emitted + * presence=false with n_persons=3 or 4. */ +static void test_person_count_fails_closed_without_presence(void) +{ + CHECK_EQ_U8("absent with four active slots -> zero", + edge_evidence_person_count(false, 4), 0); + CHECK_EQ_U8("present preserves a bounded count", + edge_evidence_person_count(true, 3), 3); + CHECK_EQ_U8("present count clamps to protocol maximum", + edge_evidence_person_count(true, 255), EDGE_MAX_PERSONS); +} + /* ────────────────────────────────────────────────────────────────────── * #996 — presence_flag_update: dithering score must NOT flicker the flag * ────────────────────────────────────────────────────────────────────── */ @@ -357,6 +370,36 @@ static void test_presence_dead_band_holds_state(void) CHECK_TRUE("dead band does not clear from true", flag); } +/* The physical C6 delivered 28-37 CSI frames/s while the former estimator was + * capped at 30 Hz. A 34 Hz stream must converge above that old ceiling. */ +static void test_sample_rate_tracks_above_thirty_hz(void) +{ + float rate = 20.0f; + for (int i = 0; i < 12; i++) { + rate = edge_sample_rate_window_update(rate, 34U, 1000000U); + } + CHECK_TRUE("sample rate follows measured 34 Hz cadence", rate > 33.0f && rate < 35.0f); +} + +static void test_sample_rate_requires_complete_window(void) +{ + float rate = 34.0f; + CHECK_TRUE("short window rejected", + edge_sample_rate_window_update(rate, 10U, 200000U) == rate); + CHECK_TRUE("stalled window rejected", + edge_sample_rate_window_update(rate, 10U, 4000000U) == rate); +} + +static void test_sample_rate_is_bounded(void) +{ + float rate = EDGE_SAMPLE_RATE_MAX_HZ; + CHECK_TRUE("sample rate upper bound holds", + edge_sample_rate_window_update(rate, 1000U, 1000000U) <= EDGE_SAMPLE_RATE_MAX_HZ); + rate = EDGE_SAMPLE_RATE_MIN_HZ; + CHECK_TRUE("sample rate lower bound holds", + edge_sample_rate_window_update(rate, 1U, 1000000U) >= EDGE_SAMPLE_RATE_MIN_HZ); +} + /* ────────────────────────────────────────────────────────────────────── * main * ────────────────────────────────────────────────────────────────────── */ @@ -375,6 +418,7 @@ int main(void) test_debounce_rejects_transient_spike(); test_debounce_accepts_sustained_change(); test_debounce_flapping_stays_stable(); + test_person_count_fails_closed_without_presence(); /* #996 presence hysteresis */ test_presence_no_flicker_on_dither(); @@ -382,6 +426,11 @@ int main(void) test_presence_genuine_departure_clears(); test_presence_dead_band_holds_state(); + /* Timestamp-derived temporal calibration */ + test_sample_rate_tracks_above_thirty_hz(); + test_sample_rate_requires_complete_window(); + test_sample_rate_is_bounded(); + printf("\n%d passed, %d failed\n", g_passed, g_failed); return g_failed == 0 ? 0 : 1; } diff --git a/firmware/esp32-csi-node/version.txt b/firmware/esp32-csi-node/version.txt index b60d71966..55485e179 100644 --- a/firmware/esp32-csi-node/version.txt +++ b/firmware/esp32-csi-node/version.txt @@ -1 +1 @@ -0.8.4 +0.8.9 diff --git a/scripts/run-ruforecast-benchmarks.sh b/scripts/run-ruforecast-benchmarks.sh new file mode 100755 index 000000000..b5612bd1e --- /dev/null +++ b/scripts/run-ruforecast-benchmarks.sh @@ -0,0 +1,288 @@ +#!/usr/bin/env bash +# Reproducible CPU benchmark evidence runner for ADR-348. +# +# This script never downloads a model or dataset and never enables CUDA. The +# benchmark fixtures are generated by the checked-in Rust benches, so the +# source commit and Cargo.lock digest identify the fixture implementation. + +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)" + +TOOLCHAIN="${RUFORECAST_TOOLCHAIN:-1.92.0}" +THREADS="${RUFORECAST_THREADS:-1}" +WARM_UP_SECONDS="${RUFORECAST_WARM_UP_SECONDS:-3}" +MEASUREMENT_SECONDS="${RUFORECAST_MEASUREMENT_SECONDS:-10}" +SAMPLE_SIZE="${RUFORECAST_SAMPLE_SIZE:-30}" +CPUSET="${RUFORECAST_CPUSET:-}" +ALLOW_DIRTY="${RUFORECAST_ALLOW_DIRTY:-0}" +INCLUDE_LARGE="${RUFORECAST_BENCH_LARGE:-0}" + +die() { + printf 'ruforecast benchmark: %s\n' "$*" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || die "required command not found: $1" +} + +require_uint() { + local name="$1" + local value="$2" + [[ "$value" =~ ^[0-9]+$ ]] || die "${name} must be an unsigned integer" +} + +[[ "$TOOLCHAIN" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ + || die "RUFORECAST_TOOLCHAIN must be an exact Rust release such as 1.92.0" +require_uint RUFORECAST_THREADS "$THREADS" +require_uint RUFORECAST_WARM_UP_SECONDS "$WARM_UP_SECONDS" +require_uint RUFORECAST_MEASUREMENT_SECONDS "$MEASUREMENT_SECONDS" +require_uint RUFORECAST_SAMPLE_SIZE "$SAMPLE_SIZE" +(( THREADS >= 1 )) || die "RUFORECAST_THREADS must be at least 1" +(( WARM_UP_SECONDS >= 1 )) || die "RUFORECAST_WARM_UP_SECONDS must be at least 1" +(( MEASUREMENT_SECONDS >= 1 )) || die "RUFORECAST_MEASUREMENT_SECONDS must be at least 1" +(( SAMPLE_SIZE >= 10 )) || die "RUFORECAST_SAMPLE_SIZE must be at least 10 for Criterion" +[[ "$ALLOW_DIRTY" == "0" || "$ALLOW_DIRTY" == "1" ]] \ + || die "RUFORECAST_ALLOW_DIRTY must be 0 or 1" +[[ "$INCLUDE_LARGE" == "0" || "$INCLUDE_LARGE" == "1" ]] \ + || die "RUFORECAST_BENCH_LARGE must be 0 or 1" + +if [[ -n "$CPUSET" ]]; then + [[ "$CPUSET" =~ ^[0-9,-]+$ ]] || die "RUFORECAST_CPUSET contains unsupported characters" + require_command taskset +fi + +for command_name in cargo rustc git jq sha256sum; do + require_command "$command_name" +done + +cd "$REPO_ROOT" + +for manifest in \ + v2/crates/ruview-forecast-core/Cargo.toml \ + v2/crates/ruview-forecast-model/Cargo.toml \ + v2/crates/ruview-forecast-train/Cargo.toml; do + [[ -f "$manifest" ]] || die "forecast crate is missing: $manifest" +done + +WORKTREE_CLEAN=true +if [[ -n "$(git status --porcelain)" ]]; then + WORKTREE_CLEAN=false + [[ "$ALLOW_DIRTY" == "1" ]] \ + || die "worktree changes detected; commit them or set RUFORECAST_ALLOW_DIRTY=1 for diagnostic evidence" +fi + +COMMIT="$(git rev-parse HEAD)" +SHORT_COMMIT="$(git rev-parse --short=12 HEAD)" +LOCK_SHA256="$(sha256sum v2/Cargo.lock | awk '{print $1}')" +RUN_UTC="$(date -u +'%Y%m%dT%H%M%SZ')" +SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)" + +DEFAULT_OUTPUT="${REPO_ROOT}/target/ruforecast-evidence/${RUN_UTC}-${SHORT_COMMIT}" +OUTPUT_DIR="${RUFORECAST_OUTPUT_DIR:-$DEFAULT_OUTPUT}" +if [[ "$OUTPUT_DIR" != /* ]]; then + OUTPUT_DIR="${REPO_ROOT}/${OUTPUT_DIR}" +fi +if [[ -d "$OUTPUT_DIR" && -n "$(find "$OUTPUT_DIR" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then + die "output directory is not empty: $OUTPUT_DIR" +fi +mkdir -p "$OUTPUT_DIR" + +finalize_evidence() { + local exit_status=$? + local sums_status=0 + local sums_tmp="" + trap - EXIT + set +e + + if [[ -d "${OUTPUT_DIR:-}" ]]; then + if (( exit_status == 0 )); then + printf 'status=PASSED\nexit_code=0\n' >"${OUTPUT_DIR}/run-status.txt" + else + printf 'status=FAILED\nexit_code=%d\n' "$exit_status" \ + >"${OUTPUT_DIR}/run-status.txt" + fi + + sums_tmp="$(mktemp "${OUTPUT_DIR}.SHA256SUMS.XXXXXX")" || sums_status=$? + if (( sums_status == 0 )); then + ( + cd "$OUTPUT_DIR" || exit + find . -maxdepth 1 -type f ! -name SHA256SUMS -printf '%P\0' \ + | sort -z \ + | xargs -0 sha256sum + ) >"$sums_tmp" || sums_status=$? + fi + if (( sums_status == 0 )); then + mv "$sums_tmp" "${OUTPUT_DIR}/SHA256SUMS" || sums_status=$? + elif [[ -n "$sums_tmp" ]]; then + rm -f "$sums_tmp" + fi + fi + + if (( exit_status == 0 && sums_status != 0 )); then + exit_status=$sums_status + fi + exit "$exit_status" +} +trap finalize_evidence EXIT + +export RAYON_NUM_THREADS="$THREADS" +export OMP_NUM_THREADS="$THREADS" +export OPENBLAS_NUM_THREADS="$THREADS" +export MKL_NUM_THREADS="$THREADS" +export NUMEXPR_NUM_THREADS="$THREADS" +export CARGO_PROFILE_BENCH_DEBUG=0 +export CARGO_PROFILE_RELEASE_DEBUG=0 +export CARGO_INCREMENTAL=0 +export RUST_BACKTRACE=1 +export SOURCE_DATE_EPOCH +export TZ=UTC +export LC_ALL=C +export RUFORECAST_BENCH_LARGE="$INCLUDE_LARGE" + +CARGO=(cargo "+${TOOLCHAIN}") +if [[ -n "$CPUSET" ]]; then + CARGO=(taskset -c "$CPUSET" cargo "+${TOOLCHAIN}") +fi + +"${CARGO[@]}" --version >"${OUTPUT_DIR}/cargo-version.txt" +if [[ -n "$CPUSET" ]]; then + taskset -c "$CPUSET" rustc "+${TOOLCHAIN}" -vV >"${OUTPUT_DIR}/rustc-version.txt" +else + rustc "+${TOOLCHAIN}" -vV >"${OUTPUT_DIR}/rustc-version.txt" +fi +if command -v lscpu >/dev/null 2>&1; then + lscpu -J >"${OUTPUT_DIR}/cpu.json" +fi +uname -srmo >"${OUTPUT_DIR}/kernel.txt" +if [[ -r /etc/os-release ]]; then + cp /etc/os-release "${OUTPUT_DIR}/os-release.txt" +fi +if command -v free >/dev/null 2>&1; then + free -b >"${OUTPUT_DIR}/memory.txt" +fi + +printf \ + 'RUFORECAST_TOOLCHAIN=%q RUFORECAST_THREADS=%q RUFORECAST_CPUSET=%q RUFORECAST_WARM_UP_SECONDS=%q RUFORECAST_MEASUREMENT_SECONDS=%q RUFORECAST_SAMPLE_SIZE=%q RUFORECAST_ALLOW_DIRTY=%q RUFORECAST_BENCH_LARGE=%q scripts/run-ruforecast-benchmarks.sh\n' \ + "$TOOLCHAIN" \ + "$THREADS" \ + "$CPUSET" \ + "$WARM_UP_SECONDS" \ + "$MEASUREMENT_SECONDS" \ + "$SAMPLE_SIZE" \ + "$ALLOW_DIRTY" \ + "$INCLUDE_LARGE" \ + >"${OUTPUT_DIR}/reproducer.txt" + +EVIDENCE_LABEL="MEASURED" +EVIDENCE_SCOPE="LOCAL_CPU_SYNTHETIC_FIXTURE" +if [[ "$WORKTREE_CLEAN" != "true" ]]; then + EVIDENCE_LABEL="SYNTHETIC" + EVIDENCE_SCOPE="DIRTY_WORKTREE_DIAGNOSTIC_ONLY" +fi + +jq -n \ + --arg schema_version "1" \ + --arg evidence_label "$EVIDENCE_LABEL" \ + --arg evidence_scope "$EVIDENCE_SCOPE" \ + --arg commit "$COMMIT" \ + --arg lock_sha256 "$LOCK_SHA256" \ + --arg toolchain "$TOOLCHAIN" \ + --arg run_utc "$RUN_UTC" \ + --argjson source_date_epoch "$SOURCE_DATE_EPOCH" \ + --arg cpuset "$CPUSET" \ + --argjson include_large "$INCLUDE_LARGE" \ + --argjson worktree_clean "$WORKTREE_CLEAN" \ + --argjson threads "$THREADS" \ + --argjson warm_up_seconds "$WARM_UP_SECONDS" \ + --argjson measurement_seconds "$MEASUREMENT_SECONDS" \ + --argjson sample_size "$SAMPLE_SIZE" \ + '{ + schema_version: ($schema_version | tonumber), + evidence_label: $evidence_label, + evidence_scope: $evidence_scope, + claim_status: "UNREVIEWED", + input_class: "SYNTHETIC", + backend: "burn-ndarray-cpu", + cuda_enabled: false, + source: { + commit: $commit, + cargo_lock_sha256: $lock_sha256, + worktree_clean: $worktree_clean, + source_date_epoch: $source_date_epoch + }, + environment: { + rust_toolchain: $toolchain, + threads: $threads, + cpuset: (if $cpuset == "" then null else $cpuset end) + }, + criterion: { + warm_up_seconds: $warm_up_seconds, + measurement_seconds: $measurement_seconds, + sample_size: $sample_size, + include_large_profile: ($include_large == 1) + }, + run_utc: $run_utc, + caveat: "Criterion timing is host-specific. This report is not a RuView capability claim until reviewed against ADR-348." + }' >"${OUTPUT_DIR}/metadata.json" + +run_logged() { + local log_name="$1" + shift + "$@" 2>&1 | tee "${OUTPUT_DIR}/${log_name}.log" +} + +run_logged core-tests \ + "${CARGO[@]}" test --manifest-path v2/Cargo.toml --locked \ + -p ruview-forecast-core --no-default-features --lib --tests + +run_logged model-cpu-tests \ + "${CARGO[@]}" test --manifest-path v2/Cargo.toml --locked \ + -p ruview-forecast-model --no-default-features --features cpu --lib --tests + +run_logged train-cpu-tests \ + "${CARGO[@]}" test --manifest-path v2/Cargo.toml --locked \ + -p ruview-forecast-train --no-default-features --features cpu,cli --lib --bins + +run_logged local-jsonl-training-smoke \ + "${CARGO[@]}" test --manifest-path v2/Cargo.toml --locked \ + -p ruview-forecast-train --no-default-features --features cpu \ + --test local_jsonl_smoke -- \ + --exact local_hash_addressed_jsonl_executes_one_real_optimizer_step + +run_logged cli-smoke-idempotency \ + "${CARGO[@]}" test --manifest-path v2/Cargo.toml --locked \ + -p ruview-forecast-train --no-default-features --features cpu,cli \ + --test cli_smoke -- \ + --exact cli_smoke_trains_and_writes_the_complete_candidate_set + +run_logged forecast-inference-compile \ + "${CARGO[@]}" bench --manifest-path v2/Cargo.toml --locked \ + -p ruview-forecast-model --no-default-features --features cpu \ + --bench forecast_inference --no-run + +run_logged data-pipeline-compile \ + "${CARGO[@]}" bench --manifest-path v2/Cargo.toml --locked \ + -p ruview-forecast-train --no-default-features --features cpu \ + --bench data_pipeline --no-run + +run_logged forecast-inference \ + "${CARGO[@]}" bench --manifest-path v2/Cargo.toml --locked \ + -p ruview-forecast-model --no-default-features --features cpu \ + --bench forecast_inference -- \ + --warm-up-time "$WARM_UP_SECONDS" \ + --measurement-time "$MEASUREMENT_SECONDS" \ + --sample-size "$SAMPLE_SIZE" + +run_logged data-pipeline \ + "${CARGO[@]}" bench --manifest-path v2/Cargo.toml --locked \ + -p ruview-forecast-train --no-default-features --features cpu \ + --bench data_pipeline -- \ + --warm-up-time "$WARM_UP_SECONDS" \ + --measurement-time "$MEASUREMENT_SECONDS" \ + --sample-size "$SAMPLE_SIZE" + +printf 'RuForecast CPU outputs collected; finalizing hashes in %s\n' "$OUTPUT_DIR" +printf 'Review metadata.json and the two Criterion logs before accepting or publishing evidence.\n' diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 3c32640a9..f03f1c664 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -1553,7 +1553,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -1791,36 +1791,36 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.123.13" +version = "0.123.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f8e1303ae2128891cb59691a74de4547dd208bc8511a2f287cca2b93bb3c728" +checksum = "6835dba958b2ab7ab523e7e99296e0524317f60430a00cf5850562ef78ea7001" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.123.13" +version = "0.123.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58b2740a5936332028d9a1e8f29a199de2fd386e426d44da5fea70cf8e3f8e75" +checksum = "0b6e4ce8ee6d899381fbdd9e6561336c651189d46cecaeee09b29e8d80aa786e" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.123.13" +version = "0.123.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37fd128d3629fb105e433bda09744c5a2959cd1da04617a455c4cc17dff1ebef" +checksum = "0cb6d37015df7ea4b60450c1229ad5f5819a1fb27434b063f8e6216dfbd0c42a" dependencies = [ "cranelift-entity", ] [[package]] name = "cranelift-bitset" -version = "0.123.13" +version = "0.123.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a88f6d5a6cf6fcbc6386415d48948094721dfa4585d6615938b11ca938f20a" +checksum = "986bea0b0858b55192782120032ce9c15943fa073f186f6e479653c59e62c329" dependencies = [ "serde", "serde_derive", @@ -1828,9 +1828,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.123.13" +version = "0.123.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daa4a357d030bdd586d8fe3da56b394f7f6b6ded506e59f945e7b32b1e126b71" +checksum = "9f30aeb2de7f97d6f26b4a1642615834daad58e2e4d7c027810010a3a32f22be" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -1855,9 +1855,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.123.13" +version = "0.123.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4121e36a8757dea6fb237435ee5095eba25bc13ecc2eaee57eb9bffd4b27784f" +checksum = "cd5dd137fcdedef33b6fd40edf1ced024460d764ceb75833e8198a843395945c" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -1868,24 +1868,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.123.13" +version = "0.123.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5173265fc30b9e42205cf06dfca9a272ee949667ce4115d975ed2c08d466e2f8" +checksum = "ab54b260ef23a8f0f536679b9fc3b3b3e05353e8d1448f3ab83df02078e8be9b" [[package]] name = "cranelift-control" -version = "0.123.13" +version = "0.123.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b6ca8a393e66dc13f915c6f456bdcf496d78fac4995792f7022b7806352d7a4" +checksum = "3f3e569779ad70537f34a670d444ee3d75ae583b2023913f4682814b0979f7e8" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.123.13" +version = "0.123.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e609d9ba416bc26d774f343295a1d411515248a6a6d83d5f7492a0dd919569fa" +checksum = "2ff53acc85f5c5f7d9315ff133a6671d329a0f04aa2d1a8a2e81d59709ccddcb" dependencies = [ "cranelift-bitset", "serde", @@ -1894,9 +1894,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.123.13" +version = "0.123.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90a3a277b1a0aff1123f6bae61c080a4bcb6df964829ed427f98e18dff14257f" +checksum = "ab5976c0ff5bfadf61cd8bda81fea78ee5a07018b9cd03e66c0952c56684928b" dependencies = [ "cranelift-codegen", "log", @@ -1906,15 +1906,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.123.13" +version = "0.123.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be53dd9b3a4cbeb9ced45c4a543ea8417ccfb334c3ba1cbb2233f4b25fb2531f" +checksum = "77b4f73d2288e9480fd2d1d9ab576394dce4805443d6148c6d819dbf78865ce4" [[package]] name = "cranelift-native" -version = "0.123.13" +version = "0.123.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca62ab1d9f48cad97da5843b913ccf96c3dfde935af5d750cb5a6d367ccfb262" +checksum = "fe9650c2baf22fa1e2542a5bdd8152616ec2023d929c4cbb450ff677ad8d9c21" dependencies = [ "cranelift-codegen", "libc", @@ -1923,9 +1923,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.123.13" +version = "0.123.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13b72860f54a2a19d3756bd47575fd8bddeafd6e5bbbf16372cdfebc482628a" +checksum = "4ad4f61ae701d73c326d3df08c366b29ad10f1ba06c245092f217b8d2306746b" [[package]] name = "crc" @@ -2900,7 +2900,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3333,7 +3333,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5280,7 +5280,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.2", "system-configuration", "tokio", "tower-service", @@ -5549,7 +5549,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6719,7 +6719,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7272,7 +7272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -8088,9 +8088,9 @@ dependencies = [ [[package]] name = "pulley-interpreter" -version = "36.0.13" +version = "36.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2662666315cb90dfb4d99a652ee053d4d8598f71c474209e84da031ca56ae5a4" +checksum = "eb0a4b56042e461cc64456650182938e2d1ede98fa0c8a975027416a2809c414" dependencies = [ "cranelift-bitset", "log", @@ -8100,9 +8100,9 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "36.0.13" +version = "36.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb9a7d9ed2618f94b6d054aba3eb2768c9b489fe16b4ef8847fbc6ed41b707bb" +checksum = "244667bea2e214273442a71f26adb12b88a41f66718fb2c6eea47c00f0dc325f" dependencies = [ "proc-macro2", "quote", @@ -8199,7 +8199,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.5.10", + "socket2 0.6.2", "thiserror 2.0.18", "tokio", "tracing", @@ -8238,9 +8238,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -8968,6 +8968,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rufield-adapters" +version = "0.1.0" +dependencies = [ + "rufield-core", + "rufield-provenance", + "serde", + "serde_json", + "sha2 0.10.9", +] + [[package]] name = "rufield-core" version = "0.1.0" @@ -9093,7 +9104,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9151,7 +9162,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -11125,7 +11136,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -12491,9 +12502,9 @@ dependencies = [ [[package]] name = "wasmtime" -version = "36.0.13" +version = "36.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d881c5dcff5f230368d84fcf110ca25fe47badc694e7d559c3891492159916" +checksum = "7d05c745dc0978e589ef295958f3130122afc33d96af6bad3f0f06dbe7ac43a8" dependencies = [ "addr2line", "anyhow", @@ -12546,9 +12557,9 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "36.0.13" +version = "36.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4b25534a3ff9dd844701c2bc997f76cb1f97bf2af0546a7066ae96f49c2e068" +checksum = "9fd1d43cfaa1a0859d2f4fccc15e7e571e2a88b357e81bc88ba6c501b83d925d" dependencies = [ "anyhow", "cpp_demangle", @@ -12573,18 +12584,18 @@ dependencies = [ [[package]] name = "wasmtime-internal-asm-macros" -version = "36.0.13" +version = "36.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d02832d760351fb3a1aa99eb8f7b95596c0f4e4e52df1547fcdb295ea5c780c" +checksum = "515dd7158bf1719b41290cd2e6a2a46ec944484146816992f195af3720e49b3f" dependencies = [ "cfg-if", ] [[package]] name = "wasmtime-internal-cache" -version = "36.0.13" +version = "36.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "651f8a16b51cde3ee8bd5b775bcedc24b66040d7dca1f13ad855b10c660e1d01" +checksum = "12a53145473629ea40f445235ed95182b76940f00a67c3c9c6c4857dae0ad823" dependencies = [ "anyhow", "base64 0.22.1", @@ -12602,9 +12613,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-macro" -version = "36.0.13" +version = "36.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba2ed6dbe24607573f7bcc59222f3bc2e7f7d31609466055c67b9484045a374a" +checksum = "dfca017b7daa80ff217c66f105ee20e674d87b7c11dca85bbb9d0146e9f443fb" dependencies = [ "anyhow", "proc-macro2", @@ -12617,15 +12628,15 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-util" -version = "36.0.13" +version = "36.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e21aadbf677ee3503ef2580ce3c6955508e90a2810e961739b30f79585af254c" +checksum = "1c3e218b51d2ef9eb181499e42c691512c1bc11dd6dc7746807a9b2b9290369c" [[package]] name = "wasmtime-internal-cranelift" -version = "36.0.13" +version = "36.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65821bab751956cddfc6ee957beb13a0e2a6cf682050d75dfb7a0228db2ed499" +checksum = "5ba1736927b58e50e741e407da7c037c0250f3e213833a09c89dcd8f73ae2eac" dependencies = [ "anyhow", "cfg-if", @@ -12650,9 +12661,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "36.0.13" +version = "36.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18cf73e5e8d28a2b30d86454430c7dea3b593598dcbbc0ebb927ca2716222d41" +checksum = "7b238e4c20bddb900ec0cb380252d63e8d0644fd94de001119574f5921e895d9" dependencies = [ "anyhow", "cc", @@ -12666,9 +12677,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "36.0.13" +version = "36.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4279dc3147ddaa21e5b3d2c0eaff117881c4f937747bdd2379eb361665843bd3" +checksum = "8f259b13685ad51e3dcf58cb69031279ed0d79c25bc3ccc8b50e7160ed04fbfe" dependencies = [ "cc", "object", @@ -12678,9 +12689,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "36.0.13" +version = "36.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8eb677944201839c0be19b39b0f19dcd663efbcbc05a09c73f26fec7bdf0331" +checksum = "41fed85537936b16460bac352ad149052c025db50467c7bc539dd47b31439374" dependencies = [ "anyhow", "cfg-if", @@ -12690,24 +12701,24 @@ dependencies = [ [[package]] name = "wasmtime-internal-math" -version = "36.0.13" +version = "36.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db9a153e184878df396a11f8912444531c2e4d0c7a1d9e9a52b30d9853d89cb9" +checksum = "82fff10da41d0d15d90ebba70946a0aa16ed0957ae7b77e0b6d2a46e8221e555" dependencies = [ "libm", ] [[package]] name = "wasmtime-internal-slab" -version = "36.0.13" +version = "36.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b771494bead25e1f0c4c89a9476dd0b65eacca314ed82125f1e197fbc3f0396b" +checksum = "e44a8c097bab08d349d57dce1ab818859fefbe261ab3632b38fe127b1b551108" [[package]] name = "wasmtime-internal-unwinder" -version = "36.0.13" +version = "36.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90199caed6925420434a923861d8ad813689ca8533d2c679f805d12e35b40a4b" +checksum = "7f40a57d5e7c221ce56391d7dca0a918ba17ea00185462c7facbf534d7745184" dependencies = [ "anyhow", "cfg-if", @@ -12718,9 +12729,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "36.0.13" +version = "36.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae71687aa834f9cc9eb5b0f97c85184d7dc70b84d32fd8927af0887998a1ba35" +checksum = "e085bfce1cb2089dbeef6e280a5d598666923d3dcd308712fe429fe43c9d19f5" dependencies = [ "proc-macro2", "quote", @@ -12729,9 +12740,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-winch" -version = "36.0.13" +version = "36.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0ba45c0d766dd56257d97702d3284b6f69efdd4c53ca981a0754cc0a139df0" +checksum = "c4916cd526e1ce294984cc5b70264cfc0ca103b41ca665b58728bde250b6b82f" dependencies = [ "anyhow", "cranelift-codegen", @@ -12746,9 +12757,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-wit-bindgen" -version = "36.0.13" +version = "36.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a64d5112c06f9d54b41e61f0b757d3a5a52361bf359f01131cc7cb8551ac73" +checksum = "39ad2f9d9c3baa70ee4d157b55b4c08e5dc00f1d80aad3692b1e0806393914ea" dependencies = [ "anyhow", "bitflags 2.11.0", @@ -13398,6 +13409,7 @@ name = "wifi-densepose-rufield" version = "0.3.0" dependencies = [ "base64 0.21.7", + "rufield-adapters", "rufield-core", "rufield-fusion", "rufield-privacy", @@ -13637,7 +13649,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -13648,9 +13660,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "winch-codegen" -version = "36.0.13" +version = "36.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82ea9625459ce35d6a4188cf0f07c9095cbb0e5afd86f711d63955bfc4216e64" +checksum = "e826c012c68403725e77adf6b904c2ea809e5d464aaf25aa6eda14559300b3df" dependencies = [ "anyhow", "cranelift-assembler-x64", diff --git a/v2/Cargo.toml b/v2/Cargo.toml index 84ce5359f..79eb5b1bd 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -126,6 +126,8 @@ members = [ # ADR-328..331 — consumer ToF transient NLOS capture, motion-induced # aperture tracking, governed CSI fusion, and native/web client contract. "crates/ruview-nlos", + # ADR-348 — RuForecast extracted to the ruvnet/RuForecast submodule + # (see crates/ruforecast in the exclude list below); path-deps only. ] # ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std), # excluded from workspace to avoid breaking `cargo test --workspace`. @@ -138,6 +140,12 @@ exclude = [ "crates/wifi-densepose-wasm-edge", "crates/homecore-plugin-example", "crates/worldgraph", # ruvnet/worldgraph submodule — its own workspace (geo/worldgraph/worldmodel) + "crates/ruforecast", # ruvnet/RuForecast submodule — its own workspace (ruforecast-core/model/train) + # LOCAL-DEV-ONLY: path-depends on an absolute, unpublished ruvnet/autogenous + # checkout that does not exist in CI. Build directly with + # `cargo build --manifest-path crates/ruforecast-autogenous-bridge/Cargo.toml` + # on a host with that checkout present. See its Cargo.toml/README. + "crates/ruforecast-autogenous-bridge", ] [workspace.package] @@ -223,6 +231,7 @@ walkdir = "2.4" # Hashing (for proof) sha2 = "0.10" +ed25519-dalek = "2.1" # CSV logging csv = "1.3" @@ -281,6 +290,9 @@ wifi-densepose-wasm = { version = "0.3.0", path = "crates/wifi-densepose-wasm" } wifi-densepose-mat = { version = "0.3.0", path = "crates/wifi-densepose-mat" } wifi-densepose-ruvector = { version = "0.3.0", path = "crates/wifi-densepose-ruvector" } wifi-densepose-worldmodel = { version = "0.3.0", path = "crates/worldgraph/wifi-densepose-worldmodel" } +ruforecast-core = { version = "0.1.0", path = "crates/ruforecast/crates/ruforecast-core" } +ruforecast-model = { version = "0.1.0", path = "crates/ruforecast/crates/ruforecast-model" } +ruforecast-train = { version = "0.1.0", path = "crates/ruforecast/crates/ruforecast-train" } [profile.release] lto = true diff --git a/v2/crates/ruforecast b/v2/crates/ruforecast new file mode 160000 index 000000000..448c035cc --- /dev/null +++ b/v2/crates/ruforecast @@ -0,0 +1 @@ +Subproject commit 448c035cc18f2fa7c05aab711c6ae0fd5cfc4e4b diff --git a/v2/crates/ruforecast-autogenous-bridge/Cargo.lock b/v2/crates/ruforecast-autogenous-bridge/Cargo.lock new file mode 100644 index 000000000..99b2d4ff8 --- /dev/null +++ b/v2/crates/ruforecast-autogenous-bridge/Cargo.lock @@ -0,0 +1,577 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "agl-types" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "antibody" +version = "0.1.0" +dependencies = [ + "agl-types", + "serde", + "serde_json", + "witness", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constitution" +version = "0.1.0" +dependencies = [ + "agl-types", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "envelope" +version = "0.1.0" +dependencies = [ + "agl-types", + "antibody", + "constitution", + "evaluator", + "serde", + "serde_json", + "sha2", + "witness", +] + +[[package]] +name = "evaluator" +version = "0.1.0" +dependencies = [ + "agl-types", + "antibody", + "serde", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "ruforecast-autogenous-bridge" +version = "0.1.0" +dependencies = [ + "agl-types", + "anyhow", + "clap", + "constitution", + "envelope", + "serde", + "serde_json", + "sha2", + "witness", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "witness" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/v2/crates/ruforecast-autogenous-bridge/Cargo.toml b/v2/crates/ruforecast-autogenous-bridge/Cargo.toml new file mode 100644 index 000000000..04c997bfa --- /dev/null +++ b/v2/crates/ruforecast-autogenous-bridge/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "ruforecast-autogenous-bridge" +version = "0.1.0" +edition = "2021" +publish = false +description = "LOCAL-DEV-ONLY bridge: signs/verifies a Darwin-found RuForecast hyperparameter candidate through Autogenous's regression-candidate promotion path (ruvnet/autogenous, not yet published/vendored). Path-dependency on a sibling local checkout; not part of any release build." + +[[bin]] +name = "ruforecast-autogenous-bridge" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" + +# LOCAL-DEV-ONLY path deps into a sibling, unpublished autogenous checkout. +# Not vendored/submoduled yet; this only builds on a host with this exact +# absolute checkout location (currently: ruvultra). See README.md. +envelope = { path = "/home/ruvultra/projects/autogenous/crates/envelope" } +agl-types = { path = "/home/ruvultra/projects/autogenous/crates/agl-types" } +constitution = { path = "/home/ruvultra/projects/autogenous/crates/constitution" } +witness = { path = "/home/ruvultra/projects/autogenous/crates/witness" } diff --git a/v2/crates/ruforecast-autogenous-bridge/examples/real_data_verify.rs b/v2/crates/ruforecast-autogenous-bridge/examples/real_data_verify.rs new file mode 100644 index 000000000..b94c15cf1 --- /dev/null +++ b/v2/crates/ruforecast-autogenous-bridge/examples/real_data_verify.rs @@ -0,0 +1,182 @@ +//! Independently verify the real-household-data WQL results (two real, +//! differently-split judges) through Autogenous's real regression-candidate +//! promotion path -- a genuine signed verdict, not a self-reported number. +//! LOCAL-DEV-ONLY, mirrors ruforecast-autogenous-bridge/src/main.rs's real +//! envelope::regression usage exactly, but consumes already-measured real +//! WQL numbers instead of running its own synthetic train_and_score. + +use agl_types::{Applicability, Authority, Genome, HardGates, Mutation, MutationScope}; +use anyhow::Result; +use constitution::{Constitution, RoleKeys}; +use envelope::regression::{ + artifact_hash, sign_regression_promotion, sign_regression_receipt, verify_regression_promotion, + MetricDirection, RegressionCandidateManifest, +}; +use witness::{content_hash, SigningAuthority}; + +struct RealJudge { + label: &'static str, + corpus_id: String, + sample_count: usize, + candidate_wql: f64, + parent_wql: f64, +} + +fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() +} + +fn main() -> Result<()> { + // Real numbers from two genuinely independent temporal splits of the + // same 6390-sample real household vitals corpus (train_fraction 0.70 + // vs 0.50, both with a 90s embargo). "candidate" = the trained model's + // WQL; "parent" = the better of the two trivial baselines on that same + // judge's held-out set (min(last_value, seasonal_naive)), matching this + // session's established `primary` convention throughout. + let judges = vec![ + RealJudge { + label: "split-70-30", + corpus_id: "ruforecast-real-home-lab-split-70".into(), + sample_count: 27, + candidate_wql: 0.0513991104899006, + parent_wql: 0.05630054057848733_f64.min(0.10253572536393343), + }, + RealJudge { + label: "split-50-50", + corpus_id: "ruforecast-real-home-lab-split-50".into(), + sample_count: 46, + candidate_wql: 0.06704526404505064, + parent_wql: 0.0618358588096424_f64.min(0.054265071040759255), + }, + ]; + + let now = now_unix(); + let candidate_bytes = b"real-home-lab-default-optimizer-spec-v1".to_vec(); + let parent_bytes = b"trivial-baseline-min-last-value-seasonal-naive".to_vec(); + let parent_genome_hash = artifact_hash(&parent_bytes); + + let judge_keys: Vec<SigningAuthority> = (0..judges.len() as u64) + .map(|i| { + let mut seed = [0u8; 32]; + seed[0] = 2; + seed[1..9].copy_from_slice(&i.to_le_bytes()); + SigningAuthority::from_seed(&format!("ruforecast-real-judge-{i}"), seed) + }) + .collect(); + let controller_key = SigningAuthority::from_seed("ruforecast-real-controller", [8u8; 32]); + + let constitution = Constitution { + identity: "ruforecast-real-data-promotion".into(), + version: 1, + authority_ceiling: Authority::Governed, + prohibited_effects: vec!["pii_egress".into()], + hard_gates: HardGates::default(), + signers: vec!["ruforecast-real-bridge-operator".into()], + pinned_keys: RoleKeys { + judges: judge_keys.iter().map(SigningAuthority::public_hex).collect(), + controllers: vec![controller_key.public_hex()], + }, + effective_at: now.saturating_sub(1), + }; + + let parent_genome = Genome { + hash: parent_genome_hash.clone(), + identity: "ruforecast-real-data-parent".into(), + constitution: constitution.hash(), + capability_ceiling: Authority::Governed, + hard_invariants: vec![], + lineage: vec![], + }; + + let mutation = Mutation { + id: format!("ruforecast-real-data-candidate-{now}"), + parent_genome_hash: parent_genome.hash.clone(), + scope: MutationScope::ApplicationCode, + requested_authority: Authority::Governed, + applicability: Applicability::default(), + preserved_invariants: vec![], + rollback_target: Some(parent_genome.hash.clone()), + expires_at: Some(now + 3600), + signature: None, + }; + + let manifest = RegressionCandidateManifest::from_parts( + mutation, + &candidate_bytes, + "weighted_quantile_loss", + MetricDirection::LowerIsBetter, + vec![], + vec![], + vec![], + ); + let candidate_hash = manifest.candidate_hash(); + let parent_hash_for_receipts = content_hash(&parent_genome.hash); + + let receipts: Vec<_> = judges + .iter() + .zip(judge_keys.iter()) + .map(|(j, judge)| { + sign_regression_receipt( + judge, + &candidate_hash, + &parent_hash_for_receipts, + &j.corpus_id, + j.sample_count, + j.candidate_wql, + j.parent_wql, + "ruforecast-real-data-verify-v1", + now, + ) + }) + .collect(); + + let promotion_envelope = sign_regression_promotion( + &controller_key, + &constitution.hash(), + &candidate_hash, + &receipts, + &format!("ruforecast-real-data-{now}"), + now, + 3600, + ); + + let min_samples = judges.iter().map(|j| j.sample_count).min().unwrap_or(1); + let margin = 0.01_f64; + + let rejections = verify_regression_promotion( + &constitution, + &parent_genome, + &manifest, + &receipts, + &promotion_envelope, + &[], + judges.len(), + min_samples, + margin, + now, + ); + + let decision = if rejections.is_empty() { "PROMOTE" } else { "REJECT" }; + let report = serde_json::json!({ + "decision": decision, + "candidate_hash": candidate_hash, + "constitution_hash": constitution.hash(), + "judges": judges.iter().map(|j| serde_json::json!({ + "label": j.label, + "corpus_id": j.corpus_id, + "sample_count": j.sample_count, + "candidate_wql": j.candidate_wql, + "parent_wql": j.parent_wql, + "candidate_beats_parent_by": j.parent_wql - j.candidate_wql, + })).collect::<Vec<_>>(), + "min_judges": judges.len(), + "min_samples": min_samples, + "non_inferiority_margin": margin, + "rejections": rejections.iter().map(|r| format!("{r:?}")).collect::<Vec<_>>(), + }); + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} diff --git a/v2/crates/ruforecast-autogenous-bridge/fixtures/bad.json b/v2/crates/ruforecast-autogenous-bridge/fixtures/bad.json new file mode 100644 index 000000000..7923aa504 --- /dev/null +++ b/v2/crates/ruforecast-autogenous-bridge/fixtures/bad.json @@ -0,0 +1 @@ +{"learning_rate": 0.0009293, "weight_decay": 0.01, "gradient_clip_norm": 0.1, "batch_size": 1, "epochs": 1} diff --git a/v2/crates/ruforecast-autogenous-bridge/fixtures/baseline.json b/v2/crates/ruforecast-autogenous-bridge/fixtures/baseline.json new file mode 100644 index 000000000..1b81bf030 --- /dev/null +++ b/v2/crates/ruforecast-autogenous-bridge/fixtures/baseline.json @@ -0,0 +1 @@ +{"learning_rate": 0.001, "weight_decay": 0.0001, "gradient_clip_norm": 1.0, "batch_size": 8, "epochs": 60} diff --git a/v2/crates/ruforecast-autogenous-bridge/fixtures/winner.json b/v2/crates/ruforecast-autogenous-bridge/fixtures/winner.json new file mode 100644 index 000000000..5a7c94d30 --- /dev/null +++ b/v2/crates/ruforecast-autogenous-bridge/fixtures/winner.json @@ -0,0 +1 @@ +{"learning_rate": 0.00002977567315122687, "weight_decay": 4.0937479376727585e-11, "gradient_clip_norm": 4.745827605560193, "batch_size": 26, "epochs": 356} diff --git a/v2/crates/ruforecast-autogenous-bridge/src/main.rs b/v2/crates/ruforecast-autogenous-bridge/src/main.rs new file mode 100644 index 000000000..76732b939 --- /dev/null +++ b/v2/crates/ruforecast-autogenous-bridge/src/main.rs @@ -0,0 +1,383 @@ +//! LOCAL-DEV-ONLY bridge from RuForecast's `evaluate` CLI into Autogenous's +//! regression-candidate promotion path (`envelope::regression`). +//! +//! Not a production path: it path-depends on a sibling, unpublished +//! `ruvnet/autogenous` checkout (see Cargo.toml), and the "judges" here are +//! real independent train+evaluate replays over genuinely distinct synthetic +//! corpora (different `--seed` per judge), not independent human/adversarial +//! review — see the module doc in `envelope::regression` for why that +//! distinction matters and why `min_judges`/`min_samples`/margin are passed +//! explicitly rather than assumed. +//! +//! Darwin's own promotion gate (`harness/ruview/flywheel/ruforecast/gate.mjs`) +//! still runs first and is unchanged; this is an additional, stronger, +//! cryptographically-checked verification step layered on top of a candidate +//! Darwin already found — defense in depth, not a gate replacement. + +use std::{ + path::{Path, PathBuf}, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +use agl_types::{Applicability, Authority, Genome, HardGates, Mutation, MutationScope}; +use anyhow::{bail, Context, Result}; +use clap::Parser; +use constitution::{Constitution, RoleKeys}; +use envelope::regression::{ + artifact_hash, sign_regression_promotion, sign_regression_receipt, verify_regression_promotion, + MetricDirection, RegressionCandidateManifest, +}; +use serde::{Deserialize, Serialize}; +use witness::{content_hash, SigningAuthority}; + +#[derive(Parser, Debug)] +#[command( + name = "ruforecast-autogenous-bridge", + about = "LOCAL-DEV-ONLY: sign/verify a RuForecast hyperparameter candidate through Autogenous's regression-candidate promotion path" +)] +struct Cli { + /// Path to the built `ruforecast` binary (ruforecast-train's CLI). + #[arg(long)] + ruforecast_bin: PathBuf, + /// Candidate hyperparameter genome, JSON: {learning_rate, weight_decay, gradient_clip_norm, batch_size, epochs}. + #[arg(long)] + candidate_genome: PathBuf, + /// Parent (baseline) hyperparameter genome, same JSON shape. + #[arg(long)] + parent_genome: PathBuf, + /// Independent judges: full train+evaluate replays on genuinely distinct synthetic corpora. + #[arg(long, default_value_t = 2)] + judges: u64, + /// Synthetic training windows per corpus. + #[arg(long, default_value_t = 24)] + train_windows: u64, + /// Synthetic held-out windows per corpus. + #[arg(long, default_value_t = 8)] + test_windows: u64, + /// Scratch directory for per-judge corpora/artifacts (created fresh; must not already exist). + #[arg(long)] + work_dir: PathBuf, + /// Non-inferiority margin on weighted quantile loss (lower-is-better; a candidate must + /// beat its parent by at least this much on every judge's receipt to be admissible). + #[arg(long, default_value_t = 0.01)] + margin: f64, + /// Minimum held-out samples a receipt must report (floor stated honestly for this + /// fixture's scale, not the detector domain's unrelated 1000-sample bar). + #[arg(long, default_value_t = 4)] + min_samples: usize, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct HyperparamGenome { + learning_rate: f64, + weight_decay: f64, + gradient_clip_norm: f64, + batch_size: u16, + epochs: u16, +} + +struct JudgeMeasurement { + seed: u64, + corpus_id: String, + sample_count: usize, + candidate_metric: f64, + parent_metric: f64, +} + +fn now_unix() -> Result<u64> { + Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()) +} + +fn run_ruforecast(bin: &Path, args: &[&str], label: &str) -> Result<String> { + let output = Command::new(bin) + .args(args) + .output() + .with_context(|| format!("spawning ruforecast for {label}"))?; + if !output.status.success() { + bail!( + "ruforecast {label} failed (exit {:?}):\nstdout: {}\nstderr: {}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// Prepare a synthetic corpus + train + evaluate one hyperparameter genome +/// against it, returning (weighted_quantile_loss, n_test_windows). +fn train_and_score( + bin: &Path, + genome: &HyperparamGenome, + corpus_dir: &Path, + seed: u64, + train_windows: u64, + test_windows: u64, +) -> Result<(f64, usize)> { + std::fs::create_dir_all(corpus_dir.parent().unwrap_or(corpus_dir))?; + run_ruforecast( + bin, + &[ + "prepare-synthetic-dataset", + "--directory", + corpus_dir.to_str().context("non-utf8 path")?, + "--seed", + &seed.to_string(), + "--train-windows", + &train_windows.to_string(), + "--test-windows", + &test_windows.to_string(), + "--learning-rate", + &genome.learning_rate.to_string(), + "--weight-decay", + &genome.weight_decay.to_string(), + "--gradient-clip-norm", + &genome.gradient_clip_norm.to_string(), + "--batch-size", + &genome.batch_size.to_string(), + "--epochs", + &genome.epochs.to_string(), + ], + "prepare-synthetic-dataset", + )?; + + let artifacts_dir = corpus_dir.join("artifacts"); + run_ruforecast( + bin, + &[ + "train-local", + "--request", + corpus_dir.join("train-local.toml").to_str().context("non-utf8 path")?, + "--dataset-root", + corpus_dir.to_str().context("non-utf8 path")?, + "--output", + artifacts_dir.to_str().context("non-utf8 path")?, + ], + "train-local", + )?; + + let candidate_mpk = artifacts_dir.join("synthetic-dataset").join("model.mpk"); + let eval_stdout = run_ruforecast( + bin, + &[ + "evaluate", + "--candidate", + candidate_mpk.to_str().context("non-utf8 path")?, + "--test-jsonl", + corpus_dir.join("test.jsonl").to_str().context("non-utf8 path")?, + "--seasonal-period", + "12", + ], + "evaluate", + )?; + let report: serde_json::Value = + serde_json::from_str(&eval_stdout).context("parsing evaluate JSON output")?; + let wql = report["model"]["weighted_quantile_loss"] + .as_f64() + .context("evaluate output missing model.weighted_quantile_loss")?; + let n = report["n_test_windows"] + .as_u64() + .context("evaluate output missing n_test_windows")? as usize; + Ok((wql, n)) +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + if cli.work_dir.exists() { + bail!( + "--work-dir {} already exists; pass a fresh directory", + cli.work_dir.display() + ); + } + if cli.judges == 0 { + bail!("--judges must be at least 1 (state min_judges: 1 honestly rather than 0)"); + } + std::fs::create_dir_all(&cli.work_dir)?; + + let candidate: HyperparamGenome = serde_json::from_str( + &std::fs::read_to_string(&cli.candidate_genome).context("reading --candidate-genome")?, + ) + .context("parsing --candidate-genome JSON")?; + let parent: HyperparamGenome = serde_json::from_str( + &std::fs::read_to_string(&cli.parent_genome).context("reading --parent-genome")?, + ) + .context("parsing --parent-genome JSON")?; + + let mut measurements = Vec::with_capacity(cli.judges as usize); + for judge_index in 0..cli.judges { + // Genuine corpus independence: each judge trains+evaluates BOTH + // genomes against its own freshly-generated synthetic corpus (same + // seed for candidate/parent within a judge, so the comparison is + // apples-to-apples on that judge's held-out set; different seed + // ACROSS judges, so disagreement between judges is a real signal + // about generalization, not a re-run of identical arithmetic). + let seed = 1000 + judge_index * 97; + let judge_dir = cli.work_dir.join(format!("judge-{judge_index}")); + let (candidate_wql, candidate_n) = train_and_score( + &cli.ruforecast_bin, + &candidate, + &judge_dir.join("candidate"), + seed, + cli.train_windows, + cli.test_windows, + ) + .with_context(|| format!("judge {judge_index}: scoring candidate genome"))?; + let (parent_wql, parent_n) = train_and_score( + &cli.ruforecast_bin, + &parent, + &judge_dir.join("parent"), + seed, + cli.train_windows, + cli.test_windows, + ) + .with_context(|| format!("judge {judge_index}: scoring parent genome"))?; + if candidate_n != parent_n { + bail!( + "judge {judge_index}: candidate/parent test-window counts disagree ({candidate_n} vs {parent_n}) — corpus was not actually shared" + ); + } + measurements.push(JudgeMeasurement { + seed, + corpus_id: format!("ruforecast-synthetic-seed-{seed}"), + sample_count: candidate_n, + candidate_metric: candidate_wql, + parent_metric: parent_wql, + }); + } + + let now = now_unix()?; + let candidate_bytes = serde_json::to_vec(&candidate)?; + let parent_bytes = serde_json::to_vec(&parent)?; + let parent_genome_hash = artifact_hash(&parent_bytes); + + let judge_keys: Vec<SigningAuthority> = (0..cli.judges) + .map(|i| { + let mut seed = [0u8; 32]; + seed[0] = 1; + seed[1..9].copy_from_slice(&i.to_le_bytes()); + SigningAuthority::from_seed(&format!("ruforecast-judge-{i}"), seed) + }) + .collect(); + let controller_key = SigningAuthority::from_seed("ruforecast-controller", [9u8; 32]); + + let constitution = Constitution { + identity: "ruforecast-hpo-promotion".into(), + version: 1, + authority_ceiling: Authority::Governed, + prohibited_effects: vec!["pii_egress".into()], + hard_gates: HardGates::default(), + signers: vec!["ruforecast-bridge-operator".into()], + pinned_keys: RoleKeys { + judges: judge_keys.iter().map(SigningAuthority::public_hex).collect(), + controllers: vec![controller_key.public_hex()], + }, + effective_at: now.saturating_sub(1), + }; + + let parent_genome = Genome { + hash: parent_genome_hash.clone(), + identity: "ruforecast-hyperparameter-genome".into(), + constitution: constitution.hash(), + capability_ceiling: Authority::Governed, + hard_invariants: vec![], + lineage: vec![], + }; + + let mutation = Mutation { + id: format!("ruforecast-hpo-candidate-{now}"), + parent_genome_hash: parent_genome.hash.clone(), + scope: MutationScope::ApplicationCode, + requested_authority: Authority::Governed, + applicability: Applicability::default(), + preserved_invariants: vec![], + rollback_target: Some(parent_genome.hash.clone()), + expires_at: Some(now + 3600), + signature: None, + }; + + let manifest = RegressionCandidateManifest::from_parts( + mutation, + &candidate_bytes, + "weighted_quantile_loss", + MetricDirection::LowerIsBetter, + vec![], + vec![], + vec![], + ); + let candidate_hash = manifest.candidate_hash(); + let parent_hash_for_receipts = content_hash(&parent_genome.hash); + + let receipts: Vec<_> = measurements + .iter() + .zip(judge_keys.iter()) + .map(|(m, judge)| { + sign_regression_receipt( + judge, + &candidate_hash, + &parent_hash_for_receipts, + &m.corpus_id, + m.sample_count, + m.candidate_metric, + m.parent_metric, + "ruforecast-autogenous-bridge-v1", + now, + ) + }) + .collect(); + + let promotion_envelope = sign_regression_promotion( + &controller_key, + &constitution.hash(), + &candidate_hash, + &receipts, + &format!("ruforecast-hpo-{now}"), + now, + 3600, + ); + + let min_samples = cli.min_samples.min( + measurements + .iter() + .map(|m| m.sample_count) + .min() + .unwrap_or(cli.min_samples), + ); + + let rejections = verify_regression_promotion( + &constitution, + &parent_genome, + &manifest, + &receipts, + &promotion_envelope, + &[], + cli.judges as usize, + min_samples, + cli.margin, + now, + ); + + let decision = if rejections.is_empty() { "PROMOTE" } else { "REJECT" }; + let report = serde_json::json!({ + "decision": decision, + "candidate_hash": candidate_hash, + "constitution_hash": constitution.hash(), + "judges": measurements.iter().map(|m| serde_json::json!({ + "seed": m.seed, + "corpus_id": m.corpus_id, + "sample_count": m.sample_count, + "candidate_wql": m.candidate_metric, + "parent_wql": m.parent_metric, + "candidate_beats_parent_by": m.parent_metric - m.candidate_metric, + })).collect::<Vec<_>>(), + "min_judges": cli.judges, + "min_samples": min_samples, + "non_inferiority_margin": cli.margin, + "rejections": rejections.iter().map(|r| format!("{r:?}")).collect::<Vec<_>>(), + }); + println!("{}", serde_json::to_string_pretty(&report)?); + if decision == "REJECT" { + std::process::exit(1); + } + Ok(()) +} diff --git a/v2/crates/wifi-densepose-rufield/Cargo.toml b/v2/crates/wifi-densepose-rufield/Cargo.toml index 14e8d8e63..c4d784f36 100644 --- a/v2/crates/wifi-densepose-rufield/Cargo.toml +++ b/v2/crates/wifi-densepose-rufield/Cargo.toml @@ -20,6 +20,10 @@ rufield-core = { version = "0.1.0", path = "../../../vendor/rufield/crates/rufie rufield-provenance = { version = "0.1.0", path = "../../../vendor/rufield/crates/rufield-provenance" } rufield-privacy = { version = "0.1.0", path = "../../../vendor/rufield/crates/rufield-privacy" } rufield-fusion = { version = "0.1.0", path = "../../../vendor/rufield/crates/rufield-fusion" } +# ADR-262 P4 second modality: UltrasonicReplayAdapter parses, validates and +# signs BatVu range profiles upstream, so RuView only has to decide what it is +# willing to put on a wire. Same pure-Rust constraint as the four above. +rufield-adapters = { version = "0.1.0", path = "../../../vendor/rufield/crates/rufield-adapters" } serde = { workspace = true } serde_json = { workspace = true } base64 = "0.21" diff --git a/v2/crates/wifi-densepose-rufield/src/bridge.rs b/v2/crates/wifi-densepose-rufield/src/bridge.rs index 09dabec21..32b2b8be5 100644 --- a/v2/crates/wifi-densepose-rufield/src/bridge.rs +++ b/v2/crates/wifi-densepose-rufield/src/bridge.rs @@ -145,6 +145,21 @@ pub fn snapshot_to_field_event(snap: &SensingSnapshot, signer: &Signer) -> Field features: build_features(snap, range_m), labels: build_labels(snap), privacy_class: class, + // ── fields added upstream since the previous submodule pin ───────── + // + // Each empty for its own reason rather than because a default was + // convenient. `track_id`: this bridge emits per-cycle snapshots, not + // tracks, and a stable per-person identifier is exactly what §3.3 + // warns about. `attributes`: nothing `features` and `labels` do not + // already carry, and an empty map is omitted from the wire. + // `identity_evidence` / `channel_sounding_provenance`: both are + // structurally BLE-only — `validate_evidence_at` rejects either on any + // other modality — so for `wifi_csi` they are not unset, they are + // unrepresentable. + track_id: None, + attributes: Default::default(), + identity_evidence: None, + channel_sounding_provenance: None, }; // ── 3. Provenance (real sha256 over the tensor bytes) ─────────────────── @@ -166,6 +181,15 @@ pub fn snapshot_to_field_event(snap: &SensingSnapshot, signer: &Signer) -> Field vendor: "esp32".to_string(), device_id: snap.node_id.clone(), placement: "unknown".to_string(), + // Optional sensor pose, added upstream. Left unset on purpose: a CSI + // link has no boresight and no surveyed position here, and §6 is + // explicit that this surface makes no validated room-coordinate claim + // (`field_localize`). A placeholder pose would be inventing exactly + // that claim. The ultrasonic path DOES set these, because a handheld + // scanner genuinely knows where it was pointing. + coordinate_frame: None, + position_m: None, + orientation_xyzw: None, clock_domain: "local".to_string(), }; diff --git a/v2/crates/wifi-densepose-rufield/src/lib.rs b/v2/crates/wifi-densepose-rufield/src/lib.rs index ae148d901..3a7585d6e 100644 --- a/v2/crates/wifi-densepose-rufield/src/lib.rs +++ b/v2/crates/wifi-densepose-rufield/src/lib.rs @@ -69,6 +69,7 @@ pub mod bridge; pub mod lidar; pub mod privacy; pub mod snapshot; +pub mod ultrasonic; pub use bridge::{snapshot_egress_class, snapshot_to_field_event}; pub use lidar::{lidar_packet_to_field_event, LidarBridgeError, LidarDepthPacket}; @@ -76,6 +77,7 @@ pub use privacy::{apply_demotion_floor, egress_class, map_privacy}; pub use snapshot::{ RuViewPrivacyClass, SensingClass, SensingFeatures, SensingSnapshot, SignalField, }; +pub use ultrasonic::{ScanError, ScanSource, UltrasonicScan, ULTRASONIC_EGRESS_CLASS}; // Re-export the rufield surface a bridge consumer needs, so callers depend on // one crate. diff --git a/v2/crates/wifi-densepose-rufield/src/lidar.rs b/v2/crates/wifi-densepose-rufield/src/lidar.rs index 97241dc32..632a5aeeb 100644 --- a/v2/crates/wifi-densepose-rufield/src/lidar.rs +++ b/v2/crates/wifi-densepose-rufield/src/lidar.rs @@ -275,8 +275,12 @@ pub fn lidar_packet_to_field_event( motion_vector: None, confidence, features, + track_id: None, + attributes: Default::default(), labels: vec!["direct_visible_depth".into(), "not_nlos".into()], privacy_class: PrivacyClass::P1, + identity_evidence: None, + channel_sounding_provenance: None, }; let provenance = ProvenanceRef { raw_hash, @@ -292,6 +296,9 @@ pub fn lidar_packet_to_field_event( vendor: "apple_arkit".into(), device_id: sensor_id.to_string(), placement: packet.provenance.coordinate_frame_id.clone(), + coordinate_frame: Some(packet.provenance.coordinate_frame_id.clone()), + position_m: None, + orientation_xyzw: None, clock_domain: "iphone_unix".into(), }; let mut event = FieldEvent::new( diff --git a/v2/crates/wifi-densepose-rufield/src/ultrasonic.rs b/v2/crates/wifi-densepose-rufield/src/ultrasonic.rs new file mode 100644 index 000000000..7d5cd8770 --- /dev/null +++ b/v2/crates/wifi-densepose-rufield/src/ultrasonic.rs @@ -0,0 +1,233 @@ +//! ADR-262 **P4, second modality**: ultrasonic range profiles on the field +//! surface. +//! +//! Until now RuView's `/api/field` and `/ws/field` have carried exactly one +//! modality — WiFi CSI, through [`crate::bridge`]. ADR-262 §4 lists multi- +//! modality as P4 and leaves the choice of the second one open (§8 question 5 +//! asks whether it should be `rvcsi`, "making RuField the convergence point for +//! both vendored sensing runtimes"). +//! +//! This is a different answer to that question, and the reason is that it costs +//! almost nothing: `rufield-adapters` now ships +//! [`UltrasonicReplayAdapter`](rufield_adapters::UltrasonicReplayAdapter), the +//! first adapter for `Modality::Ultrasonic` — registry code 7, which had sat in +//! the §8 registry since v0.1 with nothing implementing it. It parses, validates +//! and **signs** BatVu recordings upstream. RuView does not have to build any of +//! that; it has to decide what it is willing to put on a wire. +//! +//! # What BatVu is, in one paragraph +//! +//! [BatVu](https://github.com/ruvnet/batvu) is a handheld sonar that runs in a +//! phone browser: a 17.5–20.5 kHz chirp out of the speaker, a matched filter +//! over the microphone, and a **range profile** — echo amplitude against +//! distance along one beam. One microphone, so it measures range and infers +//! bearing only from where the operator was pointing. That asymmetry is why the +//! beam is carried as sensor *pose* rather than as an angle axis. +//! +//! # Why this module exists at all, given the adapter does the work +//! +//! Because RuView's egress rule is **stricter** than RuField's default guard, +//! and the difference has to be structural rather than a runtime refusal. +//! +//! The adapter offers two output modes. [`UltrasonicOutput::RangeProfile`] is +//! the full per-bin frame, classified `P0`, which +//! [`network_egress_allowed`](crate::network_egress_allowed) holds edge-local — +//! correctly, but *silently*, as a dropped event at the end of a pipeline that +//! did all the parsing and signing first. +//! [`UltrasonicOutput::CoarseProfile`] is a 32-bin max-pooled reduction, +//! classified `P1`, which is egress-safe. +//! +//! So this module does not offer the choice. It configures the adapter for the +//! coarse mode and says why, because a consumer cannot un-coarsen a coarse +//! profile and a policy expressed in the *shape of the data* cannot be +//! misconfigured later. The egress gate still runs — belt and braces, and it is +//! asserted by a test — but by then there is nothing left for it to catch. +//! +//! # Honesty (ADR-262 §0 / §6) +//! +//! Same posture as P1, plus one more caveat that is BatVu's rather than ours: +//! +//! 1. **Replay, not live.** A file, not a streaming phone. +//! 2. **Every current BatVu recording is its own simulator's output**, so the +//! events carry `synthetic: true` and are fusable only under +//! [`TrustPolicy::simulation`](rufield_provenance::TrustPolicy::simulation). +//! `captured_replay()` and `production()` reject them outright, as they +//! should. When a real device recording exists it declares +//! `device_capture` and this module refuses it unless the caller has asked +//! for that source explicitly — a recording cannot talk its way up a trust +//! tier by relabelling itself. +//! 3. **No accuracy is claimed.** The detections are CFAR outputs at a +//! documented false-alarm rate. That is an operating point, not validated +//! accuracy against surveyed ground truth. +//! 4. **Nothing here says a person is present.** The adapter deliberately +//! declines to populate the `presence` feature that would light up the +//! shipped `person_present` rule: one transducer pair cannot distinguish a +//! person from a coat over the back of a chair. The visible consequence is +//! that ultrasonic events currently produce no fused inferences at all, and +//! `ultrasonic_gates.rs` asserts that rather than papering over it. +//! +//! # What this does NOT do +//! +//! It does not touch the running server. P1 shipped as a library before P3 +//! wired it in, and this follows the same staging: a tested translation with +//! its own gates, reviewable on its own, with the surface change as a separate +//! change once the shape is agreed. [`UltrasonicScan::egress_events`] returns +//! exactly what `FieldSurface::emit` would need to broadcast. + +use rufield_adapters::{ + UltrasonicConfig, UltrasonicOutput, UltrasonicReplayAdapter, UltrasonicSource, +}; +use rufield_core::{FieldEvent, PrivacyClass}; + +use crate::network_egress_allowed; + +/// Which recordings a deployment is willing to accept. +/// +/// Mirrors [`UltrasonicSource`] rather than re-exporting it, so a caller does +/// not have to depend on `rufield-adapters` to make the choice — the bridge +/// stays the single coupling point (ADR-262 §5.4). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScanSource { + /// Rendered by BatVu's acoustic simulator. Events are `synthetic: true`. + /// The safe default: it cannot be mistaken for a measurement. + Simulated, + /// Captured from a real phone microphone. Events are `synthetic: false` + /// and are eligible for captured-replay trust once the sensor key is + /// enrolled. + DeviceCapture, +} + +impl From<ScanSource> for UltrasonicSource { + fn from(s: ScanSource) -> Self { + match s { + ScanSource::Simulated => UltrasonicSource::Simulated, + ScanSource::DeviceCapture => UltrasonicSource::DeviceCapture, + } + } +} + +/// Errors from loading a BatVu recording. +#[derive(Debug, Clone, PartialEq)] +pub enum ScanError { + /// The recording failed the adapter's parser. Carries its message, which + /// names the offending line. + Parse(String), + /// Calibration could not be established. + Calibrate(String), +} + +impl std::fmt::Display for ScanError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ScanError::Parse(m) => write!(f, "ultrasonic recording rejected: {m}"), + ScanError::Calibrate(m) => write!(f, "ultrasonic calibration failed: {m}"), + } + } +} + +impl std::error::Error for ScanError {} + +/// A loaded BatVu scan, ready to put on the field surface. +#[derive(Debug)] +pub struct UltrasonicScan { + adapter: UltrasonicReplayAdapter, + calibration_id: String, +} + +impl UltrasonicScan { + /// Load a `.ultrasonic.jsonl` recording. + /// + /// `zone_id` is the room the scan was taken in and lands on every + /// observation. `accept` declares which source the deployment will take; a + /// recording declaring anything else is **refused**, in both directions, so + /// neither is a silent reinterpretation. + /// + /// Calibration runs here rather than being optional. The receipt id ends up + /// on every tensor, so an event that skipped it would be one nobody could + /// later ask "under what calibration was this measured". + pub fn load(text: &str, zone_id: &str, accept: ScanSource) -> Result<Self, ScanError> { + let config = UltrasonicConfig { + accept: accept.into(), + zone_id: zone_id.to_string(), + placement: "handheld".to_string(), + // Not a caller's choice. See the module docs: the raw per-bin frame + // is P0 and would be dropped by the egress gate after all the work + // of parsing and signing it. Coarsening at the source makes the + // policy a property of the data instead of a property of a check + // somebody could later reorder. + output: UltrasonicOutput::CoarseProfile, + }; + let mut adapter = UltrasonicReplayAdapter::from_jsonl_with(text, config) + .map_err(|e| ScanError::Parse(e.to_string()))?; + let receipt = adapter + .calibrate(zone_id) + .map_err(|e| ScanError::Calibrate(e.to_string()))?; + Ok(UltrasonicScan { + adapter, + calibration_id: receipt.calibration_id, + }) + } + + /// Pings in the recording. + #[must_use] + pub fn ping_count(&self) -> usize { + self.adapter.ping_count() + } + + /// The sensor identity the whole recording belongs to. + /// + /// This is the key RuField's replay watermark and trust registry are both + /// keyed on, so it has to be stable across scans — the adapter refuses a + /// recording whose device changes midway. + #[must_use] + pub fn device_id(&self) -> &str { + self.adapter.device_id() + } + + /// The calibration receipt every tensor in this scan cites. + #[must_use] + pub fn calibration_id(&self) -> &str { + &self.calibration_id + } + + /// Every event in the recording, signed and validated by the adapter. + /// + /// Includes events that the egress gate would refuse. Use this for an + /// edge-local consumer; use [`egress_events`](Self::egress_events) for + /// anything that reaches a network. + pub fn events(&mut self) -> Result<Vec<FieldEvent>, ScanError> { + self.adapter + .collect_events() + .map_err(|e| ScanError::Parse(e.to_string())) + } + + /// Only the events RuView will put on the wire. + /// + /// Runs the same [`network_egress_allowed`] gate the CSI path runs, with + /// `identity_bound: false` — an ultrasonic scan has no identity to bind, and + /// hard-coding it rather than plumbing a flag means there is no argument a + /// caller can pass that turns the P4/P5 consent exception on. There is no + /// consent story for a room scan. + /// + /// In the coarse mode configured by [`load`](Self::load) this should drop + /// nothing, and `coarse_scan_passes_the_egress_gate_intact` asserts it. The + /// gate stays because a policy that is only enforced where it never fires + /// is a policy nobody notices removing. + pub fn egress_events(&mut self) -> Result<Vec<FieldEvent>, ScanError> { + Ok(self + .events()? + .into_iter() + .filter(|e| network_egress_allowed(e.observation.privacy_class, false)) + .filter(|e| network_egress_allowed(e.tensor.privacy_class, false)) + .collect()) + } +} + +/// The privacy class every event from [`UltrasonicScan`] carries. +/// +/// `P1` — a derived non-identity feature. Stated as a constant so a test can +/// pin it: if the adapter's default output mode ever changes upstream, that is +/// a submodule bump which silently moves data from edge-local to network-eligible +/// or back, and it should fail a build here rather than change behaviour in a +/// deployment. +pub const ULTRASONIC_EGRESS_CLASS: PrivacyClass = PrivacyClass::P1; diff --git a/v2/crates/wifi-densepose-rufield/tests/fixtures/batvu_living_room.ultrasonic.jsonl b/v2/crates/wifi-densepose-rufield/tests/fixtures/batvu_living_room.ultrasonic.jsonl new file mode 100644 index 000000000..59de13881 --- /dev/null +++ b/v2/crates/wifi-densepose-rufield/tests/fixtures/batvu_living_room.ultrasonic.jsonl @@ -0,0 +1,72 @@ +{"timestamp":1756162800,"source":"simulated","device_id":"batvu-reference-01","beam":[0,-0.9063078,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000181,0.0000199,0.0000296,0.0000423,0.0000753,0.0000649,0.0000353,0.0000546,0.0000678,0.0000341,0.0000214,0.0000261,0.0000025,0,0.0000408,0.0000561,0.0000694,0.000075,0.0000335,0.0000171,0.0000264,0.0000162,0.0000437,0.0000641,0.0000422,0.0000274,0.0000622,0.0000561,0.0000389,0.0000256,0.0000256,0.0000336,0.0000499,0.0000442,0.000032,0.0000312,0.000026,1e-7,0.0000226,0.0000252,0.0000028,0.0000261,0.0000394,0.0000286,0.0000096,0.0000236,0.0000383,0.0000509,0.0000553,0.0000331,0.0000366,0.0000671,0.0000645,0.0000557,0.0000436,0.0000296,0.000048,0.0000668,0.000063,0.0000546,0.0000595,0.0000492,0.0000152,0.0000373,0.0000449,0.0000455,0.0000443,0.0000361,0,0,0.0000078,0.0000151,0.0000314,0.000043,0.0000475,0.0000642,0.0000715,0.0000608,0.0000681,0.0000857,0.0000821,0.000071,0.0000648,0.0000468,0.000028,0.0000339,0.0000276,0.0000145,0.0000421,0.0000414,0.0000401,0.0000571,0.0000586,0.000055,0.0000516,0.0000447,0.0000028,0.0000136,0.0000216,0.0000224,0.0000319,0.000039,0.0000351,0.0000387,0.0000407,0.0000267,0.0000305,0.0000395,0.0000837,0.0002284,0.0005318,0.0008717,0.0012896,0.0017333,0.0021146,0.0023917,0.002502,0.0024966,0.0022837,0.0019285,0.0014871,0.0010271,0.0006496,0.0003871,0.0002045,0.0001008,0.000048,0.0000215,0.0000333,0.000042,0.0000393,0.0000339,0.0000273,0.0000061,0.0000087,0.0000163,0.0000142,0.000024,0.0000383,0.0000374,0.0000325,0.0000312,0.0000253,0.000021,0.000034,0.0000338,0.0000267,0.0000299,0.0000287,0.0000189,0.0000248,0.0000286,0.0000259,0.0000201,0.0000126,0.0000194,0.000028,0.0000307,0.0000208,0.0000246,0.0000386,0.0000424,0.0000538,0.0000652,0.0000682,0.000071,0.0000711,0.0000604,0.0000396,0.0000518,0.0000507,0.0000421,0.0000445,0.0000523,0.0000613,0.0000637,0.0000625,0.000031,0.0000224,0.0000334,0.0000361,0.000036,0.0000328,0.0000176,0.0000346,0.0000436,0.0000489,0.0000621,0.0000721,0.0000706,0.0000654,0.0000587,0.0000371,0.0000126,0.0000276,0.0000282,0.0000264,0.0000287,0.0000309,0.0000343,0.0000343,0.0000291,0.0000099,0.0000091,0.0000197,0.00004,0.0000807,0.0001038,0.0001159,0.0001184,0.0001168,0.0000931,0.000065,0.0000468,0.0000272,0.0000153,0.0000089,0.0000039,0.0000154,0.0000304,0.0000358,0.0000466,0.0000534,0.0000501,0.0000388,0.0000299,0.000022,0.0000039,0.000011,0.0000115,0.0000082,0.0000154,0.0000285,0.0000404,0.0000538,0.0000608,0.0000715,0.0000906,0.0001001,0.0000993],"detections":[{"range_m":2.738708,"snr_db":27.6382,"width_m":0.171607}],"noise_floor":0.000035574,"blast_amplitude":0.2699276,"saturated":false} +{"timestamp":1756162800.0666666,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.2345697,-0.8754261,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.000015,0.0000089,0,0,0.000089,0.0000914,0.0000789,0.0000676,0.0000659,0.0000243,0.0000868,0.0001153,0.000114,0.0001099,0.000106,0.0000423,0.0000332,0.0000434,0.0000195,0.0000231,0.0000387,0.0000207,0.0000158,0.0000348,0.0000244,0.000021,0.0000282,0.0000132,0.0000135,0.0000372,0.0000279,0.0000311,0.0000595,0.0000549,0.000041,0.0000647,0.0000696,0.0000486,0.0000368,0.0000261,0,0.000009,0.0000247,0.0000214,0.0000195,0.0000208,3e-7,0.0000031,0.0000266,0.0000293,0.0000309,0.0000322,0.0000096,0.0000074,0.0000165,0.0000075,0,0.000014,0.0000135,0.0000094,0.0000145,0.0000164,0.0000259,0.0000441,0.0000457,0.000042,0.0000543,0.0000535,0.00003,0.0000227,0.0000283,0.0000249,0.0000316,0.0000368,0.0000249,0.0000226,0.0000224,0.0000139,0.0000704,0.0001232,0.0001372,0.0001373,0.0001306,0.0001013,0.0000682,0.0000659,0.0000625,0.0000553,0.0000557,0.0000444,0.0000153,0.0000205,0.0000213,0.0000153,0.0000236,0.0000257,0.0000023,0.0000282,0.0000495,0.000053,0.0000524,0.0000455,0.0000169,0.0000103,0.0000215,0.0000288,0.0000394,0.0000453,0.0000356,0.0000184,0.0000387,0.0001307,0.000308,0.0005791,0.000915,0.0013178,0.001738,0.0021372,0.0023362,0.0023766,0.002347,0.0021355,0.0017912,0.0013838,0.0009609,0.0006085,0.0003179,0.0001483,0.0000463,0.0000625,0.0000754,0.0000766,0.000074,0.0000525,0.0000623,0.0000756,0.0000733,0.0000667,0.0000621,0.0000499,0.0000173,0.0000342,0.0000408,0.0000422,0.000045,0.0000433,0.0000375,0.0000514,0.0000515,0.0000467,0.0000439,0.0000416,0.0000231,0.0000308,0.0000393,0.0000365,0.0000301,0.0000232,0.0000115,0.0000125,0.0000128,0.0000031,0.0000106,0.0000192,0.0000168,0.0000104,0.0000114,0.000015,0.0000168,0.0000167,0.0000096,0.0000121,0.0000199,0.0000185,0.0000086,0.000019,0.0000235,0.0000232,0.0000212,0.0000162,0,0,0.0000065,0.0000182,0.0000374,0.0000434,0.0000399,0.0000356,0.0000352,0.0000236,0.0000175,0.000039,0.0000387,0.0000281,0.0000186,0.0000464,0.0000682,0.0000816,0.0000806,0.00007,0.0000621,0.0000568,0.0000361,0.0000274,0.0000427,0.0000524,0.0000542,0.0000517,0.0000304,0.000031,0.0000406,0.0000476,0.0000588,0.0000662,0.0000636,0.0000658,0.0000712,0.0000694,0.0000719,0.0000738,0.0000683,0.0000537,0.0000446,0.000036,0.0000185,0.000034,0.0000521,0.0000737,0.0001029,0.0001097,0.0001134,0.0001164,0.0001167,0.0001012,0.0000801,0.000053,0.0000265,0.000049,0.0000546,0.0000478],"detections":[{"range_m":2.789841,"snr_db":24.6641,"width_m":0.175182}],"noise_floor":0.000036094,"blast_amplitude":0.2699713,"saturated":false} +{"timestamp":1756162800.1333332,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.4531539,-0.7848856,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000107,0.0000092,0.0000054,0.0000319,0.0000529,0.0000336,0.0000449,0.0000597,0.0000454,0.0000401,0.000043,0,0,0.0000292,0.0000302,0.0000299,0.0000274,0,0,0.000014,8e-7,0,0.0000325,0.000039,0.0000403,0.0000393,0.0000075,0.0000057,0.0000261,0.0000191,0.0000126,0.0000664,0.000079,0.000078,0.0000689,0.0000493,0,0.0000153,0.000019,0,0.0000342,0.0000674,0.0000892,0.0001157,0.0001282,0.0001137,0.0001122,0.0001335,0.0001255,0.0001118,0.0000959,0.0000562,0.0000098,0.000025,0.0000228,0.0000331,0.0000731,0.0000778,0.0000782,0.0000753,0.0000614,0.0000131,0.0000275,0.0000358,0.0000444,0.0000561,0.0000588,0.000043,0.000052,0.0000589,0.0000449,0.0000388,0.0000413,0.0000237,0.0000217,0.0000372,0.0000333,0.0000229,0.0000342,0.0000412,0.0000566,0.0000728,0.0000704,0.0000707,0.0000838,0.0000782,0.0000584,0.0000621,0.0000617,0.0000469,0.0000328,0.0000216,0.0000049,0.0000102,0.0000112,0,0.0000096,0.0000256,0.0000288,0.0000363,0.0000415,0.0000355,0.0000397,0.0000463,0.0000396,0.0000304,0.0000322,0.0000244,0.0000394,0.0000893,0.000175,0.0003531,0.0006367,0.0010626,0.0014721,0.0018693,0.0021585,0.0023104,0.0023256,0.0022716,0.0020312,0.0016707,0.0011803,0.0007773,0.0004581,0.0002469,0.000115,0.0000542,0.0000392,0.0000269,0.0000174,0.0000149,0.000013,0.0000306,0.000053,0.0000531,0.0000464,0.0000314,0.0000103,0.0000248,0.0000304,0.0000274,0.0000101,0.0000289,0.0000291,0.0000235,0.0000273,0.0000287,0.000017,0.0000193,0.0000246,0.0000178,0.0000099,0.0000147,0.0000209,0.0000316,0.0000399,0.0000377,0.0000526,0.0000684,0.0000689,0.0000678,0.0000634,0.000051,0.0000343,0.0000511,0.0000521,0.0000523,0.0000521,0.0000483,0.0000339,0.0000492,0.0000545,0.0000544,0.0000532,0.0000516,0.0000331,0.0000192,0.000009,0.0000197,0.0000524,0.0000672,0.0000673,0.0000621,0.000049,0.0000233,0.0000015,0.0000075,0.0000162,0.0000336,0.0000514,0.0000519,0.0000516,0.0000505,0.0000463,0.0000303,0.0000298,0.0000294,0.0000366,0.0000527,0.0000569,0.0000561,0.0000535,0.000047,0.0000228,0.0000187,0.0000312,0.0000341,0.0000399,0.0000501,0.0000566,0.0000621,0.0000627,0.0000511,0.0000294,0.0000211,0.0000385,0.000067,0.0000843,0.0000838,0.0000753,0.0000635,0.0000583,0.000073,0.0000972,0.0000997,0.0001034,0.000113,0.0001155,0.0001154,0.0001202,0.0001221,0.000113,0.000096,0.0000862,0.0000761],"detections":[{"range_m":2.839001,"snr_db":26.9682,"width_m":0.171607}],"noise_floor":0.000039797,"blast_amplitude":0.2699136,"saturated":false} +{"timestamp":1756162800.1999998,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.6408564,-0.6408564,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000059,0.000016,0.0000391,0.0000304,0.0000275,0.0000405,0.000048,0.0000092,0.0000051,0.0000066,0,0.0000055,0.0000304,0.0000319,0.0000407,0.0000451,3e-7,0.0000369,0.0000846,0.0000844,0.0000774,0.0000648,0.0000099,0,0.00002,0.0000104,0,0.0000102,0.0000085,2e-7,0.0000284,0.0000443,0.000056,0.0000602,0.000058,0.0000048,0.0000181,0.0000194,0,0.0000252,0.0000429,0.0000409,0.0000365,0.0000312,0,0.0000189,0.0000338,0.0000216,0.0000338,0.0000826,0.000098,0.0001114,0.0001178,0.0001017,0.0000797,0.0000945,0.0000892,0.0000525,0.0000229,0.0000059,0.0000072,0.0000175,0.0000204,0.000022,0.0000273,0.0000273,0.0000273,0.0000342,0.0000354,0.0000102,0.0000249,0.0000448,0.0000448,0.0000432,0.000039,0.0000135,0.0000305,0.0000573,0.0000639,0.0000747,0.0000839,0.000077,0.0000657,0.0000614,0.0000493,0.0000233,0.0000468,0.0000464,0.000045,0.0000457,0.0000426,0.0000261,0.0000548,0.0000645,0.0000598,0.0000451,0.0000241,0.0000105,0.0000201,0.0000226,0.0000128,0.0000289,0.0000381,0.0000341,0.0000409,0.0000601,0.0000702,0.0000791,0.0000799,0.0000692,0.0001842,0.0004023,0.000701,0.0011004,0.0015592,0.0020509,0.0023468,0.0024764,0.0024758,0.002352,0.0020757,0.0017056,0.0012796,0.0008843,0.0005136,0.0002869,0.0001566,0.0000978,0.0000556,0.000042,0.0000503,0.0000451,0.0000501,0.0000558,0.0000467,0.0000262,0.0000108,0.0000227,0.0000289,0.0000303,0.0000224,0.0000043,0.0000173,0.0000172,0.0000087,0.0000096,0.0000207,0.0000358,0.0000543,0.000058,0.0000518,0.0000429,0.0000407,0.0000408,0.0000493,0.0000521,0.0000413,0.0000354,0.0000405,0.0000344,0.0000235,0.0000409,0.0000549,0.0000699,0.0000794,0.0000756,0.0000604,0.0000456,0.0000257,0.0000083,0.0000085,0.0000142,0.0000285,0.0000431,0.0000453,0.000032,0.0000254,0.0000276,0.0000318,0.0000433,0.0000512,0.0000554,0.0000614,0.0000619,0.0000464,0.0000273,0.0000166,0.0000114,0.0000275,0.0000437,0.0000424,0.0000355,0.0000273,0.000019,0.0000143,0.0000168,0.0000146,0.000001,0.0000028,0.0000024,0,0.0000174,0.0000235,0.0000234,0.0000203,0.0000184,0.0000159,0.0000256,0.0000324,0.000037,0.0000502,0.0000657,0.0000689,0.0000689,0.0000647,0.0000445,0.0000181,0.0000122,0.0000264,0.0000448,0.0000553,0.0000538,0.0000478,0.0000516,0.0000509,0.0000509,0.0000625,0.0000625,0.0000623,0.0000666,0.0000674,0.0000574,0.0000558,0.0000597,0.0000654,0.0000764,0.0000807,0.0000761],"detections":[{"range_m":2.797382,"snr_db":24.5471,"width_m":0.175182}],"noise_floor":0.000036665,"blast_amplitude":0.2699939,"saturated":false} +{"timestamp":1756162800.2666664,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.7848856,-0.4531539,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000405,0.0000231,0.0000103,0.0000028,0.0000041,0.0000017,0,0.0000034,0.0000293,0.0000222,0.0000178,0.0000242,0.0000077,0,0.0000552,0.000076,0.0000849,0.0000848,0.0000213,0,0.0000214,0.000009,0,0.0000174,0.0000185,0.0000219,0.0000331,0.00002,0.0000323,0.0000675,0.0000642,0.0000616,0.0000635,0.0000518,0.0000168,0.0000491,0.0000584,0.000075,0.0001006,0.0001033,0.0000741,0.0000642,0.0000677,0.0000406,0.0000064,0.0000445,0.0000639,0.0000773,0.0000807,0.000053,0.000022,0.0000346,0.0000334,0.0000402,0.0000523,0.0000466,0.0000554,0.0000668,0.0000606,0.0000492,0.0000742,0.000072,0.0000618,0.0000683,0.0000684,0.0000527,0.0000467,0.0000431,0.0000118,0.000024,0.0000317,0.0000222,0.0000089,0.000025,0.0000304,0.0000314,0.0000312,0.0000105,0.0000197,0.0000338,0.0000299,0.0000422,0.000056,0.0000502,0.0000317,0.0000157,0.0000346,0.0000485,0.0000514,0.0000372,7e-7,0.0000405,0.0000515,0.0000675,0.0000931,0.0000997,0.0000903,0.0000864,0.000088,0.0000721,0.0000858,0.0001071,0.0001094,0.0001033,0.0000795,0.0001798,0.0003957,0.0007041,0.0010949,0.0015653,0.0021246,0.002463,0.0026268,0.0026347,0.0025376,0.0022396,0.0018326,0.0013884,0.0008683,0.0005239,0.0002748,0.0001002,0.0000493,0.0000559,0.0000494,0.0000388,0.0000329,0.0000086,0.0000336,0.0000601,0.0000669,0.0000671,0.0000633,0.000037,0.0000306,0.0000409,0.000038,0.0000391,0.0000605,0.0000635,0.0000623,0.0000514,0.0000224,0.000021,0.0000405,0.00004,0.0000279,0.0000247,0.0000227,0.0000077,0,0.0000012,0.0000069,0.0000337,0.0000526,0.0000531,0.0000503,0.000042,0.0000182,0.000005,0.0000157,0.0000158,0.0000158,0.0000166,0.0000098,0.0000129,0.0000215,0.0000201,0.0000246,0.0000409,0.0000407,0.0000443,0.0000547,0.0000545,0.0000509,0.0000552,0.0000573,0.0000645,0.0000759,0.000078,0.0000703,0.0000651,0.0000619,0.0000409,0.0000154,0.0000106,0.0000115,0.0000111,0.0000148,0.0000149,0.0000265,0.0000444,0.0000451,0.0000443,0.0000406,0.0000312,0.0000191,0.0000212,0.0000204,0.0000229,0.0000319,0.0000311,0.0000224,0.0000142,0.0000099,0.0000075,0.0000259,0.0000382,0.0000448,0.0000464,0.0000463,0.0000392,0.0000515,0.0000587,0.0000527,0.0000476,0.000058,0.0000638,0.0000718,0.0000799,0.0000789,0.0000866,0.0001,0.0001043,0.0001094,0.0001105,0.0001067,0.000087,0.0000742,0.0000669,0.000048,0.00003,0.0000198,0.0000291,0.0000408,0.0000454,0.0000567,0.0000821,0.0001006,0.0001033],"detections":[{"range_m":2.672119,"snr_db":22.7478,"width_m":0.171607}],"noise_floor":0.000041468,"blast_amplitude":0.2700291,"saturated":false} +{"timestamp":1756162800.333333,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.8754261,-0.2345697,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000563,0.0000578,0.000044,0.0000295,0.0000313,0.0000237,0.0000392,0.0000435,0.0000057,0.0000116,0.0000399,0.0000364,0.0000344,0.0000486,0.0000576,0.0000779,0.0000925,0.0000674,0.0000542,0.0000651,0.0000383,0.0000125,0.0000111,0.0000016,0.0000089,0.0000346,0.0000246,0.0000092,0.000031,0.0000265,0.0000068,0.0000284,0.0000578,0.0000677,0.0000727,0.0000714,0.0000348,0.0000561,0.0000682,0.0000589,0.0000535,0.0000532,0.0000168,0.0000267,0.000047,0.000042,0.0000348,0.0000347,0.0000202,0.0000217,0.000032,0.0000242,0.0000148,0.000015,0.0000071,0.0000178,0.0000556,0.0000605,0.000065,0.0000685,0.0000656,0.0000385,0.0000675,0.0000707,0.000062,0.0000498,0.0000368,0.000009,0.0000328,0.0000477,0.0000429,0.0000371,0.000034,0.0000297,0.0000481,0.0000615,0.0000564,0.0000707,0.0000894,0.0000869,0.0000808,0.0000763,0.000063,0.0000368,0.0000444,0.0000401,0.0000356,0.0000795,0.0000975,0.0000997,0.0000947,0.0000684,0.0000912,0.0002457,0.0004662,0.0007986,0.0012554,0.0017706,0.0023533,0.0027192,0.0028794,0.0028824,0.0027701,0.0024729,0.0020279,0.0015114,0.0010175,0.00052,0.0002222,0.000065,0.0000863,0.0000996,0.0001088,0.0001068,0.0000827,0.0000711,0.0000641,0.0000403,0.0000384,0.0000432,0.0000479,0.0000577,0.0000593,0.0000454,0.0000503,0.0000573,0.000053,0.0000465,0.0000514,0.0000549,0.0000641,0.0000689,0.0000602,0.000047,0.000045,0.0000391,0.000028,0.0000362,0.0000343,0.0000454,0.0000655,0.0000661,0.0000587,0.0000501,0.000041,0.0000286,0.0000529,0.00006,0.0000573,0.0000437,0.0000246,0.0000126,0.0000164,0.0000186,0.0000248,0.0000374,0.0000438,0.0000384,0.0000447,0.0000603,0.0000658,0.0000725,0.0000761,0.0000697,0.000049,0.0000314,0.0000226,0.0000425,0.0000716,0.0000842,0.0000954,0.0001046,0.000106,0.0000927,0.0000948,0.0000963,0.0000852,0.0000725,0.0000603,0.0000365,0.0000238,0.00003,0.0000386,0.0000583,0.0000731,0.0000728,0.000066,0.0000526,0.0000244,0.0000032,0.0000131,0.0000125,0.0000099,0.0000096,0.0000157,0.0000293,0.0000395,0.0000385,0.0000307,0.0000378,0.00004,0.0000339,0.000025,0.0000235,0.00004,0.0000513,0.0000523,0.0000356,0.0000105,0.0000268,0.0000342,0.0000393,0.0000406,0.0000318,0.000021,0.0000292,0.0000277,0.0000204,0.0000139,0.0000089,1e-7,0.0000082,0.0000079,0.0000019,0.0000208,0.0000284,0.0000374,0.0000503,0.0000549,0.000062,0.0000767,0.0000841,0.0000841,0.0000893,0.0000932,0.0000916],"detections":[{"range_m":2.523483,"snr_db":23.0173,"width_m":0.171607}],"noise_floor":0.000043196,"blast_amplitude":0.2700235,"saturated":false} +{"timestamp":1756162800.3999996,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.9063078,0,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000048,0.0000161,0.000028,0.0000519,0.0000378,0,0.0000459,0.0000792,0.0000638,0.0000399,0.000042,0.000055,0.0000621,0.0000623,0,0.0000502,0.0000767,0.0000709,0.0000859,0.0001049,0.0000837,0.0000495,0.000024,0.0000267,0.0000541,0.0000746,0.0000596,0.0000723,0.0001127,0.00011,0.0001169,0.0001289,0.0001188,0.0000697,0.0000364,0.0000378,0.0000422,0.0000699,0.0000751,0.0000653,0.0000665,0.0000672,0.0000293,0.0000143,0.0000262,0.0000211,0.0000262,0.0000396,0.0000346,0.0000328,0.0000309,4e-7,0,0.0000042,1e-7,0.0000065,0.0000157,0.0000111,0.0000163,0.0000382,0.0000353,0.0000326,0.0000596,0.0000626,0.0000527,0.0000405,0.0000312,0.0000024,0.000028,0.0000385,0.0000349,0.0000275,0.0000204,0.0000166,0.0000351,0.0000491,0.0000545,0.0000708,0.0000835,0.0000768,0.0000564,0.0000403,0.0000109,0.0000141,0.000032,0.0000305,0.0000373,0.0000502,0.0000453,0.000046,0.0000766,0.000081,0.0001334,0.0003069,0.0006491,0.0010525,0.0015427,0.0020397,0.0024858,0.0028079,0.0029123,0.0029008,0.0026483,0.0022399,0.00172,0.0012037,0.0007759,0.0004453,0.0002076,0.0000613,0.0000752,0.000095,0.0001005,0.000092,0.000069,0.0000682,0.0000662,0.000039,0.0000157,0.0000122,0.0000193,0.0000268,0.0000288,0.0000189,0.0000412,0.0000547,0.000057,0.0000564,0.0000521,0.0000454,0.0000743,0.000104,0.0001103,0.0001111,0.0001091,0.0000872,0.0000564,0.0000387,0.000025,0.000005,0.0000037,0,0.0000038,0.0000272,0.0000302,0.0000299,0.0000319,0.0000381,0.0000669,0.0001117,0.0001414,0.0001557,0.0001576,0.000154,0.0001145,0.0000837,0.0000751,0.0000674,0.0000631,0.0000616,0.0000484,0.0000346,0.0000424,0.0000396,0.0000307,0.0000206,0.0000125,0.0000246,0.0000537,0.0000647,0.0000735,0.0000819,0.0000812,0.0000787,0.0000817,0.000082,0.0000618,0.000051,0.0000445,0.0000211,0.0000049,0.0000097,0.0000197,0.0000432,0.0000555,0.0000571,0.0000571,0.0000553,0.0000398,0.0000192,0.0000134,0.0000209,0.0000413,0.0000648,0.0000689,0.0000689,0.0000648,0.0000532,0.0000339,0.0000425,0.000041,0.0000256,0.0000132,0.0000168,0.0000164,0.0000119,0.0000071,0.0000108,0.0000175,0.0000276,0.000037,0.0000439,0.0000448,0.0000303,0.000027,0.0000406,0.0000435,0.0000468,0.0000507,0.0000472,0.0000387,0.0000327,0.000024,0.0000107,0.0000071,0.0000048,0,0.0000128,0.0000143,0.0000247,0.0000423,0.0000466,0.000045,0.0000394,0.0000357,0.0000269,0.000035,0.0000458,0.0000541],"detections":[{"range_m":2.461116,"snr_db":25.0505,"width_m":0.171607}],"noise_floor":0.000041282,"blast_amplitude":0.2699688,"saturated":false} +{"timestamp":1756162800.4666662,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.8754261,0.2345697,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0001007,0.0000987,0.0000863,0.0000664,0.0000462,0.0000233,0.000014,0.0000355,0.0000505,0.0000225,0.0000574,0.0000811,0.0000748,0.0000674,0.0000721,0.0000359,0.0000279,0.0000252,0,0,0.0000171,0.0000287,0.0000334,0.0000335,0,0,0.0000156,0.0000138,0.0000228,0.0000401,0.0000254,0.0000021,0.0000262,0.0000278,0.000028,0.0000266,0.0000229,0.0000187,0.0000321,0.0000328,0.0000129,0.0000322,0.0000392,0.000023,0.0000597,0.0000762,0.0000726,0.0000639,0.0000518,0.0000103,0.0000154,0.0000309,0.0000185,0.0000383,0.0000663,0.0000626,0.0000502,0.0000658,0.0000714,0.0000785,0.0000822,0.0000719,0.0000444,0.0000501,0.0000493,0.0000259,0.0000577,0.0000703,0.0000764,0.0000806,0.0000814,0.0000564,0.000048,0.0000512,0.0000283,0.0000093,0.0000202,0.0000317,0.000041,0.0000445,0.0000293,0.000005,0.0000072,0.0000016,0,0.0000518,0.0001501,0.0003633,0.0007963,0.0012585,0.0017948,0.0023257,0.0027246,0.0029443,0.0029718,0.0029149,0.0025351,0.0020552,0.0015256,0.0010027,0.0005828,0.0002979,0.0001144,0.0000195,0.000023,0.0000117,0.0000076,0.0000454,0.0000672,0.0000908,0.0001135,0.0001143,0.0001181,0.0001189,0.000107,0.0000708,0.0000399,0.0000183,0.0000111,0.000021,0.0000201,0.0000156,0.0000345,0.0000428,0.0000455,0.0000506,0.0000517,0.0000375,0.0000402,0.0000447,0.0000351,0.0000199,0.0000275,0.0000462,0.0000667,0.0000795,0.0000783,0.000089,0.0001034,0.0001022,0.0000989,0.0001091,0.000108,0.0001048,0.0001032,0.0000984,0.0000762,0.0000626,0.0000538,0.0000339,0.0000272,0.0000286,0.0000185,0.0000298,0.0000573,0.0000733,0.0000842,0.0000869,0.0000755,0.0000655,0.0000631,0.0000504,0.0000307,0.0000195,0.0000106,0.0000035,0.0000053,7e-7,0.0000022,0.000021,0.0000362,0.0000724,0.0001204,0.0001428,0.0001485,0.0001474,0.0001341,0.0000883,0.0000644,0.0000613,0.0000497,0.0000403,0.0000339,0.0000185,0.0000276,0.0000417,0.0000463,0.0000475,0.0000473,0.0000359,0.0000257,0.0000362,0.0000353,0.0000325,0.0000273,0.0000146,0,0.0000145,0.0000239,0.0000357,0.0000405,0.0000381,0.0000219,0.0000202,0.0000221,0.0000185,0.000024,0.0000252,0.0000157,0.0000086,0.0000049,0.0000092,0.0000199,0.0000264,0.0000215,0.0000203,0.0000301,0.0000359,0.0000443,0.000051,0.0000483,0.000044,0.0000488,0.0000451,0.0000321,0.0000288,0.0000343,0.0000425,0.0000535,0.0000531,0.0000524,0.0000588,0.0000601,0.0000501,0.0000384,0.0000284,0.0000297,0.0000517,0.0000616,0.0000608],"detections":[{"range_m":2.325592,"snr_db":24.8955,"width_m":0.171607}],"noise_floor":0.000040942,"blast_amplitude":0.2700495,"saturated":false} +{"timestamp":1756162800.5333328,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.7848856,0.4531539,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000015,0,4e-7,0.0000093,0.0000231,0.0000069,0,0,0.000029,0.0000325,0.0000378,0.0000391,0,0.0000227,0.0000617,0.0000691,0.0000763,0.0000785,0.0000259,0.0000252,0.0000582,0.0000476,0.0000217,0.0000349,0.0000511,0.0000657,0.000074,0.000052,0.0000216,0.0000521,0.0000434,0.0000286,0.0000362,0.0000344,0.000063,0.0001062,0.0001126,0.0000889,0.0000725,0.0000611,0.0000074,0.0000146,0.0000344,0.0000323,0.0000211,0.0000245,0.0000323,0.0000386,0.0000396,0.0000091,0,0.0000081,0,0.0000106,0.0000498,0.0000529,0.0000527,0.000049,0.0000318,0,0.0000018,0,0,0.000023,0.0000426,0.0000644,0.0000845,0.000085,0.0000693,0.0000757,0.0000774,0.0000475,0.0000147,0.0000487,0.0000679,0.0000868,0.0000938,0.0000795,0.000057,0.0000913,0.0002564,0.0005318,0.0009276,0.0014055,0.0019416,0.0024494,0.0028096,0.0029862,0.0029954,0.0028453,0.0024681,0.0019665,0.0014301,0.0009187,0.0005256,0.0002656,0.0000589,0.0000545,0.0000766,0.000077,0.0000729,0.0000626,0.0000304,0.0000129,0.0000177,0.0000125,0.0000446,0.000079,0.0000853,0.000085,0.0000749,0.0000339,0.0000344,0.0000609,0.0000604,0.0000557,0.0000569,0.0000539,0.000036,0.0000662,0.0000709,0.0000701,0.0000639,0.0000576,0.000037,0.000047,0.0000508,0.0000453,0.0000425,0.0000465,0.0000493,0.0000676,0.0000832,0.000082,0.0000822,0.0000871,0.0000816,0.0000739,0.0000677,0.0000517,0.0000213,0.000025,0.0000242,0.0000216,0.0000202,0.0000173,0.0000095,0.0000373,0.0000395,0.0000371,0.000026,0.0000086,0.0000214,0.0000265,0.0000265,0.000011,0.0000218,0.0000258,0.0000168,0.0000087,0.0000177,0.0000209,0.0000267,0.000029,0.0000198,0.0000125,0.0000313,0.0000368,0.0000413,0.0000451,0.0000406,0.0000397,0.0000496,0.0000486,0.0000389,0.0000423,0.0000446,0.0000411,0.0000408,0.0000394,0.0000177,0.0000283,0.00005,0.0000625,0.000075,0.0000782,0.0000701,0.0000626,0.0000627,0.000055,0.0000497,0.0000589,0.0000613,0.0000727,0.0000828,0.0000798,0.0000632,0.000042,0.00004,0.0000668,0.000085,0.000085,0.0000773,0.0000679,0.0000568,0.0000269,0.0000254,0.0000333,0.0000333,0.0000335,0.0000354,0.0000279,0.0000178,0.0000081,0.0000022,0.0000017,0.0000136,0.000025,0.0000351,0.0000405,0.0000367,0.0000301,0.0000359,0.0000327,0.0000211,0.0000246,0.0000295,0.0000304,0.0000292,0.0000183,0,0.0000065,0.0000077,0.0000039,0.0000126,0.0000166,0.0000163,0.0000313,0.0000417,0.0000426],"detections":[{"range_m":2.211216,"snr_db":24.445,"width_m":0.175182}],"noise_floor":0.000037341,"blast_amplitude":0.2700116,"saturated":false} +{"timestamp":1756162800.5999994,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.6408564,0.6408564,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.000077,0.0000774,0.0000539,0.0000356,0.0000518,0.0000537,0.0000735,0.0000783,0.0000395,0.0000436,0.0000563,0.0000131,0.0000342,0.0000668,0.0000634,0.0000477,0.0000287,0.0000554,0.0000808,0.0000915,0.0000581,0.0000258,0.0000518,0.0000413,0.0000187,0.0000031,0,0,0.0000574,0.0000707,0.0000767,0.0000769,0.0000725,0.0000139,0.0000462,0.0000531,0.0000444,0.0000404,0.0000454,0.0000324,0.0000324,0.0000346,0.0000261,0.0000373,0.0000415,0.0000185,0.0000191,0.0000472,0.0000436,0.0000362,0.000028,4e-7,1e-7,0.0000254,0.0000269,0.0000333,0.0000392,0.0000303,0.0000136,0.0000469,0.0000533,0.0000591,0.0000661,0.0000639,0.000037,0.0000453,0.000051,0.0000412,0.000056,0.0000701,0.000065,0.0000589,0.0000548,0.0000294,0.000017,0.00005,0.0000639,0.00007,0.0000707,0.0000532,0.0000385,0.0000837,0.000169,0.000367,0.0007705,0.0011934,0.0016805,0.0021657,0.0025404,0.0027689,0.0028171,0.0027894,0.0024745,0.0020375,0.0015322,0.0010193,0.0006042,0.0003234,0.0001384,0.0000265,0.0000519,0.0000531,0.0000508,0.0000451,0.0000323,0.0000367,0.0000542,0.0000525,0.0000494,0.0000475,0.0000383,0.0000218,0.0000378,0.0000392,0.0000431,0.0000493,0.0000476,0.0000425,0.0000593,0.0000632,0.0000549,0.0000421,0.000027,0.0000146,0.0000277,0.0000328,0.0000259,0.0000358,0.0000458,0.0000401,0.0000317,0.0000386,0.0000369,0.0000353,0.000033,0.0000204,0,0.000003,7e-7,0.000013,0.0000434,0.0000532,0.0000564,0.000057,0.0000545,0.0000472,0.0000547,0.0000559,0.0000414,0.0000456,0.0000515,0.0000457,0.0000395,0.0000369,0.0000222,0.0000269,0.0000397,0.0000384,0.0000325,0.0000417,0.0000469,0.0000515,0.0000534,0.0000472,0.0000365,0.0000509,0.0000502,0.0000451,0.0000373,0.0000255,0.000005,0.0000077,0.0000167,0.0000301,0.0000464,0.0000496,0.0000464,0.0000516,0.0000538,0.0000411,0.0000194,0.0000212,0.0000319,0.0000365,0.0000378,0.0000295,0.000019,0.0000183,0.00003,0.0000526,0.0000748,0.0000766,0.0000759,0.0000703,0.0000576,0.000032,0.0000312,0.0000408,0.0000546,0.000065,0.0000661,0.0000515,0.0000519,0.0000558,0.0000542,0.000048,0.0000386,0.0000185,0.0000095,0.000013,0.0000073,0.0000053,0.000008,0.0000053,0.0000119,0.0000192,0.0000171,0.000015,0.000023,0.0000215,0.0000146,0.0000073,0.0000017,0,0.0000274,0.0000523,0.0000796,0.0001026,0.0001064,0.0001017,0.0000916,0.000081,0.0000522,0.0000302,0.0000338,0.0000388],"detections":[{"range_m":2.328964,"snr_db":25.6745,"width_m":0.171607}],"noise_floor":0.000037396,"blast_amplitude":0.269958,"saturated":false} +{"timestamp":1756162800.666666,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.4531539,0.7848856,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000223,0.0000222,0.0000191,0.0000163,0.0000189,0.0000106,0.0000046,0.0000086,0.0000323,0.0000401,0.0000561,0.0000623,0.0000353,0.0000418,0.0000479,0,0.0000179,0.0000599,0.0000685,0.0000752,0.0000779,0.0000388,0.0000346,0.0000668,0.0000577,0.0000416,0.0000261,0.0000049,0.0000219,0.0000433,0.0000298,0.0000247,0.000063,0.0000602,0.0000461,0.0000322,0.0000412,0.0000749,0.0001205,0.0001274,0.0001166,0.0001093,0.0001075,0.0000635,0.0000197,0.000036,0.0000565,0.0000785,0.0000878,0.0000696,0.0000467,0.0000483,0.0000357,0.0000218,0.0000087,0.0000159,0.0000226,0.0000241,0.0000139,0.0000202,0.000038,0.000034,0.0000169,0.0000095,0.0000153,0.0000297,0.0000438,0.0000429,0.0000275,0.0000569,0.0000674,0.0000606,0.0000447,0.0000251,0.0000169,0.0000284,0.0000328,0.0000201,0.0000379,0.0000675,0.0000819,0.0000951,0.0001018,0.0000906,0.0000799,0.0000913,0.0000866,0.0000657,0.0000481,0.0000285,0.0000025,0.0000318,0.0000532,0.0000655,0.0000663,0.0000555,0.0001433,0.0003896,0.0007306,0.0011688,0.0016709,0.0021404,0.0025433,0.0026819,0.0026838,0.0025411,0.0022067,0.0017629,0.0012745,0.0008196,0.0004659,0.000175,0.0000294,0.0000558,0.0000554,0.0000438,0.0000278,0.0000185,0.0000532,0.0000947,0.0001053,0.0001072,0.0001069,0.0001021,0.0000665,0.0000291,0.0000327,0.0000577,0.0000806,0.000089,0.0000837,0.0000734,0.0000617,0.000027,0.0000139,0.0000269,0.0000251,0.0000362,0.0000556,0.0000554,0.0000507,0.0000443,0.0000316,0.0000138,0.0000127,0.0000107,0.0000045,0.0000232,0.0000288,0.0000423,0.0000602,0.0000633,0.0000535,0.0000389,0.0000301,0.0000177,0.0000138,0.0000121,0.0000049,0.0000169,0.000028,0.0000278,0.0000376,0.0000472,0.000046,0.0000443,0.0000465,0.0000418,0.0000404,0.0000534,0.0000514,0.000041,0.0000366,0.0000413,0.0000534,0.0000656,0.0000676,0.0000566,0.000055,0.000056,0.000043,0.0000285,0.0000139,0.000014,0.0000353,0.0000476,0.0000548,0.0000633,0.0000673,0.0000603,0.0000516,0.0000601,0.0000586,0.0000561,0.0000562,0.0000511,0.0000451,0.0000588,0.0000595,0.0000588,0.0000573,0.0000531,0.0000395,0.000044,0.0000457,0.0000397,0.0000495,0.0000519,0.0000474,0.0000443,0.0000469,0.000045,0.0000507,0.0000558,0.0000521,0.0000567,0.0000594,0.0000513,0.0000339,0.0000184,0.0000189,0.0000322,0.0000407,0.0000386,0.0000316,0.0000348,0.0000343,0.0000303,0.0000291,0.0000271,0.0000172,0.0000224,0.0000245,0.0000209,0.0000213,0.0000312,0.0000365,0.000038,0.0000375,0.0000231],"detections":[{"range_m":2.505157,"snr_db":22.8011,"width_m":0.171607}],"noise_floor":0.000042351,"blast_amplitude":0.2700065,"saturated":false} +{"timestamp":1756162800.7333326,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.2345697,0.8754261,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000646,0.0000708,0.0000702,0.0000625,0.0000487,0,0.0000278,0.0000379,0.0000119,0.0000468,0.0000708,0.0000532,0.0000471,0.0000603,0.0000502,0.0000693,0.0000868,0.0000632,0.0000677,0.0001022,0.0000973,0.0000858,0.0000704,0.000029,0,0.0000409,0.0000407,0.0000392,0.0000402,0.0000319,0.000024,0.0000555,0.0000628,0.0000677,0.0000779,0.0000776,0.0000407,0.0000584,0.0000723,0.0000655,0.0000597,0.0000593,0.0000255,0.0000511,0.0000801,0.0000803,0.0000768,0.0000661,0.0000246,0.0000281,0.00004,0.0000291,0.000014,0.000035,0.0000322,0.0000214,0.0000391,0.0000366,0.000025,0.0000107,0.0000264,0.0000506,0.0000678,0.0000672,0.0000469,0.0000675,0.0000761,0.0000708,0.000068,0.0000686,0.0000388,0.0000225,0.0000265,0.0000137,0.0000458,0.0000834,0.0000873,0.0000853,0.0000748,0.0000429,0.0000096,0.0000257,0.0000232,0.0000084,0.0000219,0.0000255,0.0000251,0.0000181,0.0000084,0.0000217,0.0000313,0.0000327,0.00001,0.0000304,0.0000398,0.0000398,0.0000383,0.0000346,0.0000091,0.0000689,0.0001882,0.0003849,0.0006922,0.0010882,0.0015227,0.0019577,0.0023614,0.0025066,0.0025136,0.0024362,0.0021764,0.0017757,0.0013307,0.000907,0.0004715,0.000226,0.0000787,0.0000342,0.000051,0.0000528,0.0000347,0.0000194,0.0000175,0.0000186,0.0000283,0.0000339,0.0000303,0.0000323,0.0000333,0.0000167,0.0000111,0.0000217,0.0000215,0.000035,0.0000571,0.0000625,0.0000677,0.0000711,0.0000647,0.00005,0.0000604,0.0000595,0.0000454,0.0000286,0.0000113,0.0000096,0.0000183,0.000021,0.0000189,0.0000199,0.0000205,0.0000073,0.0000112,0.0000156,0.0000116,0.0000157,0.0000227,0.0000207,0.0000248,0.0000299,0.000026,0.0000369,0.0000535,0.0000533,0.0000493,0.0000418,0.0000236,5e-7,0.0000033,0.0000032,0.0000055,0.000028,0.0000422,0.0000475,0.0000501,0.0000502,0.0000334,0.0000312,0.0000403,0.000046,0.0000544,0.0000569,0.0000483,0.000049,0.0000562,0.000054,0.0000487,0.0000435,0.0000262,0.0000228,0.0000364,0.0000356,0.0000285,0.0000278,0.0000266,0.0000178,0.0000327,0.0000423,0.0000478,0.0000489,0.0000486,0.0000334,0.0000521,0.0000643,0.0000688,0.0000692,0.0000676,0.0000504,0.0000342,0.000026,0.0000091,0.000012,0.0000218,0.0000205,0.0000145,0.0000084,0.0000072,0.0000177,0.0000311,0.0000312,0.0000292,0.000023,0.0000129,0.0000167,0.000036,0.0000378,0.0000403,0.0000456,0.0000469,0.0000393,0.0000319,0.0000243,0.0000079,0.0000077,0.000017,0.0000293],"detections":[{"range_m":2.654324,"snr_db":29.6705,"width_m":0.171607}],"noise_floor":0.000034262,"blast_amplitude":0.2699895,"saturated":false} +{"timestamp":1756162800.7999992,"source":"simulated","device_id":"batvu-reference-01","beam":[0,0.9063078,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000114,0.0000655,0.0000602,0.0000322,0.0000366,0.0000486,0.0000144,0.0000124,0.0000438,0.0000467,0.0000465,0.0000418,0,0,0.0000047,0,0.0000313,0.0000683,0.0000651,0.00006,0.0000588,0.0000249,0.000016,0.0000315,0.0000168,0.0000307,0.0000814,0.000077,0.000063,0.0000555,0.0000479,0.0000353,0.0000529,0.0000563,0.000034,0.0000279,0.0000238,0,0.0000045,0.0000137,0.0000072,0.00001,0.0000118,0.000002,0.0000112,0.0000162,0,0.0000093,0.0000394,0.0000363,0.0000469,0.0000712,0.000069,0.0000632,0.0000557,0.0000405,0.0000176,0.0000291,0.0000255,0.00002,0.0000294,0.0000283,0.0000016,0.0000329,0.0000524,0.0000653,0.0000793,0.0000826,0.0000707,0.0000844,0.0000918,0.0000743,0.0000525,0.0000338,0.0000097,0.0000189,0.0000243,0.0000142,0.0000214,0.000037,0.0000342,0.0000382,0.000054,0.0000524,0.0000529,0.0000639,0.0000606,0.0000576,0.0000689,0.0000682,0.0000583,0.0000668,0.0000686,0.0000465,0.000064,0.0000722,0.0000738,0.0000939,0.0001544,0.0003357,0.000616,0.0009919,0.0014357,0.0019098,0.002325,0.0025929,0.0026825,0.0026763,0.0024565,0.0020777,0.0016175,0.001148,0.0007292,0.0004237,0.0002268,0.0000729,0.0000324,0.0000358,0.0000332,0.0000253,0.0000441,0.0000644,0.00008,0.0000811,0.0000607,0.0000513,0.0000551,0.0000449,0.0000293,0.0000229,0.0000448,0.0000728,0.0000922,0.000096,0.0001054,0.0001124,0.0001055,0.0000687,0.0000334,0.0000236,0.0000376,0.0000487,0.000048,0.0000416,0.0000408,0.0000375,0.0000188,0.0000159,0.0000175,0.0000104,0.0000181,0.0000232,0.0000298,0.000051,0.0000641,0.0000645,0.0000639,0.0000621,0.0000512,0.0000404,0.0000381,0.000022,0.0000186,0.0000556,0.0000681,0.0000703,0.0000687,0.0000495,0.0000222,0.0000181,0.0000169,0.0000115,0.0000253,0.0000294,0.0000272,0.0000242,0.0000202,0.0000221,0.0000527,0.0000764,0.0000895,0.0000998,0.0001002,0.0000802,0.0000454,0.0000209,0.0000446,0.0000579,0.000063,0.0000588,0.000078,0.0001001,0.0001011,0.0000988,0.000091,0.0000795,0.0000594,0.0000719,0.0000772,0.0000779,0.0000748,0.0000595,0.0000196,0.0000324,0.0000386,0.0000376,0.0000354,0.0000368,0.0000307,0.0000453,0.0000633,0.0000699,0.0000771,0.0000813,0.0000761,0.0000686,0.0000685,0.0000637,0.0000489,0.0000304,0.0000148,0.0000247,0.0000312,0.0000291,0.0000144,0.0000355,0.0000404,0.0000404,0.000036,0.0000273,0.0000466,0.0000784,0.0000972,0.0001037,0.0001045,0.0001029,0.0000813],"detections":[{"range_m":2.608981,"snr_db":23.7218,"width_m":0.171607}],"noise_floor":0.000045694,"blast_amplitude":0.2700531,"saturated":false} +{"timestamp":1756162800.8666658,"source":"simulated","device_id":"batvu-reference-01","beam":[0.2345697,0.8754261,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000024,0.0000115,0.0000208,0.0000283,0.0000384,0.0000206,0,0,0.0000202,0.0000123,0.0000089,0.0000179,0.0000088,0.0000037,0.0000163,0.0000308,0.0000562,0.0000728,0.0000569,0.0000539,0.000054,0.00001,0.0000119,0.0000454,0.000038,0.0000286,0.0000252,0,4e-7,0.0000496,0.0000457,0.0000316,0.0000411,0.0000546,0.0000698,0.0000766,0.0000749,0.0000268,0.0000553,0.0000595,0.0000386,0.0000263,0.000026,0.0000024,0.0000068,0.0000136,0,0.0000069,0.0000404,0.0000463,0.0000492,0.0000508,0.000026,0.0000122,0.00002,0.00001,0,0.0000367,0.0000453,0.0000487,0.000049,0.000037,0.0000073,0.0000273,0.0000297,0.0000191,0.0000076,0.0000065,0.0000117,0.0000223,0.000029,0.0000391,0.0000568,0.0000653,0.0000529,0.000048,0.0000516,0.0000385,0.0000348,0.0000408,0.0000306,0.000012,0.0000054,0.000002,0.0000133,0.000044,0.0000563,0.0000709,0.000082,0.0000745,0.000052,0.0000518,0.0000498,0.0000246,0.0000252,0.0000543,0.0000703,0.0000836,0.0000858,0.0000706,0.0000768,0.000085,0.0001454,0.000315,0.0005826,0.0009421,0.0013838,0.0018382,0.0022176,0.0024715,0.0025457,0.0025016,0.002276,0.0019127,0.0014712,0.0010164,0.0006324,0.0003449,0.0001056,0.0000441,0.0000519,0.0000508,0.0000424,0.0000318,0.0000066,0.0000422,0.0000589,0.0000666,0.000072,0.0000735,0.0000598,0.0000647,0.0000794,0.0000784,0.0000744,0.0000662,0.0000413,0.0000183,0.0000381,0.0000387,0.0000417,0.0000504,0.0000493,0.0000505,0.0000513,0.0000475,0.0000251,0.00002,0.0000197,0.0000273,0.0000428,0.0000462,0.0000413,0.0000409,0.0000436,0.0000361,0.0000529,0.0000685,0.0000773,0.0000879,0.0000944,0.0000899,0.0000936,0.0001005,0.0000942,0.0000748,0.0000476,0.0000249,0.0000491,0.0000633,0.0000585,0.0000413,0.0000335,0.000032,0.0000241,0.0000276,0.0000289,0.0000199,0.0000261,0.0000275,0.0000157,0.0000046,0.0000152,0.0000313,0.0000593,0.0000691,0.0000663,0.0000519,0.0000304,0.0000261,0.0000369,0.0000379,0.0000226,0.0000021,0.0000152,0.0000175,0.0000255,0.0000335,0.0000319,0.0000189,0.0000174,0.000017,0.0000254,0.0000443,0.0000547,0.0000659,0.0000853,0.0000887,0.0000852,0.0000799,0.0000767,0.0000638,0.0000529,0.0000476,0.0000269,0.0000155,0.0000373,0.0000427,0.000043,0.0000421,0.0000351,0.0000231,0.0000172,0.0000102,0.0000203,0.0000445,0.0000548,0.000063,0.0000702,0.0000687,0.0000589,0.0000588,0.0000593,0.0000438,0.000023,0.0000235,0.0000448,0.000055,0.0000565,0.0000447],"detections":[{"range_m":2.624386,"snr_db":22.9517,"width_m":0.175182}],"noise_floor":0.000038604,"blast_amplitude":0.2700422,"saturated":false} +{"timestamp":1756162800.9333324,"source":"simulated","device_id":"batvu-reference-01","beam":[0.4531539,0.7848856,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000026,0,0,0,0.0000225,0.0000271,0.0000224,0.0000159,0.0000119,0,0,0.0000127,0.0000126,0.0000103,0.0000098,0.0000049,0.0000161,0.0000348,0.0000432,0.0000526,0.000056,0.0000178,0,0.0000118,0.0000076,0.0000331,0.0000706,0.0000655,0.000068,0.0000908,0.000078,0.0000563,0.0000495,0.0000371,0,0.0000261,0.0000348,0.0000259,0.0000306,0.0000355,0.0000318,0.0000342,0.0000361,0.0000198,0.0000261,0.0000275,0,0.0000016,0.0000176,0.0000126,0.0000139,0.0000369,0.0000423,0.0000474,0.0000487,0.0000276,0,0.0000216,0.0000252,0.0000438,0.0000737,0.0000727,0.0000776,0.0000886,0.000087,0.000055,0.0000301,0.0000282,0.000011,6e-7,0.0000203,0.0000278,0.0000302,0.0000299,0,0.0000213,0.0000344,0.0000374,0.0000645,0.0000883,0.0000856,0.0000656,0.0000402,0.000007,0.0000255,0.000037,0.0000329,0.0000395,0.000065,0.0000631,0.000053,0.0000511,0.0000493,0.0000357,0.0000531,0.0000595,0.0000554,0.0000584,0.0000595,0.0000405,0.0000314,0.0000349,0.0000243,0.0000146,0.0000172,0.0000186,0.0000467,0.0000744,0.000076,0.0000736,0.000086,0.0001982,0.0004095,0.0007136,0.0010783,0.0015014,0.0019219,0.0022778,0.0024012,0.0024036,0.0022981,0.0020036,0.0016099,0.001189,0.0007758,0.000441,0.0001793,0.000064,0.0000474,0.0000405,0.0000063,0.0000219,0.0000304,0.0000207,0.000018,0.000026,0.0000218,0.000014,0.0000153,0.0000105,0,0.0000179,0.0000181,0.0000162,0.0000192,0.0000261,0.0000413,0.0000645,0.000065,0.0000595,0.0000534,0.0000475,0.0000219,0.000014,0.0000363,0.0000476,0.0000577,0.000061,0.000051,0.0000408,0.0000372,0.0000232,0.0000314,0.0000559,0.0000573,0.0000531,0.0000374,0.0000156,0.0000193,0.0000197,0.0000061,0.0000168,0.0000335,0.0000335,0.000028,0.0000283,0.0000294,0.0000198,0.0000176,0.0000168,8e-7,0,0.0000022,0.0000073,0.0000225,0.0000303,0.0000329,0.00004,0.000046,0.0000428,0.0000412,0.0000438,0.0000354,0.0000263,0.0000432,0.0000488,0.0000527,0.0000551,0.0000528,0.0000394,0.0000438,0.0000432,0.0000335,0.0000184,0.0000241,0.0000427,0.0000556,0.0000568,0.0000479,0.0000566,0.0000606,0.0000545,0.0000485,0.0000535,0.0000525,0.0000562,0.0000588,0.0000527,0.0000493,0.0000572,0.000056,0.0000537,0.0000577,0.0000544,0.0000455,0.0000374,0.0000256,0.0000098,0.0000314,0.0000325,0.000035,0.0000465,0.0000533,0.0000586,0.000064,0.0000659,0.0000589,0.0000688,0.0000776,0.0000808],"detections":[{"range_m":2.78081,"snr_db":26.2487,"width_m":0.171607}],"noise_floor":0.000034262,"blast_amplitude":0.269969,"saturated":false} +{"timestamp":1756162800.999999,"source":"simulated","device_id":"batvu-reference-01","beam":[0.6408564,0.6408564,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000813,0.0000769,0.0000585,0.000039,0.0000726,0.0000664,0.0000402,0.0000452,0.0000583,0.0000287,0.0000308,0.0000457,0.0000338,0.0000275,0.0000322,0.0000259,0.0000431,0.0000514,0.0000133,0,0.0000264,0.0000328,0.0000476,0.0000635,0.0000417,0.0000168,0.0000168,0.0000208,0.0000243,0.0000298,0.0000146,0.0000143,0.0000311,0.0000245,0.0000061,0.0000267,0.000032,0.0000149,0.0000117,0.0000295,0.000046,0.0000632,0.0000669,0.0000397,0.0000642,0.0000863,0.0000929,0.0000992,0.0001013,0.0000753,0.0000702,0.000092,0.0000832,0.0000698,0.0000632,0.000048,0.00005,0.0000733,0.0000704,0.0000648,0.0000817,0.0000779,0.0000605,0.0000456,0.0000291,0,0.0000087,0.0000077,0,0.0000266,0.0000389,0.0000375,0.0000416,0.000048,0.0000357,0.0000259,0.0000352,0.00004,0.0000501,0.0000598,0.0000542,0.0000503,0.0000547,0.0000429,0.0000222,0.0000075,0.0000211,0.000031,0.0000394,0.0000344,0.0000333,0.0000417,0.0000396,0.0000213,0.0000387,0.0000427,0.0000255,0.0000169,0.0000352,0.0000458,0.0000543,0.0000574,0.0000442,0.0000652,0.0000807,0.0000802,0.0000742,0.0000623,0.0000341,0.0000073,0.000012,0.000055,0.0001692,0.0003761,0.0006684,0.0010684,0.0015341,0.0020401,0.0023499,0.0024909,0.0024918,0.0023697,0.0020836,0.0016931,0.001242,0.0008239,0.0004343,0.0001976,0.000063,0.0000426,0.0000519,0.000072,0.0000907,0.0000909,0.0000921,0.0000942,0.0000847,0.0000724,0.0000696,0.000063,0.0000452,0.0000398,0.0000361,0.0000527,0.000075,0.0000759,0.0000711,0.0000666,0.0000605,0.0000371,0.000034,0.000041,0.0000427,0.0000449,0.0000471,0.0000407,0.000052,0.0000587,0.0000565,0.0000541,0.0000523,0.0000398,0.0000289,0.0000386,0.0000397,0.0000404,0.0000401,0.0000302,0.0000124,0.0000251,0.0000252,0.0000271,0.0000272,0.0000229,0.0000306,0.0000628,0.0000792,0.0000847,0.0000893,0.0000899,0.0000735,0.0000578,0.0000466,0.0000246,0.0000091,0.0000288,0.000041,0.0000536,0.0000604,0.0000561,0.000048,0.0000456,0.0000383,0.0000371,0.00005,0.0000524,0.0000586,0.0000667,0.0000655,0.000055,0.0000476,0.0000434,0.0000249,0.0000085,0.0000151,0.0000221,0.0000355,0.0000406,0.0000492,0.0000668,0.0000779,0.0000789,0.0000786,0.0000756,0.0000567,0.0000398,0.0000402,0.0000417,0.0000431,0.000043,0.0000345,0.0000169,0.0000076,0.0000012,0.0000168,0.000038,0.0000394,0.0000363,0.0000267,0.0000247,0.0000297,0.0000363,0.0000404,0.0000475,0.0000532,0.0000539,0.0000417,0.0000251,0.0000227,0.0000269],"detections":[{"range_m":2.79791,"snr_db":23.0872,"width_m":0.175182}],"noise_floor":0.000038573,"blast_amplitude":0.2700266,"saturated":false} +{"timestamp":1756162801.0666656,"source":"simulated","device_id":"batvu-reference-01","beam":[0.7848856,0.4531539,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000372,0.0000365,0.0000382,0.0000425,0.0000496,0.0000298,0,0.0000137,0.0000379,0.0000219,0.0000079,0.0000244,0.0000283,0.0000382,0.0000473,0.0000154,0.000019,0.0000358,0.0000325,0.0000366,0.0000377,0,0,0.0000287,0.0000226,0.000038,0.0000681,0.0000596,0.0000483,0.0000714,0.0000635,0.0000493,0.0000409,0.000048,0.0000762,0.0001014,0.0001032,0.0000606,0.0000645,0.0000658,0.0000302,0.0000013,0.0000219,0.0000222,0.0000183,0.0000233,0.0000183,0.0000207,0.000027,0.0000255,0.0000349,0.0000392,0.0000124,0.0000027,0.0000294,0.0000268,0.0000261,0.000044,0.0000407,0.0000227,0.000041,0.0000497,0.0000536,0.0000531,0.0000411,0,0.0000178,0.0000186,0.0000119,0.0000423,0.0000534,0.0000486,0.0000358,0.0000237,0.0000358,0.0000466,0.0000498,0.0000327,0.0000228,0.0000251,0.0000093,0.0000098,0.0000396,0.0000424,0.0000423,0.0000378,0.000016,0.0000125,0.0000385,0.0000379,0.0000568,0.0000886,0.0000912,0.0000843,0.0000714,0.0000621,0.0000603,0.0000778,0.0000806,0.0000655,0.0000749,0.0000862,0.0000813,0.0000566,0.0000701,0.0002105,0.0004354,0.0007465,0.0011345,0.001597,0.0021432,0.0024714,0.0026279,0.0026347,0.002533,0.0022302,0.0018162,0.0013626,0.0008314,0.0004821,0.0002353,0.0000687,0.0000213,0.0000321,0.0000407,0.0000535,0.0000552,0.0000395,0.0000455,0.0000546,0.0000512,0.0000413,0.0000272,0.0000162,0.0000314,0.0000396,0.0000348,0.0000429,0.0000664,0.0000761,0.0000853,0.0000912,0.0000856,0.00007,0.0000639,0.0000592,0.0000297,0.0000344,0.0000583,0.0000689,0.0000695,0.0000651,0.0000341,0.0000124,0.0000106,0.0000056,0.0000131,0.0000176,0.0000121,0.0000121,0.0000228,0.0000225,0.0000197,0.0000099,0.0000339,0.0000632,0.0000875,0.0000895,0.0000919,0.0000942,0.0000867,0.0000612,0.0000348,0.000012,0.0000101,0.0000103,0.0000107,0.0000185,0.0000219,0.0000218,0.000003,0,0.0000085,0.0000178,0.0000322,0.000036,0.0000275,0.0000172,0.0000159,0.0000184,0.0000421,0.0000712,0.0000756,0.0000754,0.0000703,0.000053,0.0000335,0.0000605,0.0000743,0.0000836,0.0000845,0.0000794,0.0000529,0.0000272,0.0000114,0.0000317,0.0000578,0.0000667,0.0000746,0.0000857,0.0000895,0.0000815,0.0000669,0.0000534,0.0000313,0.000018,0.0000359,0.0000528,0.0000705,0.0000831,0.0000825,0.0000803,0.0000798,0.0000692,0.0000438,0.0000517,0.0000749,0.0000944,0.0001023,0.0000985,0.0000706,0.0000391,0.0000138,0.0000364,0.0000629,0.0000786,0.0000795,0.0000784,0.0000738,0.0000577],"detections":[{"range_m":2.671687,"snr_db":24.1381,"width_m":0.175182}],"noise_floor":0.000039462,"blast_amplitude":0.2699971,"saturated":false} +{"timestamp":1756162801.1333323,"source":"simulated","device_id":"batvu-reference-01","beam":[0.8754261,0.2345697,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000076,0.0000163,0.0000241,0.0000026,0,0,0.000004,0,0.0000258,0.0000574,0.0000596,0.0000596,0.0000572,0,0.0000349,0.000058,0.0000424,0.0000243,0.0000149,0,0.0000449,0.0000914,0.0000934,0.000093,0.0000887,0.0000553,0.0000406,0.000089,0.0000887,0.000084,0.0000749,0.0000541,0.0000179,0.0000315,0.0000325,0.0000097,0.0000574,0.0000657,0.0000583,0.0000431,0.0000312,0.0000283,0.0000339,0.0000344,0,0.0000115,0.0000327,0.0000295,0.0000256,0.0000229,0,0.0000036,0.0000199,0.0000132,0.0000153,0.0000567,0.0000593,0.0000576,0.0000481,0.0000226,0,0.0000124,0.0000171,0.0000229,0.0000307,0.0000294,0.0000128,0.0000313,0.0000392,0.0000332,0.0000285,0.0000327,0.0000255,0.0000268,0.0000275,0.0000092,0.0000298,0.0000575,0.0000602,0.00006,0.0000557,0.0000302,0.000002,0.0000085,0.0000069,0.0000069,0.0000127,0.0000058,0.0000033,0.000038,0.0000441,0.0000436,0.0000519,0.0001603,0.0003423,0.0006287,0.001004,0.0014732,0.0019973,0.0024653,0.0028171,0.002881,0.002856,0.0026166,0.0022054,0.0017232,0.001234,0.0007997,0.0004696,0.0001975,0.000058,0.0000214,0.0000222,0.0000327,0.0000553,0.0000566,0.0000501,0.0000461,0.0000451,0.0000556,0.0000691,0.000071,0.0000534,0.0000393,0.0000365,0.0000175,0.0000246,0.0000359,0.0000355,0.0000341,0.0000362,0.0000266,0.0000282,0.000033,0.0000264,0.0000356,0.0000599,0.0000657,0.000067,0.0000664,0.0000529,0.0000267,0.000039,0.0000435,0.0000505,0.0000618,0.0000601,0.0000433,0.0000381,0.0000434,0.0000462,0.0000468,0.0000462,0.0000256,0.0000188,0.0000203,0.0000075,0.0000044,0.00002,0.0000244,0.0000344,0.000039,0.0000297,0.000008,0.000032,0.0000438,0.0000491,0.0000524,0.0000479,0.0000404,0.0000397,0.0000353,0.0000142,0.0000119,0.0000146,0.0000138,0.0000306,0.0000408,0.0000459,0.0000488,0.0000495,0.000038,0.000038,0.0000407,0.0000308,0.0000166,0.0000056,0.0000061,0.0000115,0.0000168,0.0000119,0.0000118,0.0000222,0.0000218,0.0000206,0.0000208,0.0000182,0.0000034,0.0000098,0.0000102,0.0000092,0.0000164,0.000019,0.0000246,0.0000461,0.0000558,0.0000591,0.0000594,0.0000583,0.0000434,0.0000353,0.0000408,0.0000361,0.0000259,0.0000119,0.0000401,0.0000671,0.0000814,0.0000793,0.0000647,0.0000437,0.0000267,0.0000422,0.0000673,0.0000708,0.0000702,0.0000653,0.000053,0.0000304,0.000016,0.0000073,0.0000205,0.0000442,0.0000534,0.0000506,0.0000412,0.0000458,0.0000531],"detections":[{"range_m":2.49792,"snr_db":27.1549,"width_m":0.171607}],"noise_floor":0.000031168,"blast_amplitude":0.270043,"saturated":false} +{"timestamp":1756162801.1999989,"source":"simulated","device_id":"batvu-reference-01","beam":[0.9063078,0,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8e-7,0,0,0.0000014,0.0000921,0.0000951,0.0000991,0.0001027,0.0001033,0.0000388,0.0000728,0.0001004,0.0000973,0.0000904,0.0000824,0.0000067,0.0000118,0.0000362,0.0000279,0.0000152,0.000012,0.0000208,0.0000245,0.0000275,0.0000021,0.0000043,0.0000093,0,0,0.0000395,0.0000397,0.0000385,0.0000333,0.000015,0.0000095,0.0000351,0.000041,0.0000485,0.0000704,0.0000734,0.0000547,0.0000662,0.0000692,0.0000385,0.0000052,0.0000249,0.0000305,0.0000347,0.0000371,0.0000165,0.0000086,0.0000128,0,0,0.0000075,0.0000118,0.0000208,0.0000307,0.0000241,0.0000114,0.0000398,0.0000385,0.0000309,0.0000231,0.0000193,0.0000188,0.0000301,0.0000291,0.0000143,0.0000437,0.0000561,0.0000561,0.0000566,0.000057,0.000028,0.0000259,0.0000398,0.0000374,0.0000337,0.0000307,0.000015,0.0000073,0.0000156,0.0000091,0,0.0000308,0.0000438,0.0000494,0.0000502,0.0001099,0.0002816,0.0005713,0.0009581,0.0014625,0.0020337,0.0026333,0.0029591,0.0030588,0.0030487,0.0028297,0.0024275,0.0019317,0.0014014,0.0008381,0.0004895,0.0002386,0.0000943,0.0000443,0.0000249,0.0000063,0.0000074,0.0000098,0.0000359,0.0000718,0.0000809,0.0000845,0.0000848,0.0000804,0.0000541,0.0000715,0.0000753,0.000082,0.0000906,0.0000922,0.0000772,0.0000691,0.0000688,0.0000427,0.0000241,0.0000342,0.0000341,0.0000359,0.0000522,0.000067,0.0000854,0.0000952,0.0000906,0.000083,0.0000913,0.0000892,0.0000824,0.0000809,0.0000735,0.0000591,0.0000776,0.0000817,0.000085,0.000088,0.0000854,0.0000691,0.0000748,0.0000774,0.0000711,0.0000662,0.0000642,0.0000426,0.0000258,0.0000165,0.0000042,0.000016,0.000025,0.0000276,0.0000498,0.0000634,0.0000611,0.0000482,0.0000303,0.0000109,0.0000109,0.0000146,0.0000094,0.0000046,0.000019,0.0000226,0.0000354,0.0000553,0.0000594,0.0000494,0.0000401,0.0000613,0.00008,0.0000881,0.0000882,0.0000691,0.000047,0.000032,0.0000073,0.0000327,0.0000586,0.0000602,0.0000546,0.0000323,0.0000372,0.0000659,0.0000811,0.0000801,0.0000813,0.0000948,0.0000953,0.0000966,0.0001012,0.0000994,0.000086,0.000076,0.0000698,0.0000501,0.000044,0.0000464,0.0000441,0.0000397,0.0000315,0.0000102,0.0000128,0.0000183,0.000011,0.0000035,0.0000067,0.0000022,0.000001,0.000017,0.0000371,0.0000835,0.0001231,0.0001354,0.0001357,0.0001269,0.0001031,0.0000641,0.0000366,0.0000186,0,0.000018,0.000027,0.0000346,0.0000481,0.0000579,0.0000629,0.0000716,0.0000758,0.000073],"detections":[{"range_m":2.389984,"snr_db":26.5278,"width_m":0.171607}],"noise_floor":0.000036572,"blast_amplitude":0.2700389,"saturated":false} +{"timestamp":1756162801.2666655,"source":"simulated","device_id":"batvu-reference-01","beam":[0.8754261,-0.2345697,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000413,0.0000498,0.0000508,0.0000489,0.0000414,0.000015,0,0,0.0000372,0.0000404,0.0000403,0.0000352,0,0.0000059,0.0000127,0,0.0000029,0.0000079,0,8e-7,0.0000464,0.0000469,0.0000489,0.0000619,0.0000537,0.0000719,0.0000896,0.0000733,0.0000483,0.0000511,0.0000295,0,0.0000367,0.0000329,0.0000089,0.0000338,0.0000634,0.0000686,0.0000683,0.0000589,0.0000023,0.0000071,0.0000208,0.0000182,0.0000276,0.0000319,0.0000158,0.0000277,0.0000407,0.0000314,0.0000229,0.0000195,0,0,0.0000016,0,0,0.000009,0.0000049,0.0000042,0.0000404,0.0000403,0.0000359,0.0000268,0.0000154,0.0000045,0.0000079,0.0000058,0,0.0000015,0.0000066,0.0000105,0.0000237,0.0000333,0.000027,0.00004,0.0000499,0.0000443,0.0000523,0.0000647,0.0000601,0.000066,0.0000776,0.0000711,0.0000531,0.0000388,0.0000433,0.0000589,0.000079,0.0000788,0.0001104,0.000207,0.0003738,0.0006645,0.001086,0.0016886,0.0022178,0.0026817,0.0029551,0.0030048,0.0029674,0.0027241,0.0022836,0.0016488,0.0011477,0.0007086,0.000372,0.0001497,0.000028,0.000049,0.0000492,0.0000206,0.0000075,0.0000255,0.0000261,0.0000375,0.0000525,0.0000517,0.0000319,0.0000166,0.0000137,0.0000228,0.0000282,0.0000287,0.0000088,0.0000134,0.0000177,0.0000214,0.0000405,0.0000598,0.0000773,0.0001021,0.0001184,0.0001157,0.0001081,0.0000993,0.0000752,0.0000435,0.0000389,0.0000508,0.0000644,0.0000745,0.0000711,0.0000627,0.0000747,0.0000743,0.0000617,0.0000405,0.0000227,0.000052,0.0000694,0.000071,0.0000529,0.0000368,0.0000324,0.0000132,0.0000277,0.0000482,0.0000555,0.0000581,0.0000582,0.0000467,0.0000614,0.0000854,0.0000912,0.0000917,0.0000893,0.0000739,0.0000476,0.0000342,0.0000241,0.0000206,0.0000461,0.0000502,0.0000497,0.0000419,0.0000266,0.0000149,0.0000305,0.0000327,0.0000226,0.0000141,0.0000136,0.0000034,0.0000231,0.0000426,0.0000543,0.000065,0.00007,0.0000635,0.0000525,0.0000481,0.00004,0.0000335,0.0000399,0.000037,0.000027,0.000023,0.000022,0.0000152,0.0000276,0.000042,0.0000544,0.0000613,0.000062,0.0000485,0.0000494,0.0000516,0.000047,0.0000403,0.0000308,0.0000083,0,0.0000017,4e-7,0.0000089,0.0000158,0.0000142,0.0000186,0.0000372,0.00005,0.0000653,0.0000708,0.000066,0.000051,0.0000408,0.0000328,0.0000126,0.0000271,0.0000374,0.0000434,0.0000494,0.0000527,0.0000666,0.0000885,0.000103,0.0001096,0.0001177,0.0001202,0.0001111],"detections":[{"range_m":2.421122,"snr_db":25.9909,"width_m":0.171607}],"noise_floor":0.000039479,"blast_amplitude":0.2700178,"saturated":false} +{"timestamp":1756162801.333332,"source":"simulated","device_id":"batvu-reference-01","beam":[0.7848856,-0.4531539,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000178,0.0000242,0.0000253,0.0000249,0.0000216,0.0000018,0.0000159,0.0000339,0.0000417,0.000012,0.0000827,0.0001163,0.000117,0.0001181,0.00012,0.0000605,0.0000689,0.0000842,0.0000574,0.0000364,0.0000545,0.0000407,0.0000231,0.0000082,0.0000171,0.0000238,0.0000251,0.0000025,0.0000093,0.0000219,0.0000055,0,0.0000415,0.0000406,0.0000333,0.0000222,0.000017,0.000028,0.0000381,0.0000382,0.0000127,0.0000222,0.0000242,0,5e-7,0.0000142,0.000007,0.0000222,0.0000447,0.0000452,0.0000474,0.000049,0.0000206,0.0000016,0.0000378,0.0000432,0.0000543,0.0000758,0.0000734,0.0000783,0.0000963,0.0000927,0.0000769,0.0000631,0.0000472,0.0000156,0.0000269,0.0000256,0.0000042,0.0000287,0.0000395,0.0000385,0.000037,0.0000352,0.0000123,0.0000197,0.0000244,0.0000113,0.0000282,0.0000566,0.0000624,0.0000666,0.0000701,0.0000592,0.0000667,0.0000919,0.0000913,0.0000889,0.0000876,0.000075,0.0000496,0.0000646,0.0000653,0.0000563,0.0000578,0.00006,0.0000454,0.0000378,0.0000289,0.0000793,0.0002027,0.0003949,0.0007627,0.001207,0.0017121,0.0022001,0.0025975,0.0028137,0.0028329,0.0027688,0.0025159,0.0020095,0.001501,0.0010163,0.0006052,0.0002919,0.0001058,0.0000276,0.0000346,0.0000423,0.0000399,0.0000168,0.0000223,0.000037,0.0000407,0.0000411,0.0000405,0.0000183,0.0000185,0.0000267,0.0000277,0.0000382,0.000047,0.0000409,0.0000388,0.0000461,0.0000421,0.0000331,0.000024,0.0000089,0.0000192,0.0000429,0.0000486,0.0000559,0.0000656,0.0000646,0.0000547,0.000051,0.0000476,0.0000264,0.0000208,0.0000256,0.00003,0.0000383,0.0000421,0.0000321,0.0000297,0.0000339,0.0000299,0.0000273,0.0000279,0.0000174,0.0000174,0.0000395,0.0000471,0.0000515,0.0000525,0.0000454,0.0000287,0.0000406,0.0000405,0.0000411,0.0000439,0.0000418,0.0000288,0.000026,0.0000283,0.0000458,0.0000752,0.0000898,0.0000933,0.0000933,0.00009,0.0000693,0.0000501,0.0000418,0.0000295,0.0000485,0.0000685,0.0000699,0.0000685,0.0000634,0.0000518,0.0000417,0.0000395,0.0000355,0.0000416,0.000062,0.0000682,0.0000692,0.0000686,0.0000618,0.0000428,0.0000437,0.0000458,0.0000437,0.0000589,0.0000665,0.0000701,0.0000707,0.0000695,0.0000514,0.0000331,0.0000249,0.0000122,0.0000012,0.0000072,0.0000132,0.0000333,0.0000608,0.0000698,0.0000702,0.0000607,0.0000338,0.0000286,0.0000491,0.0000491,0.0000377,0.0000203,0.0000158,0.0000263,0.0000371,0.0000392,0.0000352,0.0000348,0.0000366,0.0000285,0.0000206,0.0000116,0.0000208],"detections":[{"range_m":0.852904,"snr_db":6.1874,"width_m":0.132281},{"range_m":2.581617,"snr_db":25.3381,"width_m":0.171607}],"noise_floor":0.0000376,"blast_amplitude":0.2699973,"saturated":false} +{"timestamp":1756162801.3999987,"source":"simulated","device_id":"batvu-reference-01","beam":[0.6408564,-0.6408564,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8e-7,0.000001,0,0.0000101,0.0000398,0.0000385,0.0000576,0.000071,0.0000659,0.0000786,0.0000831,0.0000203,0.000029,0.0000501,0.0000333,0.0000131,0.0000243,0.0000314,0.0000403,0.0000474,0.0000166,0.000028,0.0000846,0.0000904,0.0000945,0.0000963,0.0000721,0.0000408,0.0000859,0.0000851,0.0000805,0.0000722,0.0000574,0.0000144,0.0000432,0.0000463,0.0000189,0.0000285,0.0000416,0.0000314,0.0000147,0.0000093,0.0000096,0.0000095,0.0000097,0,0,0.000024,0.0000369,0.0000514,0.0000617,0.0000523,0.0000568,0.0000741,0.0000689,0.0000383,0.0000349,0.0000313,0.0000252,0.0000521,0.0000559,0.0000514,0.0000419,0.0000305,0.0000166,0.0000455,0.0000564,0.00006,0.0000768,0.0000895,0.000084,0.0000773,0.0000648,0.0000209,0.0000148,0.0000358,0.0000343,0.0000556,0.0000782,0.0000768,0.0000689,0.0000564,0.0000318,0,0.0000109,0.000007,0,0.000014,0.0000209,0.0000284,0.000041,0.000044,0.0000338,0.0000525,0.0000584,0.0000506,0.0000361,0.0000191,0.0000074,0.0000108,0.0000107,4e-7,0.0000084,0.0000115,0.000015,0.0000765,0.0002316,0.000449,0.0007877,0.0012215,0.0016686,0.0020809,0.0023825,0.0024913,0.002483,0.0023419,0.0020495,0.0016364,0.001198,0.0007999,0.0004627,0.0002355,0.0000988,0.000034,0.000004,0.0000191,0.0000266,0.0000376,0.0000458,0.0000441,0.000062,0.0000776,0.0000761,0.0000669,0.0000517,0.0000255,0.0000111,0.0000306,0.0000302,0.0000368,0.0000573,0.0000603,0.0000606,0.0000602,0.0000528,0.0000265,0.0000093,0.0000088,0.0000307,0.0000629,0.0000784,0.0000765,0.0000639,0.0000522,0.0000589,0.0000745,0.0000834,0.0000811,0.000086,0.0000911,0.0000847,0.0000746,0.0000798,0.0000779,0.0000732,0.0000697,0.0000587,0.0000426,0.0000397,0.0000373,0.0000229,0.0000257,0.0000279,0.0000201,0.0000239,0.0000269,0.0000349,0.0000565,0.0000747,0.0000806,0.0000808,0.0000737,0.0000452,0.0000174,0.0000079,0.000015,0.0000337,0.000058,0.0000628,0.0000648,0.0000657,0.0000605,0.0000543,0.0000597,0.0000575,0.0000392,0.0000173,0.0000109,0.0000132,0.0000158,0.0000169,0.0000137,0.0000286,0.0000356,0.000037,0.0000365,0.0000301,0.0000075,0.0000257,0.000036,0.0000332,0.0000285,0.0000299,0.000024,0.0000226,0.0000342,0.0000347,0.0000396,0.0000476,0.0000468,0.0000445,0.0000459,0.0000445,0.00004,0.0000427,0.0000405,0.0000267,0.00002,0.00002,0.0000068,0.0000334,0.0000703,0.0001007,0.0001298,0.0001404,0.0001389],"detections":[{"range_m":2.757947,"snr_db":26.4476,"width_m":0.175182}],"noise_floor":0.000038272,"blast_amplitude":0.2700154,"saturated":false} +{"timestamp":1756162801.4666653,"source":"simulated","device_id":"batvu-reference-01","beam":[0.4531539,-0.7848856,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000173,0.0000063,0,0,0.000055,0.0000592,0.0000512,0.0000335,0.000015,0.0000135,0.0000221,0.0000231,0,0,0.0000132,0.000008,0.0000097,0.0000184,0.0000065,0.000027,0.0000558,0.0000563,0.0000855,0.0001107,0.0000931,0.0000639,0.0000408,0.0000236,0.0000341,0.0000396,0.0000195,0.0000217,0.0000805,0.0000879,0.0000944,0.0000987,0.000097,0.0000445,0.0000747,0.0000803,0.0000611,0.0000347,0.0000215,0.0000504,0.0000624,0.0000632,0.0000215,0.0000097,0.0000295,0.0000259,0.0000319,0.0000401,0.0000232,0.000012,0.0000321,0.0000291,0.0000234,0.0000193,0.0000103,0.0000065,0.0000312,0.0000317,0.0000372,0.0000443,0.0000422,0.0000135,0.0000283,0.0000298,0.0000141,0.0000272,0.0000527,0.0000666,0.0000775,0.0000809,0.0000573,0.0000716,0.0000909,0.0000909,0.0000871,0.0000755,0.0000397,0.0000016,0.000017,0.0000131,0.0000315,0.0000635,0.0000653,0.0000668,0.0000719,0.000065,0.0000478,0.0000556,0.0000557,0.000047,0.0000447,0.0000443,0.0000109,0.0000094,0.0000216,0.0000249,0.0000337,0.0000403,0.0000359,0.0000384,0.0000412,0.0000302,0.0000315,0.0000353,0.0000227,0.0000075,0.0000269,0.0000231,0.0000043,0.0000762,0.0001693,0.0003327,0.0005886,0.0009894,0.0014031,0.0018314,0.0021677,0.0023661,0.0024007,0.0023664,0.0021422,0.0017866,0.0012878,0.0008633,0.0005092,0.0002557,0.0000834,0.0000211,0.0000378,0.0000337,0.0000251,0.0000161,0.0000018,0.0000101,0.0000168,0.000017,0.0000355,0.0000546,0.0000538,0.0000462,0.0000371,0.0000274,0.0000049,0.0000141,0.0000151,0.0000125,0.0000266,0.0000324,0.0000319,0.0000272,0.0000205,0.0000092,0.0000174,0.0000237,0.0000301,0.0000526,0.0000803,0.0000938,0.0000994,0.0000986,0.000078,0.0000486,0.0000468,0.0000513,0.0000552,0.0000556,0.0000472,0.0000293,0.0000212,0.0000138,0.0000104,0.0000275,0.0000327,0.000041,0.0000554,0.0000581,0.0000496,0.0000351,0.0000215,0.0000329,0.000054,0.0000591,0.0000516,0.0000399,0.0000389,0.0000357,0.0000352,0.0000359,0.0000269,0.0000218,0.0000352,0.000039,0.000047,0.0000573,0.0000566,0.0000496,0.0000525,0.000051,0.000038,0.0000232,0.0000155,0.0000339,0.0000542,0.0000569,0.0000525,0.0000539,0.0000561,0.0000473,0.0000359,0.0000296,0.0000212,0.0000365,0.0000547,0.0000659,0.0000841,0.0001043,0.0001064,0.0001056,0.0000957,0.0000776,0.0000569,0.0000554,0.000054,0.0000454,0.0000492,0.0000489,0.0000501,0.0000571,0.0000588,0.000059,0.0000679,0.0000706,0.0000549,0.0000274,0.0000309,0.0000534],"detections":[{"range_m":2.842285,"snr_db":28.1564,"width_m":0.171607}],"noise_floor":0.00003805,"blast_amplitude":0.2699913,"saturated":false} +{"timestamp":1756162801.5333319,"source":"simulated","device_id":"batvu-reference-01","beam":[0.2345697,-0.8754261,-0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000303,0.0000253,0.0000054,0,0.0000054,0.0000046,0.0000048,0.0000137,0.000014,0.000019,0.000022,0,0.0000073,0.0000141,0,0.0000096,0.000055,0.0000535,0.0000409,0.0000259,6e-7,0.000023,0.0000644,0.0000703,0.0000813,0.0000866,0.0000654,0.0000329,0.0000416,0.0000335,0.0000068,0.0000341,0.0000409,0.0000339,0.000037,0.0000352,0,0.0000384,0.000055,0.0000499,0.000057,0.0000619,0.0000411,0.0000243,0.0000241,0.0000126,0.0000049,0.0000238,0.0000219,0.0000319,0.0000591,0.0000679,0.0000775,0.0000839,0.0000747,0.000044,0.0000402,0.000031,0.0000189,0.000053,0.0000576,0.000056,0.0000501,0.000036,0.0000023,0.0000397,0.0000519,0.0000561,0.000076,0.0000911,0.0000882,0.0000855,0.0000813,0.000051,0.0000328,0.0000461,0.0000414,0.0000294,0.0000237,0.000003,0.0000168,0.0000545,0.0000602,0.000065,0.0000771,0.0000748,0.0000853,0.0001106,0.0001125,0.0001073,0.0001009,0.000092,0.0000472,0.000024,0.000018,0,0.0000217,0.0000506,0.000063,0.0000649,0.0000633,0.0000341,0.000007,0.0000199,0.0000212,0.0000309,0.0000626,0.0001315,0.0002843,0.0005303,0.0008524,0.0012662,0.0017246,0.0021912,0.0024461,0.0025224,0.0025092,0.0023259,0.0019959,0.0015854,0.0011378,0.0007403,0.0003856,0.0001772,0.0000659,0.0000428,0.0000428,0.0000564,0.0000651,0.0000536,0.000031,0.0000304,0.0000388,0.0000414,0.0000412,0.0000312,0.000018,0.0000345,0.0000343,0.0000322,0.0000286,0.0000256,0.0000171,0.0000173,0.0000129,4e-7,0.0000175,0.0000256,0.000032,0.0000408,0.0000446,0.0000389,0.0000561,0.0000683,0.0000699,0.0000718,0.0000747,0.0000694,0.0000873,0.0001006,0.0000999,0.0000972,0.0000953,0.0000874,0.0000705,0.0000629,0.000056,0.0000442,0.0000524,0.0000513,0.0000385,0.0000238,0.0000159,0.000031,0.0000476,0.0000511,0.0000482,0.0000525,0.0000559,0.0000539,0.0000737,0.0000844,0.0000844,0.0000819,0.0000763,0.0000589,0.0000383,0.0000282,0.0000167,0.0000122,0.0000393,0.0000558,0.0000702,0.0000788,0.0000769,0.0000612,0.0000484,0.0000446,0.0000332,0.0000318,0.0000325,0.0000209,0.0000178,0.0000193,0.0000103,0.0000154,0.0000345,0.0000412,0.0000415,0.0000367,0.000006,0.0000319,0.0000497,0.00005,0.0000471,0.0000409,0.0000284,0.0000084,0.0000103,0.0000103,0.0000168,0.000035,0.0000379,0.0000377,0.0000392,0.0000401,0.0000426,0.0000426,0.0000389,0.0000184,0.0000101,0.0000273,0.0000432,0.000061,0.00007,0.0000752],"detections":[{"range_m":2.793585,"snr_db":26.3236,"width_m":0.171607}],"noise_floor":0.000039334,"blast_amplitude":0.2699779,"saturated":false} +{"timestamp":1756162801.5999985,"source":"simulated","device_id":"batvu-reference-01","beam":[0.258819,-0.9659258,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000117,0.0000102,0.0000064,0.0000275,0.0000464,0.0000267,0.0000209,0.0000241,0,0.0000159,0.0000423,0.0000363,0.0000332,0.0000427,0.0000258,0.0000257,0.0000273,0,0,0.0000298,0.000025,0.0000133,0.0000272,0.0000464,0.0000654,0.0000718,0.0000499,0.0000209,0.000049,0.0000435,0.0000166,0.0000236,0.0000477,0.0000524,0.0000523,0.0000436,0,0,0.0000104,0.0000188,0.0000335,0.0000368,0.000011,0.0000092,0.0000285,0.0000255,0.000033,0.000042,0.0000261,0.0000258,0.0000475,0.0000429,0.0000301,0.000018,0.0000063,0.0000017,0.0000119,0.0000212,0.0000322,0.0000401,0.0000375,0.0000045,0.0000097,0.0000109,0,0.0000203,0.0000337,0.0000338,0.0000335,0.000033,0.0000026,6e-7,0.0000099,0.0000102,0.0000446,0.0000809,0.0000839,0.0000821,0.0000758,0.0000567,0.0000367,0.0000432,0.0000558,0.000082,0.0001072,0.0001044,0.0000914,0.0000991,0.0000976,0.0000753,0.0000565,0.0000449,0.0000123,0.0000256,0.000031,0.0000246,0.000019,0.0000195,0.0000076,0.0000334,0.0000475,0.0000441,0.0000316,0.0000173,3e-7,0,0.0000335,0.0000469,0.0000549,0.0000568,0.0000475,0.0000208,0.0000098,0.0000202,0.0000252,0.000027,0.0000238,0.0000043,0.0000118,0.000014,0.0000128,0.0000439,0.000053,0.0000526,0.0000471,0.0000371,0.0000081,0.000012,0.0000256,0.0000311,0.0000517,0.0000653,0.00006,0.0000282,0.0001362,0.000342,0.000586,0.0008849,0.0011987,0.001511,0.0017627,0.0018705,0.0018737,0.0017978,0.0015257,0.0011899,0.0008472,0.0005485,0.0002956,0.0001175,0.0000515,0.0000649,0.0000646,0.000055,0.0000285,0.0000366,0.0000467,0.0000439,0.0000414,0.0000478,0.0000451,0.0000422,0.0000459,0.0000421,0.0000347,0.0000336,0.0000257,0.0000235,0.0000504,0.0000541,0.0000533,0.0000464,0.0000359,0.0000044,0.0000168,0.0000284,0.0000311,0.000031,0.0000271,0.0000092,0.0000364,0.0000521,0.0000596,0.000069,0.0000742,0.0000674,0.0000525,0.0000446,0.0000394,0.0000306,0.0000196,0.0000275,0.000037,0.0000403,0.000038,0.0000408,0.0000596,0.0000617,0.0000642,0.0000686,0.0000695,0.0000579,0.0000501,0.0000481,0.0000345,0.0000366,0.0000425,0.0000413,0.000038,0.000035,0.0000332,0.0000503,0.0000673,0.0000721,0.000073,0.0000716,0.0000583,0.0000341,0.0000245,0.0000221,0.0000146,0.0000095,0.0000198,0.0000293,0.000035,0.0000328,0.0000201,0.0000218,0.0000234,0.0000179,0.0000193,0.0000233,0.0000219,0.0000215,0.0000209,0.0000095],"detections":[{"range_m":3.27492,"snr_db":23.9076,"width_m":0.171607}],"noise_floor":0.000032543,"blast_amplitude":0.2699602,"saturated":false} +{"timestamp":1756162801.666665,"source":"simulated","device_id":"batvu-reference-01","beam":[0.5,-0.8660254,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.000019,0,0,0,0.0000652,0.0000679,0.0000514,0.0000287,0.0000146,0.0000044,0.0000127,0.0000193,0.0000025,0,0.0000168,0.0000115,0.0000016,0.0000272,0.0000401,0.000047,0.0000523,0.0000343,0.0000673,0.0000893,0.0000674,0.0000421,0.0000709,0.0000653,0.0000581,0.0000614,0.0000426,0.0000193,0.0000549,0.0000538,0.0000563,0.0000573,0.0000551,0.0000145,0.0000493,0.0000547,0.0000399,0.0000632,0.0000754,0.0000624,0.0000416,0.0000352,0.0000212,0.0000173,0.0000107,0.0000119,0.0000191,0.0000213,0,0.000006,0.0000299,0.0000262,0.0000168,0.0000318,0.000039,0.0000462,0.0000503,0.0000403,0.0000122,0.0000333,0.0000436,0.0000541,0.0000672,0.0000656,0.0000381,0.0000294,0.0000321,0.0000235,0.0000384,0.000049,0.0000429,0.0000627,0.0000786,0.0000772,0.0000741,0.0000686,0.0000449,0.0000251,0.0000353,0.0000289,0.0000163,0.000038,0.0000436,0.0000531,0.0000673,0.0000643,0.0000566,0.000068,0.0000678,0.0000593,0.0000661,0.0000677,0.0000461,0.0000598,0.0000676,0.000067,0.0000647,0.0000595,0.0000346,0.000034,0.0000407,0.0000331,0.000024,0.0000256,0.000016,0.0000096,0.0000414,0.0000517,0.0000582,0.0000595,0.0000498,0.0000252,0.0000219,0.00002,0,0.0000165,0.0000181,0.0000127,0.0000152,0.0000261,0.0000325,0.0000441,0.0000467,0.0000386,0.0000315,0.000024,0,0.0000137,0.0000248,0.00002,0.0000197,0.0000336,0.0000356,0.0000359,0.0000353,0.0000261,0.0000663,0.0001539,0.0002863,0.0005058,0.0008086,0.0011371,0.0014617,0.0017678,0.0018749,0.0018799,0.0018252,0.001647,0.0013535,0.0010161,0.0006912,0.0003549,0.0001635,0.0000578,0.0000216,0.000041,0.0000528,0.0000506,0.0000427,0.0000356,0.0000257,0.000016,0.0000206,0.0000191,0.0000189,0.0000236,0.0000197,0.0000198,0.0000363,0.0000368,0.0000306,0.0000191,0.0000068,0.0000161,0.000024,0.0000257,0.0000297,0.0000516,0.0000631,0.0000624,0.0000527,0.0000413,0.0000656,0.0000898,0.0000996,0.0000961,0.0000877,0.000084,0.0000712,0.000052,0.0000414,0.0000366,0.0000389,0.0000521,0.0000521,0.0000471,0.0000463,0.0000451,0.0000387,0.000037,0.0000366,0.0000206,0.000008,0.0000083,0.0000111,0.0000114,0.0000102,0.0000019,0.0000112,0.000016,0.0000086,0.0000016,0.0000096,0.0000199,0.0000407,0.0000615,0.0000652,0.0000735,0.0000839,0.0000832,0.0000752,0.0000602,0.0000395,0.0000119,0.0000143,0.0000238,0.0000292,0.0000298,0.000028,0.00001,0.0000049,0.0000264,0.0000548,0.0000803,0.0000896,0.0000879],"detections":[{"range_m":3.333622,"snr_db":26.3499,"width_m":0.171607}],"noise_floor":0.000036678,"blast_amplitude":0.2700387,"saturated":false} +{"timestamp":1756162801.7333317,"source":"simulated","device_id":"batvu-reference-01","beam":[0.7071068,-0.7071068,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000137,0.000012,0.0000168,0.0000282,0.0000413,0.0000205,0,0.0000011,0.0000302,0.0000157,0.0000444,0.0000702,0.0000689,0.0000694,0.0000756,0.0000398,0.0000389,0.00004,0,0,0.000034,0.0000294,0.0000148,0.0000136,0.0000243,0.0000266,0.0000248,0.0000064,0.0000365,0.0000531,0.0000358,0.0000138,0.0000392,0.000033,0.0000022,0.0000256,0.0000384,0.0000361,0.0000251,0.0000082,0.0000105,0.0000134,0.0000139,0.0000089,0.0000327,0.0000453,0.0000434,0.0000446,0.0000441,0.0000099,0.0000069,0.0000191,0.0000011,0.0000112,0.0000458,0.000046,0.0000463,0.0000519,0.0000449,0.0000374,0.0000677,0.0000672,0.0000653,0.0000623,0.0000543,0.0000187,0.0000449,0.0000526,0.0000819,0.0001493,0.0002163,0.0002835,0.0003488,0.0003746,0.0003668,0.0003385,0.0002987,0.0002248,0.0001454,0.0000853,0.0000306,5e-7,0.0000299,0.0000353,0.0000362,0.0000348,0.0000174,0,0.000014,0.0000133,0.0000284,0.0000596,0.0000672,0.000073,0.0000774,0.0000779,0.0000488,0.0000523,0.0000564,0.0000491,0.000054,0.0000606,0.0000546,0.000047,0.0000471,0.0000319,0.0000185,0.0000322,0.0000422,0.0000491,0.0000495,0.0000291,0,0.0000144,0.0000151,0.0000304,0.0000583,0.0000607,0.0000572,0.0000487,0.0000336,0.0000013,0.0000108,0.0000142,0.0000085,0.00002,0.0000232,0.0000158,0.0000284,0.0000533,0.0000673,0.0000746,0.0000749,0.0000522,0.0000326,0.000052,0.000062,0.0000683,0.0000683,0.0000493,0.0000675,0.0001936,0.0003584,0.0005937,0.0008879,0.0011807,0.0014459,0.00166,0.0016919,0.0016765,0.0015576,0.0013459,0.0010596,0.0007694,0.0005193,0.0002829,0.000163,0.0001076,0.0000769,0.0000655,0.0000681,0.0000602,0.0000546,0.0000609,0.0000563,0.0000461,0.0000365,0.0000233,0.000007,0.0000026,0.0000049,0.0000082,0.0000189,0.0000214,0.0000284,0.000043,0.0000546,0.0000659,0.0000753,0.0000765,0.0000625,0.0000529,0.0000542,0.000044,0.0000327,0.0000248,0.0000079,0.0000087,0.0000285,0.0000341,0.0000386,0.0000459,0.0000505,0.0000616,0.0000703,0.0000679,0.0000655,0.0000768,0.0000766,0.0000691,0.0000543,0.0000364,0.0000246,0.0000408,0.0000437,0.0000397,0.0000422,0.0000433,0.0000309,0.0000175,0.0000298,0.0000469,0.0000656,0.0000779,0.000081,0.0000888,0.0000936,0.0000878,0.0000734,0.0000582,0.00004,0.000022,0.0000253,0.0000256,0.0000312,0.00004,0.0000394,0.0000332,0.0000256,0.0000074,0.0000297,0.0000488,0.0000515,0.0000443,0.0000323,0.0000266,0.0000329,0.0000435,0.0000465,0.000038],"detections":[{"range_m":1.942919,"snr_db":9.2414,"width_m":0.150156},{"range_m":3.323008,"snr_db":20.8997,"width_m":0.171607}],"noise_floor":0.000040071,"blast_amplitude":0.2700149,"saturated":false} +{"timestamp":1756162801.7999983,"source":"simulated","device_id":"batvu-reference-01","beam":[0.8660254,-0.5,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0001062,0.0001065,0.0001062,0.0001054,0.0001035,0.0000774,0,0.0000038,0.0000446,0.0000358,0.0000228,0.0000188,0.0000324,0.0000519,0.0000599,0.0000109,0.0000363,0.0000586,0.0000449,0.0000293,0.0000307,0.0000318,0.0000525,0.0000678,0.0000445,0.0000548,0.0000929,0.000085,0.0000593,0.0000318,0.0000402,0.0000567,0.0000664,0.0000562,0.0000174,0.0000288,0.0000332,0.0000123,0.0000106,0.0000139,0.0000035,0,0.0000173,0.0000226,0.0000251,0.0000259,0.0000126,0.0000417,0.0000605,0.0000518,0.0000438,0.0000643,0.0000617,0.0000607,0.0000581,0.000036,0.0000151,0.0000263,0.0000216,0.0000122,0.0000449,0.0000498,0.0000545,0.0000555,0.0000508,0.0000114,0.0000396,0.000046,0.0000726,0.0001508,0.0002531,0.0003889,0.0005567,0.000723,0.0008033,0.0008289,0.0008266,0.0007529,0.0006294,0.0004972,0.0003537,0.0001926,0.0001037,0.0000394,0.0000046,0.0000149,0.0000135,0.0000097,0.0000153,0.0000132,0.0000091,0.0000153,0.0000152,0.0000157,0.0000251,0.0000274,0.0000331,0.0000723,0.0000891,0.0000892,0.0000812,0.0000621,0.0000252,0.0000387,0.0000448,0.0000338,0.0000388,0.0000603,0.0000634,0.0000641,0.0000639,0.0000492,0.0000398,0.0000517,0.0000491,0.0000382,0.0000506,0.000052,0.0000525,0.0000575,0.0000566,0.0000609,0.0000729,0.000075,0.0000599,0.0000477,0.0000493,0.0000489,0.0000585,0.000063,0.0000565,0.0000639,0.0000699,0.0001042,0.0002353,0.0004372,0.0006797,0.0009529,0.0012115,0.0014189,0.0014911,0.0014927,0.0014249,0.0012436,0.0010042,0.0007468,0.0004932,0.0002947,0.0001383,0.0000569,0.0000483,0.0000505,0.000041,0.0000453,0.0000498,0.0000391,0.0000273,0.0000221,0.0000211,0.0000362,0.0000481,0.0000466,0.0000408,0.0000331,0.0000181,0.0000288,0.0000647,0.0000819,0.0000887,0.0000886,0.0000719,0.000047,0.0000426,0.0000398,0.0000186,0.0000078,0.0000232,0.0000355,0.0000504,0.0000536,0.0000555,0.0000689,0.0000756,0.0000729,0.0000676,0.0000636,0.0000498,0.0000443,0.0000492,0.0000442,0.0000355,0.0000311,0.0000196,0.0000301,0.0000653,0.0000827,0.0000916,0.0000928,0.0000884,0.0000655,0.0000453,0.0000344,0.0000264,0.000049,0.0000607,0.0000624,0.0000614,0.0000521,0.0000273,0.0000173,0.0000188,0.0000093,0,0.0000153,0.0000302,0.0000515,0.0000691,0.0000772,0.0000878,0.0000941,0.0000891,0.0000676,0.000035,0.0000134,0.0000333,0.0000525,0.0000584,0.0000704,0.0000929,0.0000983,0.0001041,0.0001082,0.0001082,0.0000887,0.0000649,0.0000452,0.0000246,0.0000367,0.0000536,0.0000613],"detections":[{"range_m":1.986331,"snr_db":17.0724,"width_m":0.171607},{"range_m":3.184895,"snr_db":20.1425,"width_m":0.171607}],"noise_floor":0.00004538,"blast_amplitude":0.2699941,"saturated":false} +{"timestamp":1756162801.866665,"source":"simulated","device_id":"batvu-reference-01","beam":[0.9659258,-0.258819,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000103,0.0000375,0.0000249,0,0.000012,0.0000755,0.0000843,0.0000889,0.0000893,0.0000262,0.0000417,0.0000676,0.0000523,0.000036,0.0000222,0,0.0000025,0.0000144,0.0000047,0,0.0000209,0.0000333,0.0000441,0.0000492,0.0000253,0.0000013,0.000041,0.0000347,0.0000228,0.000048,0.0000449,0.0000375,0.0000354,0.0000339,0,0.0000052,0.000013,0.0000102,0.0000274,0.0000459,0.0000601,0.0000711,0.0000726,0.0000363,0.0000179,0.0000302,0.0000207,0.0000182,0.0000364,0.000032,0.0000416,0.0000509,0.000039,0.0000255,0.0000666,0.0000699,0.0000686,0.0000591,0.0000325,0,1e-7,2e-7,0,0.0000236,0.0000384,0.0000929,0.0002118,0.0003516,0.0005071,0.0006679,0.0007945,0.0008294,0.0008301,0.0008018,0.0006985,0.0005622,0.0004353,0.0003131,0.0001889,0.0001283,0.0000804,0.0000445,0.0000419,0.0000383,0.0000532,0.0000996,0.000111,0.0001182,0.0001197,0.000115,0.0000808,0.0000637,0.0000655,0.0000474,0.0000374,0.0000353,0.0000195,0.000026,0.0000305,0.0000196,0.0000226,0.0000292,0.0000219,0.0000103,0.0000076,0.0000066,0.0000116,0.0000225,0.000018,0.0000747,0.0002016,0.0003768,0.0006297,0.0009458,0.0013221,0.0016017,0.0017941,0.0018352,0.0018208,0.0016935,0.0014603,0.0011435,0.0008274,0.0005176,0.0003147,0.000184,0.0001121,0.0000585,0.000026,0.0000343,0.0000368,0.0000392,0.0000416,0.0000336,0.0000423,0.0000669,0.0000732,0.0000739,0.000072,0.0000559,0.0000272,0.0000231,0.0000228,0.0000166,0.000019,0.0000216,0.0000333,0.000049,0.0000525,0.0000569,0.0000739,0.0000806,0.0000703,0.0000511,0.0000363,0.0000369,0.0000415,0.0000417,0.0000272,0.0000113,0.0000198,0.000017,0.0000092,0.0000239,0.0000319,0.0000421,0.0000552,0.0000539,0.0000492,0.0000482,0.0000455,0.000029,0.0000303,0.00004,0.0000445,0.0000475,0.0000483,0.0000443,0.0000579,0.0000644,0.0000626,0.0000725,0.0000777,0.0000748,0.0000687,0.0000591,0.0000366,0.0000248,0.0000377,0.0000363,0.0000322,0.000029,0.0000175,0.0000091,0.0000367,0.000043,0.0000434,0.0000429,0.000041,0.0000384,0.0000503,0.000054,0.0000601,0.0000768,0.0000806,0.0000796,0.0000784,0.0000761,0.0000589,0.0000419,0.0000369,0.0000284,0.0000207,0.0000238,0.0000209,0.0000155,0.0000113,0.0000188,0.0000289,0.0000349,0.0000331,0.0000445,0.0000613,0.0000619,0.0000577,0.000049,0.0000333,0.0000117,0.0000293,0.0000461,0.0000596,0.0000693,0.0000713,0.0000613,0.0000485,0.0000457,0.0000343],"detections":[{"range_m":1.972917,"snr_db":13.4923,"width_m":0.168032},{"range_m":2.809088,"snr_db":23.5471,"width_m":0.171607}],"noise_floor":0.000036602,"blast_amplitude":0.2699966,"saturated":false} +{"timestamp":1756162801.9333315,"source":"simulated","device_id":"batvu-reference-01","beam":[1,0,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000647,0.0000395,0,0,0.0000399,0.0000476,0.0000476,0.0000455,0.0000474,0.0000077,0.0000181,0.0000302,0.0000192,0.0000244,0.0000281,0,0,0.0000116,0,0.0000155,0.000066,0.0000795,0.000084,0.0000838,0.000037,0.0000062,0.0000486,0.0000462,0.0000483,0.0000513,0.0000274,0,0.0000252,0.0000213,0.0000034,0.0000018,0.0000097,0.0000045,0.0000417,0.0000545,0.0000593,0.000061,0.0000607,0.0000148,0.000007,0.0000201,0.0000087,0.0000254,0.000058,0.0000614,0.0000613,0.0000587,0.000026,0.0000192,0.0000459,0.0000442,0.0000393,0.0000324,0.0000195,0.0000257,0.0000352,0.000027,0,0.0000208,0.0000247,0.0000183,0.0000244,0.000063,0.0001256,0.0002194,0.0003196,0.0004518,0.0006201,0.0007829,0.0008471,0.0008542,0.0008346,0.0007291,0.0005878,0.00046,0.0003386,0.0002081,0.0001342,0.0000704,0.0000344,0.000063,0.0000632,0.0000649,0.0000708,0.0000645,0.0000671,0.0001017,0.0001058,0.0001028,0.000088,0.0000625,0.0000043,0.0000351,0.0000424,0.0000488,0.0000777,0.000098,0.0000988,0.0000951,0.0000872,0.0001527,0.0003149,0.0005536,0.0008518,0.0012124,0.0016506,0.0019343,0.0021077,0.0021418,0.0021088,0.0019222,0.0016278,0.0012783,0.0008434,0.0005507,0.0003389,0.0001846,0.0001015,0.0000678,0.0000397,0.0000602,0.0000689,0.0000686,0.0000629,0.0000505,0.0000268,0.0000433,0.0000524,0.0000437,0.000038,0.000043,0.0000355,0.0000206,0.0000109,0.0000154,0.0000203,0.0000239,0.0000185,0.0000078,0.0000137,0.0000181,0.0000322,0.0000563,0.0000577,0.0000633,0.0000737,0.0000757,0.0000632,0.0000494,0.0000381,0.0000355,0.0000603,0.0000702,0.000067,0.0000581,0.000051,0.0000388,0.0000605,0.0000851,0.0000909,0.0000917,0.0000908,0.0000778,0.0000506,0.000033,0.000032,0.0000403,0.0000457,0.0000427,0.000016,0.0000312,0.0000547,0.0000614,0.0000691,0.0000713,0.0000673,0.0000645,0.0000598,0.0000395,0.000022,0.0000179,0.0000219,0.0000442,0.000062,0.0000623,0.000057,0.000042,0.0000088,0.0000235,0.0000355,0.0000343,0.0000381,0.0000614,0.0000744,0.0000862,0.0000884,0.000085,0.0000657,0.0000675,0.0000701,0.0000657,0.0000576,0.0000494,0.0000354,0.0000312,0.0000288,0.0000104,0.0000103,0.000028,0.0000363,0.0000484,0.0000558,0.000053,0.0000467,0.0000435,0.0000356,0.0000154,0.0000222,0.0000345,0.0000417,0.0000442,0.0000419,0.0000287,0.000036,0.0000408,0.0000554,0.0000745,0.0000779,0.0000708,0.0000507,0.0000401,0.0000617,0.0000734,0.000074,0.0000585],"detections":[{"range_m":1.977967,"snr_db":15.2254,"width_m":0.168032},{"range_m":2.678728,"snr_db":20.6919,"width_m":0.175182}],"noise_floor":0.000040832,"blast_amplitude":0.2699939,"saturated":false} +{"timestamp":1756162801.999998,"source":"simulated","device_id":"batvu-reference-01","beam":[0.9659258,0.258819,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.00002,0.0000234,0.0000197,0.0000269,0.0000337,0,0.0000229,0.000058,0.0000589,0.0000563,0.000045,0,0.0000206,0.000029,0,0.0000038,0.0000388,0.0000339,0.0000199,0.000009,0.0000245,0.0000339,0.0000352,0.0000102,0.0000303,0.0000681,0.0000568,0.000031,0.0000181,0.0000079,0,0.0000465,0.0000559,0.0000517,0.0000453,0.0000441,0.0000162,0.0000185,0.0000297,0.0000371,0.0000446,0.0000461,0.0000158,0.0000388,0.0000682,0.0000743,0.0000786,0.0000789,0.000045,0.0000171,0.0000339,0.0000283,0.0000282,0.0000443,0.0000401,0.0000256,0.0000431,0.0000399,0.0000256,0.0000146,0.0000094,0.0000062,0.0000376,0.00005,0.0000662,0.0000895,0.000095,0.0000787,0.0000568,0.0000403,0.0000085,0.0000079,0.0000427,0.0000613,0.0000755,0.000081,0.0000673,0.0000526,0.0000642,0.0000588,0.0000466,0.0000392,0.000032,0.0000314,0.0000655,0.0000686,0.0000688,0.000066,0.0000534,0.0000165,0.0000133,0.0000179,0.0000129,0.0000146,0.0000154,0.0000148,0.0000475,0.0000744,0.0000878,0.000095,0.0000962,0.0000753,0.0000505,0.0000353,0.0000142,0.0000117,0.0000645,0.0001429,0.0002986,0.0005424,0.0008519,0.0012387,0.0016612,0.0020918,0.0023366,0.0024222,0.0024139,0.0022551,0.0019503,0.001561,0.0011316,0.0007522,0.0004186,0.0002234,0.0001178,0.0000866,0.0000724,0.0000639,0.0000715,0.0000753,0.0000876,0.0000959,0.0000908,0.0000806,0.0000738,0.0000585,0.000025,0.0000212,0.0000175,0.0000011,0.0000439,0.0000635,0.0000694,0.0000689,0.0000535,0.0000264,0.0000466,0.0000523,0.0000581,0.0000742,0.000082,0.0000763,0.0000638,0.0000472,0.0000172,0.0000277,0.0000388,0.000036,0.0000281,0.0000292,0.0000259,0.0000256,0.0000287,0.0000246,0.0000281,0.0000438,0.0000428,0.0000367,0.0000428,0.0000478,0.0000601,0.0000756,0.0000786,0.0000727,0.0000674,0.0000627,0.000043,0.0000404,0.0000466,0.0000514,0.0000648,0.00007,0.0000665,0.000065,0.0000663,0.0000567,0.0000384,0.0000261,0.0000332,0.0000429,0.0000492,0.0000459,0.0000434,0.0000561,0.0000565,0.0000593,0.0000672,0.0000665,0.0000672,0.0000796,0.0000821,0.0000762,0.0000683,0.0000677,0.0000603,0.0000536,0.0000411,0.0000098,0.000024,0.0000371,0.0000361,0.0000377,0.0000431,0.0000475,0.0000654,0.0000855,0.0000869,0.0000856,0.0000757,0.0000555,0.0000248,0.0000167,0.0000278,0.0000372,0.0000483,0.0000476,0.000045,0.0000552,0.0000578,0.0000559,0.0000575,0.0000596,0.0000539,0.0000549,0.0000587,0.0000573],"detections":[{"range_m":2.794828,"snr_db":22.1958,"width_m":0.171607}],"noise_floor":0.000046455,"blast_amplitude":0.2700394,"saturated":false} +{"timestamp":1756162802.0666647,"source":"simulated","device_id":"batvu-reference-01","beam":[0.8660254,0.5,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000148,0.0000266,0.0000302,0.0000303,0.000031,0,0.0000012,0.0000146,0.0000089,0.0000074,0.0000054,0.0000031,0.000007,0.0000081,0,0.0000017,0.0000062,0,0,0.0000226,0.0000202,0.0000169,0.0000165,0,0,0.0000567,0.000064,0.0000686,0.0000706,0.0000576,0.0000123,0.0000176,0.0000251,0.0000224,0.0000321,0.0000341,0.0000186,0.0000321,0.0000397,0.0000253,0.0000148,0.0000118,0.0000109,0.0000199,0.0000253,0.0000108,0.0000094,0.0000248,0.0000208,0.0000452,0.0000805,0.0000849,0.0000851,0.0000815,0.0000584,0.0000266,0.0000433,0.0000386,0.0000212,0.0000412,0.0000426,0.0000261,0.0000377,0.0000412,0.0000406,0.00004,0.0000368,0,0.0000159,0.0000336,0.0000298,0.0000262,0.00003,0.0000191,0.0000182,0.0000385,0.0000408,0.0000456,0.0000552,0.000052,0.0000514,0.0000506,0.0000348,0.000009,0.0000313,0.0000292,0.0000178,0.0000168,0.0000243,0.0000325,0.0000388,0.00004,0.0000229,0.0000442,0.0000521,0.0000511,0.000049,0.000044,0.0000176,0.0000229,0.0000349,0.0000369,0.0000433,0.0000468,0.0000337,0.000011,0.0000588,0.0000855,0.0001071,0.000116,0.0001081,0.00008,0.0000598,0.0000482,0.0000201,0.0000128,0.0000102,0,0.0000229,0.0000359,0.0000423,0.0000493,0.0000621,0.0001617,0.0003655,0.0006496,0.0009948,0.0013773,0.0017909,0.0020263,0.0021271,0.0021282,0.0020198,0.0017529,0.0013953,0.0010078,0.0005898,0.0003492,0.0001998,0.0001098,0.0000691,0.0000508,0.0000273,0.0000309,0.0000302,0.0000198,0.0000132,0.0000165,0.0000162,0.0000151,0.0000118,0.0000104,0.0000168,0.0000187,0.0000072,0.0000073,0.0000175,0.0000162,0.0000196,0.0000337,0.0000434,0.0000548,0.0000606,0.0000546,0.0000356,0.0000339,0.0000328,0.0000288,0.0000303,0.000029,0.0000225,0.0000323,0.0000348,0.0000241,0.0000114,0.000031,0.0000442,0.0000523,0.0000546,0.0000445,0.0000318,0.000015,0.0000197,0.0000414,0.0000553,0.0000572,0.0000639,0.0000724,0.0000693,0.0000635,0.0000599,0.0000544,0.0000508,0.0000588,0.0000578,0.0000493,0.0000477,0.0000459,0.0000349,0.000029,0.000027,0.0000153,0.0000302,0.000037,0.0000494,0.0000738,0.0000921,0.0000995,0.0001049,0.0001084,0.0001019,0.0000973,0.0000956,0.0000831,0.0000632,0.0000486,0.0000403,0.0000505,0.0000565,0.0000523,0.0000376,0.0000245,0.0000126,0.0000211,0.0000422,0.0000427,0.0000382,0.00003,0.0000198,0.0000065,0.0000254,0.000039,0.00005,0.0000656,0.0000711,0.0000664],"detections":[{"range_m":3.073406,"snr_db":23.4035,"width_m":0.175182}],"noise_floor":0.00003081,"blast_amplitude":0.2699623,"saturated":false} +{"timestamp":1756162802.1333313,"source":"simulated","device_id":"batvu-reference-01","beam":[0.7071068,0.7071068,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000179,0.0000468,0.0000735,0.0000897,0.0000917,0.0000641,0,0.0000099,0.0000424,0.0000334,0.0000369,0.000039,0,0,0.0000125,0.0000095,0.000033,0.0000515,0.000037,0.0000369,0.0000553,0.0000404,0.0000259,0.0000308,0.0000228,0.00003,0.0000328,0.000008,0,0.0000146,0.000009,0.0000037,0.0000224,0.0000188,0.0000229,0.000055,0.0000618,0.0000558,0.0000638,0.0000638,0.0000337,0.0000588,0.0000706,0.0000586,0.0000485,0.0000485,0.0000162,0.0000363,0.0000565,0.0000476,0.0000266,0.0000265,0.0000353,0.0000361,0.0000339,0.0000158,0.0000257,0.0000391,0.0000344,0.0000218,0.0000253,0.0000156,0.0000157,0.0000559,0.0000618,0.0000615,0.0000586,0.0000508,0.0000138,0.0000107,0.0000218,0.000033,0.0000511,0.0000586,0.000043,0.0000501,0.0000694,0.0000744,0.0000775,0.0000775,0.0000567,0.0000286,0.0000413,0.0000361,0.0000224,0.0000205,0.0000193,0.0000184,0.0000177,0.000017,0.0000321,0.0000509,0.0000513,0.0000489,0.0000702,0.0000771,0.0000719,0.0000699,0.0000706,0.0000519,0.0000705,0.0000852,0.0000848,0.0000794,0.0000701,0.0000387,0.0000155,0.0000238,0.0000191,0.0000096,0.0000222,0.00003,0.0000489,0.0000751,0.0000786,0.0000792,0.000078,0.0000686,0.0000418,0.0000576,0.0000573,0.0000447,0.0000441,0.0000463,0.0000328,0.0000154,0.0000086,0.000009,0.0000212,0.0000475,0.0000617,0.0000648,0.0000625,0.0000291,0.0000236,0.0000364,0.00004,0.000088,0.0001989,0.000411,0.0006833,0.0010174,0.0013495,0.001646,0.0018497,0.0018988,0.0018876,0.0017634,0.0014618,0.0011188,0.0007763,0.0004859,0.0002579,0.0001271,0.0000833,0.000076,0.00009,0.0000953,0.0000876,0.0000749,0.0000654,0.0000489,0.0000461,0.000057,0.0000547,0.0000448,0.0000322,0.000018,0.0000221,0.0000321,0.0000285,0.0000185,0.0000208,0.0000189,0.0000043,0.0000108,0.0000156,0.0000122,0.0000084,0.0000105,0.0000188,0.0000407,0.0000587,0.0000671,0.0000757,0.0000781,0.0000684,0.0000582,0.0000581,0.0000542,0.0000654,0.0000827,0.0000852,0.000086,0.0000856,0.000075,0.0000514,0.0000348,0.0000268,0.0000221,0.0000325,0.0000321,0.000025,0.0000222,0.0000233,0.0000152,0.0000261,0.0000334,0.0000341,0.0000337,0.0000341,0.0000258,0.0000178,0.0000172,0.0000313,0.0000424,0.0000447,0.0000346,0.0000138,0.0000167,0.000033,0.0000572,0.0000731,0.0000727,0.0000667,0.0000612,0.0000597,0.0000521,0.0000472,0.0000429,0.0000342,0.0000321,0.0000293,0.0000107,0.0000377,0.0000594,0.0000611,0.0000581,0.0000448,0.000037],"detections":[{"range_m":3.26781,"snr_db":21.9042,"width_m":0.171607}],"noise_floor":0.000039872,"blast_amplitude":0.2699689,"saturated":false} +{"timestamp":1756162802.199998,"source":"simulated","device_id":"batvu-reference-01","beam":[0.5,0.8660254,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.000009,0.0000061,4e-7,0,0.0000175,0.0000255,0.0000304,0.0000352,0.0000399,0,0.0000337,0.0000607,0.0000608,0.0000593,0.0000554,0,0.0000124,0.0000517,0.0000651,0.0000774,0.0000828,0.0000455,0.0000376,0.0000733,0.0000706,0.0000686,0.0000671,0.0000362,0,0.0000313,0.000023,0.0000028,0.000028,0.0000315,0.0000318,0.0000306,0.000024,0,0.0000053,0.0000056,0,0.0000334,0.0000516,0.0000478,0.0000367,0.0000306,0,0.0000328,0.0000801,0.0001036,0.0001503,0.0002442,0.0003295,0.0004269,0.0005005,0.0005198,0.0005278,0.0005281,0.0005042,0.0004084,0.0003189,0.0002186,0.000109,0.0000472,0.0000229,0,0.0000109,0.00001,0,0.0000293,0.0000486,0.0000578,0.0000679,0.0000714,0.0000474,0.0000512,0.0000612,0.0000513,0.000032,0.0000233,0.0000193,0.0000094,0.0000283,0.0000446,0.0000543,0.0000566,0.0000465,0.0000268,0.0000613,0.0000665,0.0000674,0.0000649,0.0000487,0.0000029,0.0000298,0.000042,0.0000567,0.0000859,0.0000998,0.0001079,0.0001159,0.0001175,0.0000969,0.0000721,0.0000689,0.0000494,0.0000297,0.0000524,0.0000614,0.0000665,0.0000675,0.0000531,0.000033,0.0000296,0.0000227,0.0000064,0.0000304,0.0000354,0.0000366,0.0000401,0.0000381,0.0000264,0.0000408,0.0000459,0.0000436,0.0000403,0.0000336,0.0000097,0.0000114,0.0000141,0.0000024,0.0000091,0.0000274,0.0000304,0.0000321,0.0000322,0.0000192,0.0000176,0.0000345,0.0000336,0.0000244,0.0000333,0.0000652,0.0001371,0.0002642,0.0004341,0.0006711,0.0010202,0.0012919,0.0015116,0.0016357,0.0016479,0.0015996,0.0014403,0.0012047,0.0008586,0.0005831,0.0003582,0.0001789,0.0000593,0.0000266,0.0000412,0.0000539,0.0000588,0.0000509,0.0000365,0.0000301,0.0000244,0.0000108,0.0000241,0.0000283,0.0000289,0.0000273,0.0000193,0.0000174,0.0000325,0.0000353,0.0000325,0.0000397,0.0000412,0.0000299,0.0000199,0.000018,0.0000131,0.0000411,0.0000654,0.0000796,0.0000894,0.0000932,0.0000862,0.0000772,0.0000779,0.0000685,0.0000491,0.0000284,0.000017,0.000039,0.0000563,0.0000562,0.0000494,0.0000454,0.0000429,0.0000274,0.0000205,0.0000232,0.0000315,0.0000497,0.0000537,0.0000534,0.0000537,0.000053,0.0000345,0.0000098,0.0000313,0.0000407,0.0000444,0.0000446,0.0000346,0.0000215,0.0000238,0.0000215,0.0000156,0.0000138,0.0000106,0.0000197,0.0000442,0.0000605,0.0000775,0.0000924,0.0000913,0.0000794,0.0000673,0.000057,0.0000322,0.0000174,0.0000278,0.0000307,0.000031,0.000031,0.0000204],"detections":[{"range_m":1.640507,"snr_db":13.9003,"width_m":0.171607},{"range_m":3.370651,"snr_db":25.5498,"width_m":0.175182}],"noise_floor":0.000034518,"blast_amplitude":0.2699966,"saturated":false} +{"timestamp":1756162802.2666645,"source":"simulated","device_id":"batvu-reference-01","beam":[0.258819,0.9659258,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000394,0.000043,0.0000309,0.0000135,0.0000092,0.000012,0.0000115,0.0000088,0.0000246,0.0000385,0.0000428,0,0.000001,0.0000068,0,0.000034,0.0000698,0.000061,0.00004,0.0000339,0.0000137,0.0000112,0.0000216,0.0000091,0,0.0000247,0.0000229,0.000021,0.0000173,0,0.0000054,0.0000145,0.0000143,0,0.0000145,0.00002,0.0000066,0.0000071,0.0000389,0.0000598,0.000078,0.0000819,0.0000572,0.0000691,0.0000981,0.0001263,0.0001913,0.0002943,0.0003882,0.0005154,0.0006329,0.000683,0.0006911,0.0006816,0.0006154,0.0004661,0.0003577,0.0002628,0.0001628,0.0000983,0.0000639,0.0000187,0.0000294,0.0000385,0.0000398,0.0000396,0.0000374,0.0000058,0.0000041,0.0000158,0.0000128,0.0000208,0.0000265,0.0000168,0.0000223,0.0000395,0.0000365,0.000026,0.0000187,0.0000176,0.0000242,0.0000272,0.0000176,0,0.0000232,0.0000213,0.0000195,0.0000505,0.0000553,0.0000541,0.0000434,0.0000242,0.0000347,0.0000556,0.0000588,0.0000451,0.0000468,0.0000519,0.0000384,0.0000097,0.0000258,0.0000347,0.000036,0.0000354,0.0000214,0.000036,0.0000682,0.0000775,0.000091,0.0001075,0.0001067,0.0001079,0.0001105,0.0001072,0.000078,0.0000663,0.0000634,0.0000512,0.0000677,0.0000718,0.0000635,0.0000514,0.0000515,0.0000532,0.0000581,0.0000598,0.0000457,0.0000319,0.0000351,0.0000451,0.0000576,0.000063,0.0000534,0.0000371,0.0000598,0.0001108,0.0002009,0.0003442,0.0005346,0.0007944,0.0010935,0.0013541,0.0015428,0.0016226,0.0016151,0.0015021,0.0013026,0.0010569,0.00078,0.0005289,0.0003322,0.0001663,0.0001041,0.0000867,0.0000629,0.000037,0.0000181,0.0000094,0.0000186,0.0000242,0.0000251,0.0000368,0.0000479,0.0000462,0.0000443,0.0000529,0.0000494,0.0000378,0.0000285,0.0000191,0.0000012,0.0000231,0.0000295,0.0000281,0.0000224,0.0000182,0.0000202,0.000027,0.0000288,0.0000177,0.0000124,0.0000157,0.0000091,5e-7,0.0000091,0.0000099,0.0000096,0.0000081,0.0000047,0.0000168,0.00004,0.0000537,0.0000669,0.0000752,0.0000735,0.0000613,0.0000639,0.0000626,0.0000502,0.0000365,0.0000249,0.0000143,0.000026,0.0000279,0.0000253,0.0000384,0.000048,0.0000526,0.0000604,0.0000657,0.0000599,0.0000568,0.0000609,0.0000573,0.0000593,0.0000734,0.0000802,0.00009,0.0000945,0.0000898,0.0000751,0.0000617,0.0000474,0.0000235,0.0000264,0.0000262,0.0000174,0.0000235,0.0000537,0.0000802,0.000102,0.0001094,0.0001075,0.0001057,0.0001056,0.0000928],"detections":[{"range_m":1.629893,"snr_db":16.9165,"width_m":0.171607},{"range_m":3.30911,"snr_db":20.815,"width_m":0.175182}],"noise_floor":0.000040869,"blast_amplitude":0.2699916,"saturated":false} +{"timestamp":1756162802.333331,"source":"simulated","device_id":"batvu-reference-01","beam":[0,1,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000313,0.000023,0.0000081,0,0.0000527,0.0000721,0.0000841,0.0000858,0.0000841,0.00001,0.0000109,0.0000316,0.0000181,0.0000289,0.0000514,0.0000456,0.0000475,0.0000482,0,2e-7,0.0000377,0.0000328,0.0000291,0.0000448,0.0000299,0.0000473,0.0000937,0.0000939,0.0000921,0.000078,0.0000295,0,0.0000317,0.0000277,0.0000331,0.000062,0.0000664,0.0000392,0.000031,0.0000301,0,0.0000194,0.0000379,0.0000339,0.0000168,0.0000206,0.0000348,0.0000379,0.0000354,0.0000265,0.0000977,0.0001994,0.0002905,0.0004165,0.0005346,0.0005874,0.0005984,0.0005926,0.0005343,0.0003968,0.000299,0.0002134,0.0001178,0.0000488,0.0000182,0.0000374,0.0000446,0.0000418,0.0000112,0.0000269,0.0000365,0.0000328,0.0000281,0.0000223,0.0000045,0.0000186,0.0000241,0.0000098,0,0.0000133,0.000011,0.0000138,0.0000309,0.0000346,0.0000437,0.0000569,0.0000547,0.0000578,0.0000663,0.0000582,0.0000332,0.0000301,0.0000286,0.000008,0.000005,0.0000143,0.0000121,0.0000086,0.0000083,0,0.0000104,0.000021,0.0000175,0.0000294,0.0000434,0.0000434,0.0000415,0.0000366,0.0000233,0.0000312,0.0000425,0.0000359,0.0000291,0.0000449,0.0000443,0.0000427,0.0000421,0.0000385,0.0000164,0.0000381,0.000043,0.0000476,0.0000531,0.0000542,0.0000415,0.000066,0.0000733,0.000073,0.0000713,0.0000687,0.000048,0.0000312,0.0000293,0.000023,0.0001326,0.000307,0.0005341,0.0008192,0.0011287,0.0014517,0.0016511,0.0017366,0.0017334,0.0016363,0.0014251,0.0011409,0.0008236,0.0005486,0.0003074,0.0001608,0.0000743,0.0000369,0.000017,0.0000302,0.0000374,0.0000302,0.0000151,0.0000172,0.0000284,0.0000375,0.0000444,0.0000423,0.0000475,0.0000512,0.0000445,0.0000489,0.0000652,0.0000649,0.0000605,0.0000529,0.000036,0.0000137,0.0000193,0.00002,0.0000162,0.0000264,0.0000433,0.0000502,0.0000512,0.0000498,0.0000281,0.0000132,0.0000191,0.0000246,0.000037,0.0000432,0.0000423,0.0000423,0.0000407,0.0000268,0.0000241,0.0000418,0.0000423,0.0000418,0.0000382,0.0000255,0.0000091,0.0000289,0.0000382,0.0000543,0.0000707,0.0000713,0.0000749,0.0000868,0.0000901,0.0000882,0.0000854,0.0000803,0.000061,0.000056,0.00006,0.000057,0.000055,0.0000572,0.0000529,0.0000631,0.00007,0.0000656,0.0000522,0.0000358,0.0000131,0.0000204,0.0000401,0.0000465,0.0000554,0.0000643,0.0000629,0.0000483,0.0000292,0.0000225,0.0000334,0.0000378,0.0000376,0.0000186,0.0000076,0.000031,0.0000466,0.0000637,0.00007,0.0000672],"detections":[{"range_m":1.631952,"snr_db":17.1802,"width_m":0.168032},{"range_m":3.199946,"snr_db":22.4903,"width_m":0.175182}],"noise_floor":0.000040592,"blast_amplitude":0.2699973,"saturated":false} +{"timestamp":1756162802.3999977,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.258819,0.9659258,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000321,0.0000152,0,0,0.0000349,0.0000405,0.0000357,0.0000229,0.0000286,0.0000224,0.0000289,0.0000307,0,0.0000313,0.0000777,0.0000858,0.0000874,0.0000865,0.0000281,0.0000218,0.0000509,0.0000403,0.0000247,0.0000228,0.0000054,0.0000094,0.0000211,0.0000086,1e-7,0.0000447,0.0000513,0.0000558,0.0000559,0.0000396,0.0000135,0.0000487,0.0000538,0.0000268,0.0000112,0.0000038,0,0.0000015,0.000008,0,0.0000327,0.0000612,0.0000743,0.0000929,0.0001121,0.000163,0.0002922,0.0005003,0.0006666,0.0008208,0.0009145,0.0009201,0.0009045,0.0008458,0.000739,0.0005447,0.0003912,0.0002496,0.0001192,0.0000523,0.0000338,0.0000024,0.0000181,0.0000304,0.0000513,0.0000833,0.0000918,0.0000793,0.0000567,0.0000363,0.0000395,0.0000516,0.0000537,0.000029,0.0000052,0.0000206,0.0000169,0.0000153,0.0000352,0.000037,0.0000388,0.0000419,0.0000359,0.0000525,0.0000853,0.0000838,0.0000695,0.0000494,0.0000269,0.0000149,0.000031,0.0000341,0.0000151,0.0000117,0.0000409,0.0000629,0.0000834,0.0000904,0.0000819,0.0000812,0.0000851,0.0000714,0.0000543,0.0000459,0.0000311,0.0000177,0.0000536,0.000068,0.0000762,0.0000768,0.0000641,0.0000324,0.0000227,0.0000216,0.0000223,0.0000482,0.0000506,0.0000495,0.0000449,0.0000383,0.0000111,0.0000169,0.000025,0.0000346,0.0000531,0.0000631,0.0000643,0.0000726,0.0000773,0.0000762,0.0001728,0.0003356,0.0005298,0.000752,0.0009733,0.0011795,0.0012975,0.0013377,0.0013278,0.0012339,0.0010635,0.0008454,0.0006047,0.0004028,0.0002297,0.0001161,0.0000404,0.0000204,0.0000231,0.0000194,0.0000252,0.000053,0.0000767,0.000084,0.0000794,0.0000755,0.0000782,0.0000688,0.0000503,0.0000352,0.0000311,0.0000373,0.0000406,0.0000339,0.0000135,0.0000151,0.000022,0.000025,0.0000251,0.0000192,0.0000045,0.0000291,0.0000371,0.000037,0.0000365,0.0000363,0.0000174,0.0000101,0.0000278,0.0000353,0.0000413,0.000043,0.000033,0.000025,0.0000245,0.0000167,0.0000114,0.0000176,0.0000119,0.0000059,0.0000219,0.0000217,0.0000161,0.0000251,0.0000312,0.0000314,0.0000242,0.0000173,0.0000276,0.0000302,0.0000291,0.0000077,0.0000089,0.0000114,0.000023,0.0000509,0.0000745,0.0000861,0.0000895,0.0000888,0.0000676,0.000054,0.000064,0.0000648,0.0000647,0.0000639,0.0000569,0.0000512,0.0000669,0.0000707,0.0000716,0.0000709,0.000063,0.0000388,0.0000241,0.0000322,0.0000459,0.0000649,0.0000747,0.0000797,0.0000806,0.0000791,0.0000599,0.0000361,0.000027,0.0000122],"detections":[{"range_m":1.609424,"snr_db":17.6228,"width_m":0.171607},{"range_m":3.196794,"snr_db":19.0956,"width_m":0.171607}],"noise_floor":0.000036313,"blast_amplitude":0.2699793,"saturated":false} +{"timestamp":1756162802.4666643,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.5,0.8660254,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.000044,0.0000375,0.0000229,0.0000042,0.0000483,0.0000644,0.0000718,0.0000713,0.0000608,0,0.0000347,0.0000452,0.0000166,0.0000607,0.0000884,0.0000763,0.0000607,0.0000441,0,0.0000102,0.0000349,0.0000255,0.0000327,0.0000494,0.0000325,0.0000329,0.0000661,0.0000621,0.0000571,0.0000494,0.0000225,0.0000278,0.0000438,0.0000349,0.0000102,0.000053,0.0000612,0.0000484,0.0000272,0.0000825,0.0001961,0.0003637,0.0005474,0.0008145,0.0010672,0.0012471,0.0013251,0.0013288,0.0012856,0.0011168,0.0008888,0.0006749,0.0004371,0.0002737,0.0001652,0.0000788,0.0000173,0.000029,0.0000253,0.0000227,0.0000499,0.0000479,0.000035,0.0000199,0.0000071,0.0000054,0.0000093,0.0000105,0.0000146,0.0000155,0.0000145,0.0000015,0.0000139,0.0000186,0,0.0000206,0.0000382,0.0000392,0.0000443,0.0000495,0.000039,0.0000515,0.0000747,0.0000736,0.0000657,0.0000514,0.0000238,0.000018,0.0000407,0.0000379,0.0000246,0.0000271,0.0000259,0.0000085,0.0000091,0.0000225,0.0000247,0.0000262,0.0000266,0.0000058,0.0000131,0.0000269,0.0000295,0.0000352,0.0000398,0.0000377,0.0000498,0.0000583,0.000052,0.000051,0.0000609,0.0000515,0.0000291,0.0000425,0.0000482,0.0000495,0.0000551,0.0000542,0.0000458,0.0000419,0.0000319,0.0000058,0.0000124,0.0000158,0.0000152,0.0000245,0.0000265,0.0000229,0.0000497,0.0000737,0.0000847,0.0000916,0.0000932,0.0000763,0.0000634,0.0000699,0.0000656,0.0000717,0.0001141,0.0002093,0.0003579,0.0005584,0.0007687,0.0009797,0.0011544,0.0012367,0.0012455,0.0012217,0.0010832,0.0008764,0.0006454,0.0004299,0.0002392,0.0001164,0.0000667,0.0000473,0.0000415,0.0000421,0.000036,0.0000531,0.0000733,0.000082,0.0000892,0.0000894,0.0000721,0.0000388,0.0000408,0.0000643,0.0000821,0.00009,0.0000834,0.000063,0.0000499,0.0000425,0.0000234,0.0000254,0.0000361,0.0000432,0.0000516,0.0000535,0.0000537,0.000073,0.000084,0.0000834,0.0000749,0.0000532,0.0000215,0.0000136,0.0000249,0.0000423,0.0000683,0.0000884,0.0000881,0.0000848,0.0000826,0.0000725,0.0000462,0.0000207,0.0000129,0.0000206,0.0000256,0.0000244,0.0000186,0.0000184,0.0000194,0.0000185,0.0000254,0.0000267,0.0000215,0.000033,0.0000426,0.0000506,0.0000635,0.0000726,0.0000747,0.0000835,0.00009,0.0000872,0.0000823,0.0000836,0.0000798,0.0000721,0.0000696,0.0000623,0.0000461,0.0000445,0.000044,0.0000412,0.0000424,0.0000402,0.0000284,0.0000375,0.0000515,0.0000712,0.0000926,0.0000993,0.0000929,0.0000789,0.0000637,0.0000384],"detections":[{"range_m":1.460104,"snr_db":22.5978,"width_m":0.171607},{"range_m":3.279398,"snr_db":16.6055,"width_m":0.171607}],"noise_floor":0.000042944,"blast_amplitude":0.270032,"saturated":false} +{"timestamp":1756162802.533331,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.7071068,0.7071068,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000313,0.0000315,0.0000294,0.0000261,0.0000226,9e-7,0,0,0.0000275,0.0000221,0.0000153,0.000012,0.000018,0.0000337,0.0000466,0.0000325,0.0000501,0.0000613,0.0000356,0.0000647,0.0000958,0.0000853,0.0000676,0.000058,0.0000189,0.0000099,0.0000214,0.000002,0,0.000048,0.0000476,0.0000424,0.0000339,0.0000217,0.0000208,0.00003,0.0000297,0,0.000024,0.0000601,0.0001198,0.0002267,0.0003594,0.0006087,0.0009033,0.0011824,0.0014051,0.0015468,0.0015759,0.0015369,0.0014007,0.0012082,0.0009033,0.000631,0.0004019,0.0002129,0.000078,0.0000293,0.0000257,0.0000194,0.0000594,0.0000816,0.0001059,0.0001231,0.0001217,0.00009,0.0000871,0.0000874,0.0000688,0.000055,0.0000487,0.000021,0.0000082,0.0000144,0.0000322,0.000058,0.0000712,0.0000699,0.000091,0.0001107,0.0001067,0.0000905,0.0000823,0.0000704,0.0000612,0.0000649,0.0000559,0.0000267,0.000018,0.0000289,0.0000411,0.0000561,0.0000556,0.0000384,0.0000278,0.0000355,0.0000444,0.0000523,0.0000535,0.0000329,0.0000342,0.0000418,0.000035,0.00003,0.0000408,0.0000407,0.0000382,0.0000297,0.0000052,0.0000096,0.0000315,0.0000374,0.000044,0.0000476,0.0000403,0.0000262,0.0000401,0.0000403,0.0000432,0.0000705,0.0000817,0.0000912,0.0000959,0.0000959,0.000069,0.0000453,0.000032,0.000009,0.0000283,0.0000444,0.0000446,0.0000404,0.0000294,0.0000327,0.0000709,0.0001266,0.0002096,0.0003522,0.0005446,0.0007824,0.0009686,0.0011013,0.001138,0.0011359,0.0010807,0.0009559,0.0007644,0.0005603,0.0003393,0.0001842,0.0000923,0.0000585,0.000035,0.0000511,0.0000725,0.0000785,0.0000804,0.0000805,0.0000658,0.0000543,0.0000535,0.000045,0.0000388,0.0000407,0.00003,0.0000119,0.0000121,0.0000098,6e-7,0.0000203,0.0000226,0.0000244,0.0000291,0.0000275,0.0000194,0.0000394,0.0000572,0.000066,0.0000688,0.0000681,0.0000454,0.0000411,0.000058,0.0000698,0.0000839,0.0000878,0.0000808,0.0000725,0.0000675,0.0000504,0.0000217,0.0000373,0.0000647,0.0000878,0.0000998,0.0000967,0.0000816,0.0000678,0.0000599,0.0000533,0.000064,0.0000638,0.0000602,0.0000632,0.0000635,0.0000448,0.000022,0.0000201,0.0000348,0.0000496,0.0000569,0.000063,0.0000776,0.0000869,0.0000823,0.0000665,0.000043,0.0000099,0.0000346,0.0000532,0.0000572,0.0000712,0.0000822,0.0000799,0.0000626,0.0000343,0.00002,0.0000367,0.0000404,0.000037,0.0000344,0.0000542,0.0000604,0.0000604,0.0000593,0.000058,0.0000474,0.0000507,0.0000556,0.0000532],"detections":[{"range_m":1.482792,"snr_db":20.0078,"width_m":0.171607},{"range_m":3.216961,"snr_db":16.8942,"width_m":0.171607}],"noise_floor":0.000047459,"blast_amplitude":0.2699607,"saturated":false} +{"timestamp":1756162802.5999975,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.8660254,0.5,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000193,0.0000159,0,0,0.0000648,0.0000622,0.0000294,0,0.0000524,0.0000647,0.0000669,0.0000657,0,0.0000041,0.0000329,0.0000287,0.0000277,0.0000322,0.0000129,0.0000344,0.0000577,0.0000455,0.0000624,0.0000809,0.00006,0.0000431,0.0000707,0.0000645,0.0000576,0.0000551,0.0000285,0,0.0000398,0.0000377,0.0000249,0.000028,0.0000498,0.0000646,0.000073,0.0000708,0.0000151,0.0001279,0.0002777,0.000523,0.0007938,0.0010549,0.001291,0.0014844,0.0015623,0.0015584,0.0014718,0.0012896,0.0009588,0.0006618,0.0004206,0.0002291,0.0000928,0.0000603,0.0000582,0.0000552,0.000064,0.0000576,0.000036,0.0000459,0.0000477,0.0000387,0.0000332,0.00003,0.0000216,0.0000391,0.0000439,0.0000319,0.0000258,0.0000283,0.0000144,0.0000313,0.0000423,0.0000361,0.0000335,0.000038,0.0000253,0.0000058,0.0000426,0.0000595,0.0000795,0.0001001,0.0000985,0.0000993,0.0001113,0.0001058,0.0000845,0.0000677,0.0000544,0.0000265,0.0000283,0.0000312,0.0000327,0.0000598,0.0000691,0.000068,0.0000622,0.0000536,0.0000264,0.0000053,0.0000038,0,0.0000138,0.0000378,0.000044,0.0000449,0.0000424,0.0000381,0.0001105,0.0002237,0.0003659,0.0005728,0.0008284,0.0011058,0.0012841,0.0013755,0.0013778,0.0013191,0.0011794,0.000988,0.0007487,0.0005181,0.0002912,0.0001344,0.0000419,0.0000479,0.0000549,0.0000661,0.0000731,0.0000613,0.000042,0.0000265,0.0000087,0.0000135,0.0000273,0.0000333,0.0000552,0.000081,0.0000818,0.0000794,0.0000708,0.0000559,0.0000262,0.0000404,0.0000448,0.0000469,0.0000519,0.0000536,0.0000523,0.0000673,0.0000745,0.0000687,0.0000583,0.0000465,0.0000255,0.0000377,0.0000459,0.0000389,0.0000335,0.0000648,0.0000758,0.0000761,0.0000679,0.0000396,0.0000245,0.0000544,0.000055,0.000054,0.0000541,0.0000519,0.0000433,0.0000563,0.0000592,0.0000505,0.0000482,0.0000538,0.000058,0.0000598,0.0000599,0.0000456,0.0000286,0.0000208,0.0000355,0.0000472,0.0000516,0.000046,0.0000558,0.0000737,0.0000781,0.0000867,0.0000942,0.0000908,0.0000785,0.0000738,0.0000729,0.0000736,0.0000784,0.0000766,0.0000653,0.0000631,0.0000638,0.0000503,0.0000326,0.0000151,0.0000377,0.0000529,0.000056,0.0000456,0.0000323,0.0000293,0.0000225,0.0000271,0.0000331,0.00003,0.0000241,0.0000229,0.0000188,0.0000104,0.0000102,0.0000071,0.000001,0.0000147,0.0000194,0.0000227,0.0000239,0.0000215,0.0000145,0.0000303,0.0000368,0.0000428,0.0000583,0.0000754,0.0000871,0.0000985,0.0001024,0.0000979],"detections":[{"range_m":1.489718,"snr_db":21.5458,"width_m":0.171607},{"range_m":2.798894,"snr_db":19.9053,"width_m":0.171607}],"noise_floor":0.000042585,"blast_amplitude":0.2699904,"saturated":false} +{"timestamp":1756162802.6666641,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.9659258,0.258819,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000442,0.0000476,0.0000506,0.0000507,0.0000469,0.0000152,0,0.0000046,0.0000102,0.0000034,0.0000133,0.0000201,0.0000161,0.0000542,0.0000792,0.0000718,0.0000748,0.0000758,0.0000213,0.0000192,0.0000541,0.0000465,0.0000284,0.0000244,0.0000203,0.0000275,0.0000315,0.0000085,0,0.0000123,0.0000086,0.000004,0.0000299,0.0000274,0.0000303,0.0000674,0.0001286,0.0002635,0.0004806,0.0006923,0.0009043,0.0010915,0.0011806,0.0011884,0.0011628,0.0010759,0.0008878,0.0006699,0.0004676,0.0002719,0.000128,0.0000673,0.0000497,0.0000396,0.000031,0.0000058,0.0000132,0.0000443,0.0000586,0.00008,0.0000976,0.0000929,0.0000762,0.0000938,0.0000945,0.0000756,0.0000692,0.0000687,0.0000656,0.0000894,0.0000965,0.0000856,0.0000807,0.0000887,0.000081,0.000076,0.0000726,0.0000548,0.0000675,0.0000784,0.0000709,0.0000678,0.000078,0.0000701,0.0000503,0.0000437,0.0000416,0.0000434,0.0000569,0.000055,0.0000542,0.0000569,0.0000538,0.00003,0.0000272,0.0000288,0.0000169,0.0000454,0.0000605,0.0000642,0.0000644,0.0000623,0.0000352,0.0000079,0.0000141,0.0000444,0.0001221,0.0002598,0.0004659,0.000766,0.0011942,0.0015249,0.0017874,0.0019265,0.0019322,0.0018751,0.0016961,0.0014258,0.0010178,0.0006926,0.0004183,0.0001994,0.0000794,0.0000598,0.0000542,0.0000552,0.0000561,0.0000359,0.0000162,0.0000047,0,0.0000116,0.0000383,0.0000467,0.0000517,0.0000539,0.0000443,0.0000372,0.0000426,0.0000373,0.0000228,0.0000382,0.0000378,0.0000323,0.0000203,0.0000078,0.0000166,0.0000176,0.0000128,0.0000197,0.0000325,0.0000344,0.0000212,0.0000174,0.0000212,0.0000148,0.0000305,0.0000441,0.0000497,0.0000564,0.0000607,0.0000541,0.0000535,0.0000621,0.0000608,0.0000646,0.0000714,0.0000686,0.0000734,0.0000943,0.0000944,0.0000925,0.0000861,0.0000723,0.0000427,0.0000272,0.0000287,0.0000361,0.0000558,0.0000609,0.000059,0.0000541,0.0000503,0.0000353,0.0000217,0.0000121,0.0000044,0.000022,0.000035,0.0000345,0.0000293,0.0000215,0.0000096,0.0000197,0.000044,0.0000549,0.0000592,0.0000589,0.0000503,0.0000295,0.0000281,0.0000256,0.0000078,0.0000035,0.0000078,0.0000117,0.0000243,0.0000268,0.0000232,0.0000266,0.0000318,0.0000332,0.000035,0.0000353,0.0000241,0.0000279,0.0000408,0.0000465,0.0000539,0.0000583,0.0000537,0.0000425,0.0000608,0.0000755,0.0000928,0.0001075,0.0001072,0.0000992,0.0000913,0.0000832,0.0000662,0.0000613,0.0000634,0.0000657,0.0000676,0.0000676,0.0000535,0.0000341,0.0000378,0.0000483],"detections":[{"range_m":1.38882,"snr_db":18.2941,"width_m":0.171607},{"range_m":2.690172,"snr_db":23.7513,"width_m":0.171607}],"noise_floor":0.000043777,"blast_amplitude":0.270011,"saturated":false} +{"timestamp":1756162802.7333307,"source":"simulated","device_id":"batvu-reference-01","beam":[-1,0,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000137,0,0,0.0000026,0.000049,0.0000398,0,0,0.0000286,0.0000201,0.0000125,0.0000371,0.0000431,0.0000466,0.0000478,0,0,0.000019,0.0000139,0.0000221,0.0000375,0.0000154,0,0.0000371,0.0000375,0.0000344,0.000026,0,0,0.0000196,0.0000129,0.0000011,0.000029,0.0000411,0.0000508,0.0000586,0.0000584,0.0000265,0.0000468,0.0000492,0.000022,0.0000222,0.0000319,0.0000197,0.0000391,0.0000545,0.0000509,0.0000398,0.0000239,0.0000198,0.0000267,0.0000272,0,0,0.0000225,0.0000218,0.0000251,0.000028,0.0000176,0.0000403,0.0001052,0.0001351,0.0001588,0.0001711,0.0001685,0.0001325,0.0001324,0.0001327,0.0001116,0.0000894,0.0000673,0.0000185,0.0000159,0.0000286,0.0000246,0.0000336,0.0000404,0.0000328,0.0000312,0.0000338,0.0000193,0.0000123,0.0000367,0.0000427,0.0000495,0.0000528,0.000043,0.000017,0.0000371,0.0000389,0.0000571,0.0000853,0.0000868,0.000073,0.0000525,0.0000347,0.0000147,0.0000235,0.0000255,0.0000164,0.0000455,0.0000698,0.0000731,0.0000702,0.000063,0.000182,0.0004017,0.0007093,0.0010877,0.0015303,0.0020434,0.0023468,0.0024921,0.002499,0.0024107,0.0021386,0.0017626,0.0013422,0.0008333,0.0004859,0.0002299,0.0000497,0.0000544,0.0000603,0.0000503,0.0000406,0.0000422,0.0000302,0.0000207,0.0000132,2e-7,0.0000066,0.0000139,0.0000161,0.0000259,0.0000334,0.0000286,0.0000265,0.0000389,0.000038,0.0000339,0.0000317,0.0000247,0.0000118,0.0000207,0.0000239,0.0000373,0.0000654,0.0000693,0.0000696,0.0000684,0.0000612,0.000028,0.0000272,0.0000549,0.0000727,0.0000937,0.0001022,0.0000989,0.0000914,0.0000855,0.0000711,0.0000625,0.0000731,0.0000723,0.0000704,0.0000729,0.0000679,0.0000621,0.0000884,0.0000988,0.0001026,0.0001021,0.0000909,0.0000601,0.000041,0.0000368,0.0000228,0.000026,0.0000279,0.0000223,0.00002,0.0000177,0.0000101,0.0000357,0.0000448,0.0000428,0.0000358,0.0000305,0.0000222,0.0000135,0.0000083,0.0000105,0.0000279,0.0000493,0.0000551,0.0000579,0.000058,0.0000532,0.0000329,0.0000191,0.0000137,0.0000087,0.0000241,0.0000292,0.0000337,0.0000489,0.0000527,0.0000517,0.0000503,0.0000487,0.0000341,0.0000312,0.0000409,0.0000385,0.0000285,0.0000141,0.000011,0.0000189,0.0000263,0.0000263,0.0000352,0.0000428,0.0000399,0.0000262,0.0000173,0.0000368,0.0000508,0.0000598,0.000059,0.0000738,0.0000947,0.0000988,0.0000943,0.000081,0.0000628,0.0000307,0.0000321,0.0000547,0.0000625],"detections":[{"range_m":2.672074,"snr_db":25.7906,"width_m":0.171607}],"noise_floor":0.000034871,"blast_amplitude":0.2699593,"saturated":false} +{"timestamp":1756162802.7999973,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.9659258,-0.258819,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000521,0.0000506,0.0000406,0.0000258,0.0000351,0.0000299,0.0000171,0.0000112,0.0000171,0.0000076,0.000007,0.0000067,0,0,0.000025,0.0000277,0.0000276,0.0000236,0,0.0000105,0.0000206,0.0000059,0.0000283,0.0000497,0.0000325,0.0000204,0.0000546,0.0000519,0.0000498,0.0000652,0.0000631,0.0000836,0.0001015,0.0000922,0.0000498,0.0000645,0.0000722,0.0000678,0.000067,0.0000651,0.0000254,0.000046,0.0000622,0.000065,0.0000659,0.0000649,0.000019,0,0.0000072,0,0.0000023,0.0000187,0.0000092,0,0.0000225,0.000023,0.0000224,0.0000182,0.0000053,0.0000236,0.0000394,0.0000345,0.0000258,0.0000582,0.0000618,0.0000521,0.0000335,0.0000126,0.0000179,0.0000414,0.0000474,0.0000354,0.0000287,0.0000334,0.0000155,0.0000106,0.0000492,0.0000657,0.0000735,0.0000741,0.000055,0.0000322,0.0000453,0.0000407,0.0000281,0.0000397,0.000063,0.0000898,0.0001094,0.0001043,0.000086,0.0000959,0.0000957,0.0000826,0.0000764,0.0000756,0.0000429,0.0000281,0.00003,0.0000168,0.0000165,0.0000356,0.0000463,0.0000518,0.0000525,0.0000312,0.0000152,0.0000289,0.0000298,0.0000464,0.0000936,0.000171,0.0003425,0.000619,0.0009665,0.0013803,0.0018058,0.0022034,0.0023967,0.0024329,0.0023985,0.002178,0.0018246,0.001411,0.0009878,0.0006421,0.0003656,0.0002099,0.0001224,0.0000899,0.0000743,0.000067,0.000066,0.0000506,0.0000351,0.0000271,0.0000173,0.0000212,0.000036,0.000036,0.0000402,0.0000461,0.0000416,0.0000279,0.0000223,0.0000257,0.0000367,0.0000538,0.0000538,0.0000547,0.0000779,0.0000952,0.0001053,0.0001076,0.0001058,0.0000715,0.0000424,0.0000255,0.0000014,0.0000138,0.0000261,0.0000243,0.0000222,0.0000328,0.0000337,0.0000336,0.0000325,0.0000261,0.0000174,0.0000223,0.0000237,0.000038,0.000054,0.0000538,0.0000468,0.0000442,0.0000442,0.0000194,0.0000291,0.0000464,0.0000542,0.0000567,0.0000565,0.0000391,0.0000199,0.0000226,0.0000296,0.0000368,0.0000394,0.0000298,0.0000164,0.000041,0.0000478,0.0000492,0.0000488,0.0000404,0.0000265,0.0000322,0.0000316,0.0000287,0.0000331,0.0000314,0.0000212,0.0000294,0.0000322,0.0000298,0.0000281,0.000028,0.0000133,0.0000042,0.0000052,0.0000185,0.0000431,0.000061,0.0000606,0.000052,0.0000378,0.0000376,0.0000546,0.0000656,0.0000639,0.0000559,0.0000497,0.0000405,0.000021,0.0000061,0.0000014,0.0000014,0.0000374,0.0000605,0.000081,0.0000953,0.000097,0.000087,0.0000759,0.0000682,0.0000471,0.0000339,0.000035,0.0000294],"detections":[{"range_m":2.789088,"snr_db":24.1004,"width_m":0.171607}],"noise_floor":0.000036772,"blast_amplitude":0.2699547,"saturated":false} +{"timestamp":1756162802.866664,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.8660254,-0.5,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000269,0.0000276,0.0000106,0.0000101,0.0000381,0.0000332,0.0000387,0.0000411,5e-7,0.0000205,0.0000491,0.00005,0.000052,0.0000533,0.0000035,0.0000151,0.0000511,0.0000456,0.0000325,0.0000319,0.0000203,0.0000305,0.0000423,0.0000251,0.0000065,0.0000345,0.0000277,0.0000172,0.0000106,0,0.0000041,0.0000226,0.0000266,0.0000059,0.0000113,0.0000241,0.0000279,0.00003,0.0000308,0.0000078,0.000028,0.0000364,0.0000217,0.0000106,0.0000196,0.000014,0.0000173,0.000044,0.0000556,0.0000676,0.0000726,0.0000551,0.0000304,0.0000465,0.0000424,0.0000221,0.0000345,0.0000324,0.0000264,0.0000219,0.0000129,0.0000207,0.0000522,0.0000551,0.0000446,0.0000342,0.0000294,0.0000083,0.0000236,0.0000392,0.0000376,0.0000347,0.0000292,0.0000071,0.0000157,0.0000203,0.0000062,0,0.0000064,0.0000026,0.0000028,0.0000212,0.0000194,0.0000097,0.0000326,0.0000407,0.0000455,0.0000461,0.0000415,0.0000126,0.0000365,0.00005,0.0000513,0.0000512,0.000049,0.0000212,0.0000383,0.0000661,0.0000859,0.0000986,0.0001013,0.0000824,0.0000625,0.0000607,0.000048,0.0000232,0.0000191,0.000024,0.0000265,0.0000317,0.0000285,0.0000278,0.0000314,0.0000286,0.0000173,0.0000386,0.0000396,0.000032,0.0000217,0.0000157,0.0000257,0.0000529,0.0000824,0.0001751,0.0003669,0.0006408,0.000978,0.0013535,0.0017582,0.0019858,0.0020814,0.002082,0.0019768,0.0017232,0.0013875,0.0010258,0.000634,0.0004049,0.0002567,0.0001615,0.0001141,0.0000889,0.0000571,0.0000415,0.0000347,0.0000102,0.0000241,0.0000415,0.0000467,0.000047,0.0000452,0.0000231,0.0000348,0.0000474,0.0000481,0.0000472,0.0000411,0.0000194,0.0000171,0.0000249,0.0000198,0.0000133,0.0000211,0.0000188,0.0000168,0.0000319,0.0000314,0.0000325,0.000044,0.0000443,0.0000471,0.0000558,0.0000579,0.0000581,0.0000758,0.000086,0.0000952,0.0001074,0.0001121,0.000106,0.0000979,0.0000952,0.0000858,0.0000877,0.0000919,0.0000847,0.0000697,0.0000584,0.0000411,0.0000224,0.0000248,0.0000255,0.0000255,0.0000237,0.0000189,0.0000061,0.0000204,0.0000223,0.0000231,0.0000233,0.0000234,0.0000095,0.0000091,0.000011,0.0000078,0.0000141,0.000019,0.0000285,0.0000514,0.0000738,0.0000773,0.0000773,0.0000749,0.000062,0.0000494,0.0000548,0.0000527,0.0000475,0.0000574,0.0000616,0.0000684,0.0000736,0.0000715,0.000055,0.0000372,0.0000216,0.0000264,0.0000439,0.0000551,0.0000624,0.0000644,0.0000634,0.0000452,0.0000246,0.0000215,0.0000137],"detections":[{"range_m":3.073217,"snr_db":23.6053,"width_m":0.171607}],"noise_floor":0.000032506,"blast_amplitude":0.2699894,"saturated":false} +{"timestamp":1756162802.9333305,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.7071068,-0.7071068,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000567,0.0000616,0.000057,0.0000494,0.0000557,0.0000219,0.0000145,0.0000151,0,0.0000108,0.0000275,0.0000077,0,0.0000185,0.0000193,0.0000244,0.0000383,0.0000292,0.0000551,0.00008,0.0000657,0.0000531,0.0000759,0.000068,0.0000597,0.0000563,0.0000259,0,0.0000123,0.0000108,0.0000034,0.0000178,0.0000246,0.0000188,0.000014,0.0000077,0.0000104,0.0000217,0.0000272,0.0000189,0.0000333,0.0000373,0.0000137,0.0000188,0.0000359,0.0000259,0.0000055,0.0000477,0.0000609,0.0000654,0.0000653,0.0000396,0.0000034,0.0000135,0.0000089,0.0000068,0.000062,0.0000757,0.0000806,0.0000806,0.0000738,0.0000356,0.0000637,0.0000718,0.0000757,0.0000771,0.0000767,0.0000404,0.0000151,0.0000187,0.0000049,0.0000178,0.0000481,0.0000531,0.0000528,0.0000452,0.0000134,0,0.0000042,0,0.0000087,0.0000313,0.0000333,0.0000413,0.000052,0.0000459,0.0000278,0.0000308,0.0000303,0.0000189,0.0000191,0.0000208,0.0000217,0.0000509,0.0000599,0.0000567,0.0000461,0.000033,0.0000059,0.000001,0.0000128,0.0000135,0.0000152,0.0000211,0.0000196,0.0000323,0.0000619,0.0000732,0.0000803,0.0000807,0.0000676,0.0000381,0.0000595,0.000067,0.0000696,0.0000737,0.0000717,0.0000596,0.000064,0.0000651,0.0000483,0.0000532,0.0000569,0.0000517,0.0000453,0.0000369,0.0000112,0.0000152,0.0000235,0.0000225,0.0000434,0.0000564,0.0000506,0.0000421,0.0001785,0.0004188,0.0007006,0.0010328,0.0013598,0.0016563,0.0018673,0.0019242,0.0019163,0.0017967,0.0014947,0.0011528,0.0008165,0.0005336,0.0003035,0.0001506,0.0000631,0.0000099,0.0000253,0.0000294,0.0000199,0.0000203,0.0000292,0.0000261,0.0000147,0.0000258,0.0000418,0.0000557,0.0000648,0.000062,0.0000562,0.0000613,0.0000569,0.0000474,0.0000569,0.0000564,0.0000499,0.0000515,0.0000525,0.0000369,0.000035,0.000042,0.0000497,0.0000593,0.0000631,0.0000554,0.0000439,0.0000305,0.000011,0.0000274,0.0000429,0.000044,0.0000436,0.0000398,0.0000237,0.0000273,0.0000432,0.0000432,0.0000461,0.0000578,0.0000583,0.0000577,0.0000541,0.0000434,0.00002,0.0000217,0.000032,0.0000445,0.0000602,0.0000623,0.0000545,0.0000511,0.0000534,0.0000472,0.0000411,0.0000377,0.0000242,0.0000253,0.0000339,0.0000328,0.0000278,0.0000199,0.000004,0.0000167,0.0000262,0.0000245,0.0000181,0.0000197,0.0000194,0.000021,0.0000224,0.0000194,0.0000022,0.0000187,0.0000299,0.000036,0.0000392,0.0000391,0.0000231,0.0000118,0.0000278,0.0000309],"detections":[{"range_m":3.268919,"snr_db":23.9412,"width_m":0.171607}],"noise_floor":0.000030864,"blast_amplitude":0.2699768,"saturated":false} +{"timestamp":1756162802.9999971,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.5,-0.8660254,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000718,0.0000751,0.0000613,0.00004,0.0000203,0.0000108,0.0000191,0.0000198,0,0.0000085,0.000025,0.0000094,0.0000155,0.0000259,0.0000083,0.0000046,0.0000098,0.000001,0.0000185,0.0000425,0.0000323,0.0000278,0.0000365,0.0000176,0,0.0000622,0.0000792,0.0000878,0.0000884,0.0000708,0.0000161,0.0000282,0.000033,0.0000137,0.0000277,0.0000319,0.0000213,0.000024,0.0000249,0,0.0000032,0.0000111,0,0.0000232,0.0000451,0.0000393,0.0000267,0.000026,0.0000178,0.000011,0.0000328,0.0000397,0.000043,0.0000427,0.0000251,0.0000109,0.0000424,0.0000407,0.0000303,0.0000323,0.0000366,0.0000456,0.0000505,0.000048,0.0000306,0.0000527,0.0000587,0.0000459,0.0000337,0.0000292,0.0000011,0.0000099,0.0000285,0.0000283,0.0000265,0.0000275,0.0000157,0.0000084,0.0000256,0.0000276,0.000029,0.0000291,0.0000197,0.0000096,0.000026,0.0000237,0.0000167,0.00003,0.0000328,0.0000455,0.0000612,0.0000636,0.0000338,0.0000105,0.0000383,0.0000501,0.0000572,0.00006,0.0000468,0.0000394,0.0000409,0.0000249,0.0000178,0.0000253,0.0000183,0.0000012,0.0000418,0.0000619,0.000078,0.0000863,0.0000801,0.0000623,0.0000645,0.0000629,0.0000417,0.000026,0.00001,0.0000071,0.0000224,0.0000268,0.0000239,0.000024,0.0000237,0,0.0000285,0.0000499,0.0000554,0.0000567,0.0000564,0.0000371,0.0000307,0.0000427,0.0000419,0.0000445,0.0000532,0.0000645,0.0001044,0.0001783,0.000289,0.0004815,0.0007574,0.0010664,0.0013849,0.0017021,0.0018287,0.0018421,0.0018043,0.0016414,0.0013547,0.0010162,0.0006855,0.0003444,0.0001603,0.0000833,0.0000612,0.0000512,0.0000541,0.0000505,0.0000643,0.000074,0.0000716,0.0000649,0.0000602,0.000051,0.0000345,0.0000382,0.0000363,0.0000399,0.000059,0.0000626,0.000064,0.0000646,0.0000645,0.0000439,0.0000471,0.0000526,0.0000536,0.0000562,0.0000589,0.0000542,0.0000533,0.0000519,0.000033,0.0000244,0.0000431,0.0000497,0.0000506,0.0000482,0.0000241,0.0000127,0.0000301,0.0000301,0.000033,0.000043,0.0000428,0.0000435,0.0000506,0.0000491,0.0000359,0.0000246,0.000023,0.0000159,0.0000118,0.0000102,0.000015,0.0000361,0.0000527,0.0000595,0.0000633,0.0000647,0.0000554,0.0000609,0.0000693,0.0000674,0.0000576,0.0000408,0.0000164,0.0000301,0.0000405,0.0000392,0.0000404,0.0000461,0.000044,0.0000249,0.0000297,0.0000625,0.0000951,0.0001236,0.0001313,0.0001312,0.0001273,0.0001201,0.0000982,0.0000715,0.0000561,0.000033],"detections":[{"range_m":3.336489,"snr_db":21.5684,"width_m":0.171607}],"noise_floor":0.000037665,"blast_amplitude":0.2699642,"saturated":false} +{"timestamp":1756162803.0666637,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.258819,-0.9659258,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000311,0.0000106,0,0,0.0000399,0.0000367,0.0000152,0.0000278,0.0000582,0.0000519,0.0000496,0.00005,0.0000047,0.0000368,0.000055,0.000022,0.0000124,0.00005,0.0000561,0.0000577,0.0000576,0.0000129,0.0000191,0.0000633,0.0000627,0.000055,0.0000425,0.0000177,0.0000185,0.0000266,0.0000177,0.0000406,0.0000713,0.0000657,0.0000416,0.0000458,0.0000495,0.0000292,0.0000222,0.0000194,0,0.0000397,0.0000731,0.0000827,0.0000827,0.0000753,0.0000176,6e-7,0.000017,0.0000063,0,0.0000108,0.0000069,3e-7,0.000006,0.0000038,0.000008,0.0000484,0.0000609,0.0000681,0.0000704,0.0000587,0.0000262,0.0000426,0.0000449,0.000034,0.0000255,0.0000213,5e-7,0.0000358,0.0000542,0.0000557,0.0000551,0.0000502,0.0000139,0.0000065,0.0000196,0.0000204,0.0000228,0.0000246,0.0000118,0.0000165,0.00003,0.0000258,0.0000188,0.0000256,0.0000241,0.0000332,0.0000469,0.0000428,0.0000306,0.0000403,0.0000393,0.0000208,0.000007,0.0000074,0.0000043,0.0000287,0.0000452,0.0000481,0.0000475,0.0000401,0.0000083,0.0000045,0.00001,0.0000089,0.0000177,0.000026,0.0000227,0.0000281,0.0000347,0.0000245,0.000013,0.0000242,0.0000211,0.0000055,0.0000282,0.0000441,0.0000482,0.0000482,0.0000427,0.0000283,0.0000433,0.0000465,0.0000399,0.0000518,0.0000557,0.0000646,0.0000868,0.0000964,0.0000913,0.0000786,0.000069,0.0000524,0.0000591,0.0000736,0.0000719,0.0000601,0.0001255,0.0003104,0.0005503,0.0008524,0.0011736,0.0014945,0.001754,0.0018676,0.0018718,0.0017998,0.0015337,0.0012066,0.0008771,0.0005946,0.0003574,0.0001899,0.0000875,0.0000205,0.0000117,0.0000189,0.000041,0.000063,0.0000737,0.0000711,0.0000776,0.0000883,0.0000869,0.0000819,0.0000739,0.0000558,0.0000305,0.0000334,0.0000312,0.0000223,0.0000223,0.0000278,0.0000383,0.000049,0.0000508,0.000041,0.0000478,0.0000501,0.0000555,0.0000808,0.0000991,0.0001007,0.0000981,0.0000815,0.0000496,0.0000278,0.0000269,0.0000186,0.0000059,0.0000055,0.0000099,0.0000246,0.000041,0.000041,0.000044,0.0000618,0.0000733,0.0000893,0.0000997,0.0000984,0.0000853,0.0000725,0.0000627,0.0000404,0.0000258,0.000021,0.0000097,0.0000058,0.0000227,0.0000353,0.0000455,0.0000491,0.0000385,0.0000253,0.0000186,0.000017,0.0000318,0.0000487,0.0000504,0.000057,0.0000726,0.0000828,0.0000928,0.0000959,0.0000928,0.000073,0.0000588,0.0000522,0.0000408,0.000036,0.0000354,0.0000216,0.0000216,0.0000267,0.0000243,0.0000228,0.000025,0.0000211],"detections":[{"range_m":3.27542,"snr_db":20.1394,"width_m":0.171607}],"noise_floor":0.000036342,"blast_amplitude":0.2700173,"saturated":false} +{"timestamp":1756162803.1333303,"source":"simulated","device_id":"batvu-reference-01","beam":[0,-1,0],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000059,9e-7,0,0.0000136,0.0000502,0.0000422,0.0000333,0.0000572,0.0000686,0.0000242,0.0000415,0.000068,0.00007,0.0000729,0.0000745,0.0000113,0.0000088,0.0000229,0,0.0000043,0.0000549,0.0000608,0.0000678,0.0000778,0.0000543,0.0000534,0.0000776,0.0000666,0.0000493,0.0000498,0.0000331,0.0000236,0.0000424,0.0000355,0.0000217,0.0000672,0.0000764,0.0000732,0.0000727,0.0000723,0.000042,0.0000553,0.0000645,0.0000503,0.0000381,0.0000336,0,0,0.000028,0.0000298,0.0000346,0.0000433,0.000031,0.0000232,0.0000277,0.0000155,0,0.0000293,0.0000331,0.0000337,0.0000356,0.0000273,0.0000126,0.0000266,0.0000277,0.0000175,0.0000438,0.0000505,0.0000516,0.0000506,0.0000444,0.0000102,0.0000183,0.0000273,0.0000192,0.0000187,0.0000201,0,0.0000076,0.0000413,0.0000519,0.000056,0.0000568,0.0000417,0.0000417,0.0000631,0.0000616,0.0000549,0.0000513,0.0000371,0.0000066,0.0000097,0.00001,0.0000056,0.0000406,0.0000609,0.000063,0.0000624,0.0000581,0.00003,0.0000398,0.0000562,0.0000601,0.0000605,0.0000587,0.0000355,0.0000346,0.0000443,0.0000374,0.0000204,0.0000239,0.0000387,0.0000576,0.0000732,0.0000713,0.0000693,0.0000824,0.0000814,0.0000568,0.0000333,0.0000486,0.0000687,0.0000822,0.0000841,0.0000711,0.0000823,0.0000852,0.0000771,0.0000871,0.0000955,0.0000908,0.0000723,0.0000692,0.0002192,0.0004484,0.0007454,0.0010862,0.0014539,0.0017828,0.0020088,0.0020403,0.0020124,0.0018334,0.0015236,0.0011676,0.0008224,0.0005128,0.000285,0.0001198,0.0000481,0.0000488,0.0000505,0.0000382,0.0000488,0.0000621,0.0000633,0.0000628,0.000058,0.0000336,0.0000127,0.0000108,0.0000042,0,0.0000112,0.0000117,0.0000138,0.000019,0.0000166,0.0000146,0.0000153,0.000009,0.0000209,0.0000455,0.0000479,0.0000467,0.0000395,0.0000278,0.0000167,0.0000316,0.0000344,0.0000298,0.0000287,0.0000305,0.0000241,0.0000314,0.0000349,0.0000296,0.0000383,0.0000502,0.0000549,0.0000618,0.0000651,0.0000552,0.0000372,0.0000269,0.0000255,0.0000427,0.0000659,0.000068,0.0000671,0.0000674,0.0000661,0.0000577,0.0000491,0.0000345,0.000009,0.0000385,0.0000432,0.0000405,0.0000353,0.0000349,0.0000269,0.0000257,0.0000307,0.0000278,0.0000286,0.0000289,0.0000184,0.0000053,0.0000315,0.0000453,0.0000629,0.0000743,0.0000736,0.0000703,0.0000745,0.0000729,0.0000615,0.0000636,0.0000687,0.0000727,0.0000727,0.000068,0.000044,0.0000256,0.0000181,0.0000213,0.0000367,0.0000425,0.0000403],"detections":[{"range_m":3.175081,"snr_db":21.31,"width_m":0.175182}],"noise_floor":0.00003978,"blast_amplitude":0.2699786,"saturated":false} +{"timestamp":1756162803.199997,"source":"simulated","device_id":"batvu-reference-01","beam":[0,-0.9063078,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000729,0.0000701,0.0000532,0.0000334,0.000054,0.0000494,0.000031,0.0000151,0.000051,0.0000761,0.0000974,0.0001005,0.0000468,0.0000663,0.0000916,0.0000744,0.0000597,0.0000547,0.0000124,0.0000406,0.0000815,0.000081,0.0000796,0.0000751,0.0000282,0,0.0000122,0.0000071,0.0000021,0.0000055,0,0,0.0000071,0,0,0.0000079,0.0000162,0.0000095,0.0000428,0.0000507,0.0000522,0.0000569,0.0000588,0.0000323,0.0000308,0.0000378,0.0000278,0.0000405,0.0000502,0.0000356,0.0000182,0.0000081,0.0000013,0.0000136,0.0000328,0.0000281,0.0000172,0.0000055,0.0000139,0.0000162,0.000016,0.000006,0.0000105,0.0000153,0.000012,0.0000099,0.0000449,0.0000508,0.0000554,0.0000607,0.0000613,0.0000298,0.0000079,0.0000143,0.0000045,8e-7,0.0000123,0.0000094,0.0000108,0.0000234,0.0000204,0.000021,0.0000314,0.0000275,0.0000224,0.0000212,0.000014,0.0000066,0.0000124,0.0000049,0,0.0000286,0.0000316,0.0000289,0.0000199,0.0000132,0.0000333,0.0000513,0.0000542,0.000039,0.0000357,0.0000395,0.0000237,0.0000263,0.0000515,0.0000536,0.0000523,0.0000544,0.0000896,0.0001913,0.0004234,0.0007117,0.0011041,0.00156,0.0019938,0.0023589,0.0025824,0.0026108,0.0025335,0.0022765,0.0018869,0.0014166,0.0009683,0.0005962,0.0002977,0.0001022,0.0000431,0.0000476,0.0000445,0.0000318,0.0000079,0.0000217,0.0000338,0.0000344,0.0000418,0.0000479,0.0000416,0.0000307,0.0000255,0.0000252,0.0000442,0.0000642,0.0000637,0.000056,0.0000417,0.000021,0.000017,0.0000394,0.0000396,0.0000333,0.0000328,0.000035,0.0000414,0.0000544,0.0000596,0.0000513,0.0000441,0.0000397,0.0000198,0.0000017,0.0000122,0.0000157,0.0000417,0.0000651,0.0000683,0.0000676,0.0000601,0.00004,0.0000121,0.0000196,0.0000279,0.0000531,0.0000919,0.0001097,0.0001146,0.0001133,0.0000982,0.0000442,0.0000244,0.000042,0.0000558,0.0000736,0.000082,0.0000813,0.0000797,0.0000798,0.0000691,0.0000746,0.0000846,0.0000832,0.0000771,0.0000692,0.0000516,0.0000353,0.0000265,0.0000116,0.0000179,0.0000462,0.0000542,0.0000581,0.0000618,0.0000603,0.0000548,0.0000638,0.0000656,0.0000562,0.0000429,0.0000301,0.0000182,0.0000358,0.0000587,0.0000785,0.0000953,0.000102,0.0000952,0.000088,0.000089,0.0000804,0.0000612,0.0000377,0.0000091,0.0000347,0.0000455,0.0000434,0.0000358,0.0000432,0.0000428,0.0000373,0.0000316,0.0000229,0.0000081,0.0000061,0.0000063,0.0000014,0.0000146,0.0000271,0.0000425,0.0000668,0.0000811,0.000082],"detections":[{"range_m":2.747782,"snr_db":26.9263,"width_m":0.171607}],"noise_floor":0.000035007,"blast_amplitude":0.2700119,"saturated":false} +{"timestamp":1756162803.2666636,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.2345697,-0.8754261,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000636,0.0000623,0.0000491,0.0000614,0.0000684,0.0000168,0.0000251,0.0000502,0.0000446,0.0000354,0.0000271,0.0000015,0.0000256,0.0000426,0.0000229,0.0000022,0.0000354,0.0000527,0.0000671,0.0000811,0.0000615,0.0000537,0.0000556,0.0000292,0,0.0000455,0.0000472,0.0000539,0.0000644,0.0000555,0.0000278,0.0000545,0.0000594,0.0000363,0.0000487,0.0000575,0.0000702,0.0000849,0.0000876,0.0000579,0.0000675,0.0000769,0.0000585,0.0000339,0.0000178,0.0000216,0.0000256,0.0000279,0.0000076,0.0000056,0.0000214,0.0000209,0.0000315,0.0000469,0.0000427,0.0000326,0.0000484,0.0000447,0.0000275,0.0000118,0.0000212,0.0000438,0.0000639,0.0000641,0.0000462,0.0000758,0.000095,0.0001004,0.0001007,0.0000975,0.0000578,0.0000521,0.0000595,0.0000473,0.0000275,0.0000296,0.0000469,0.0000705,0.0000875,0.0000829,0.0000704,0.0000588,0.000037,0,0.0000524,0.0000685,0.0000781,0.0000789,0.0000708,0.000029,0.0000382,0.0000488,0.0000484,0.0000553,0.0000583,0.000058,0.0000705,0.0000764,0.0000683,0.000071,0.0000742,0.0000581,0.0000371,0.0000249,0.0000077,0.0000147,0.0000285,0.0000424,0.0001715,0.0004108,0.000734,0.0011494,0.0016125,0.002105,0.0024068,0.0025434,0.0025433,0.00241,0.0021005,0.0016802,0.0012073,0.0007874,0.0004201,0.0002052,0.0000764,0.0000128,0.0000188,0.0000267,0.0000364,0.0000357,0.000041,0.0000462,0.0000396,0.0000292,0.0000301,0.0000386,0.0000539,0.000065,0.0000618,0.0000527,0.0000633,0.0000629,0.0000552,0.0000602,0.000059,0.0000494,0.0000462,0.0000465,0.0000293,0.0000278,0.0000329,0.0000288,0.0000468,0.000063,0.0000701,0.0000812,0.000092,0.0000914,0.0000914,0.0000899,0.0000741,0.0000549,0.0000572,0.0000557,0.0000609,0.0000684,0.0000623,0.0000393,0.000029,0.0000365,0.0000384,0.0000382,0.0000375,0.0000235,0.0000175,0.0000125,0.0000214,0.0000354,0.0000404,0.0000356,0.0000418,0.0000464,0.0000417,0.0000365,0.0000386,0.0000313,0.0000195,0.0000225,0.0000262,0.0000336,0.0000421,0.0000403,0.000034,0.0000291,0.0000238,0.0000137,0.000027,0.0000274,0.0000264,0.0000358,0.0000421,0.000052,0.000069,0.0000714,0.0000624,0.0000527,0.0000599,0.0000606,0.0000583,0.0000475,0.0000255,0.0000349,0.0000391,0.0000325,0.0000219,0.0000202,0.0000159,0.0000336,0.0000625,0.0000735,0.0000755,0.000074,0.0000632,0.0000398,0.0000282,0.000026,0.0000312,0.0000535,0.0000659,0.0000726,0.00008,0.0000836,0.0000805,0.0000797,0.0000773,0.0000585],"detections":[{"range_m":2.797511,"snr_db":25.2471,"width_m":0.171607}],"noise_floor":0.000042446,"blast_amplitude":0.2700145,"saturated":false} +{"timestamp":1756162803.3333302,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.4531539,-0.7848856,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000076,0.0000011,0,0,0.0000305,0.0000308,0.0000254,0.0000237,0.0000216,0,0.0000127,0.0000293,0.0000269,0.0000351,0.000041,0.0000033,0.0000134,0.0000185,0,0,0.0000167,0.0000086,0,0.0000307,0.0000337,0.0000392,0.0000508,0.0000349,0.0000151,0.0000162,0,0.0000043,0.00006,0.0000606,0.0000592,0.0000532,0.0000423,0.0000031,0.0000272,0.0000304,0.0000216,0.0000642,0.0000822,0.0000767,0.000058,0.000038,0.0000032,0.0000233,0.0000338,0.0000213,0.00004,0.0000683,0.0000624,0.0000483,0.0000338,0.0000122,0.0000128,0.0000257,0.0000218,0.0000066,0.0000096,0.0000183,0.0000275,0.0000378,0.0000364,0.0000139,0.0000658,0.000083,0.0000904,0.0000931,0.0000933,0.0000631,0.0000694,0.0000845,0.0000783,0.0000654,0.0000503,0.0000245,0.0000311,0.0000372,0.0000258,0.0000121,0.0000224,0.0000184,0.0000136,0.0000289,0.0000278,0.0000228,0.0000132,0.0000206,0.0000359,0.0000474,0.000046,0.0000342,0.0000501,0.0000538,0.0000409,0.0000571,0.0000727,0.0000791,0.0000797,0.000077,0.0000519,0.0000558,0.0000623,0.0000521,0.0000381,0.0000394,0.0000325,0.0000221,0.0000394,0.0000517,0.0000665,0.0000982,0.0001754,0.0003502,0.000625,0.0010354,0.0014368,0.0018381,0.0021464,0.0023293,0.002363,0.002333,0.002122,0.0017783,0.0012874,0.0008679,0.0005213,0.0002743,0.000102,0.0000859,0.0001372,0.0001452,0.0001447,0.0001335,0.0000985,0.0000566,0.0000342,0.0000212,0.0000099,0.0000318,0.0000328,0.0000318,0.0000269,0.0000262,0.0000371,0.0000471,0.0000454,0.0000457,0.000063,0.0000661,0.0000576,0.0000485,0.0000472,0.0000311,0.0000224,0.000027,0.0000271,0.0000346,0.0000417,0.0000388,0.0000372,0.0000391,0.0000297,0.0000147,0.0000087,0.0000173,0.0000322,0.0000508,0.0000508,0.0000599,0.0000758,0.000076,0.0000691,0.0000669,0.0000687,0.0000662,0.0000733,0.0000752,0.0000679,0.0000612,0.0000545,0.0000343,0.0000245,0.0000341,0.0000415,0.0000509,0.0000559,0.0000498,0.0000386,0.0000303,0.0000096,0.0000252,0.0000563,0.0000683,0.0000751,0.0000771,0.0000744,0.0000596,0.0000759,0.0000787,0.0000789,0.0000783,0.0000752,0.0000528,0.0000315,0.0000291,0.0000557,0.0000925,0.0001209,0.0001341,0.0001365,0.0001338,0.0000999,0.0000603,0.0000374,0.0000468,0.0000531,0.0000533,0.0000429,0.000016,0.0000263,0.0000392,0.0000507,0.0000631,0.0000631,0.0000594,0.0000564,0.0000504,0.0000318,0.0000174,0.0000066,0.0000253,0.0000573,0.0000812,0.0000905,0.0000906,0.0000858,0.0000656],"detections":[{"range_m":2.842646,"snr_db":21.2006,"width_m":0.171607}],"noise_floor":0.000039849,"blast_amplitude":0.2699783,"saturated":false} +{"timestamp":1756162803.3999968,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.6408564,-0.6408564,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000574,0.0000522,0.0000356,0.0000197,0.0000074,0.0000052,0.0000096,0.0000269,0.0000456,0.0000289,0.0000396,0.0000523,0.0000352,0.0000234,0.0000173,0.0000287,0.0000473,0.0000577,0.0000309,0.0000574,0.0000874,0.0000764,0.0000573,0.0000446,0.0000056,0.0000112,0.0000271,0.0000131,0,0.0000406,0.0000506,0.0000596,0.0000636,0.0000505,0.0000052,0.0000416,0.0000496,0.0000413,0.0000346,0.0000266,0,0,0.0000073,4e-7,0.0000026,0.0000131,0.0000156,0.0000258,0.0000333,0.0000186,0.000026,0.0000701,0.0000854,0.0000988,0.0001047,0.0000886,0.0000803,0.0001127,0.0001114,0.0001,0.0000882,0.000066,0.0000316,0.0000672,0.0000778,0.0000817,0.0000815,0.0000715,0.000025,0.0000107,0.0000145,0.0000019,0.0000142,0.0000342,0.0000388,0.0000468,0.0000509,0.0000375,0.0000537,0.0000771,0.0000762,0.0000695,0.0000612,0.0000373,0.0000132,0.0000162,0.0000128,0.0000062,0.0000176,0.0000194,0.0000304,0.0000453,0.0000449,0.0000385,0.0000603,0.0000671,0.0000587,0.0000469,0.0000342,0,0.0000136,0.0000316,0.0000374,0.0000393,0.0000391,0.0000158,0.0000135,0.0000291,0.0000337,0.0000547,0.0000774,0.000073,0.0000876,0.0002546,0.0005144,0.0008796,0.0013194,0.0018325,0.0021931,0.0024181,0.0024504,0.0024101,0.0022015,0.0018613,0.0014359,0.001026,0.0006275,0.0003652,0.0001861,0.0000823,0.0000294,0.000031,0.0000436,0.0000556,0.0000669,0.0000698,0.000057,0.0000355,0.0000297,0.0000246,0.0000107,0.0000095,0.000006,0.0000155,0.0000348,0.0000356,0.0000302,0.0000245,0.0000227,0.0000121,0.000005,0.0000112,0.0000208,0.0000316,0.0000364,0.0000337,0.0000497,0.0000615,0.0000628,0.0000627,0.0000601,0.0000432,0.0000232,0.0000195,0.0000122,0.0000018,0.0000075,0.0000107,0.0000179,0.0000373,0.0000441,0.0000536,0.0000605,0.0000583,0.0000393,0.0000299,0.0000438,0.0000584,0.000072,0.000074,0.0000611,0.0000439,0.0000302,0.0000117,0.000014,0.0000247,0.0000262,0.0000261,0.0000238,0.0000139,0.0000166,0.0000243,0.0000211,0.0000331,0.0000569,0.0000683,0.0000775,0.0000838,0.0000822,0.0000783,0.0000923,0.0000925,0.0000866,0.0000752,0.0000584,0.0000261,0.0000334,0.0000409,0.0000463,0.0000559,0.0000599,0.000054,0.000044,0.0000353,0.000028,0.0000375,0.0000428,0.0000381,0.0000319,0.0000383,0.0000367,0.0000306,0.0000203,0.0000083,0.0000295,0.0000497,0.0000505,0.000047,0.0000412,0.0000355,0.0000217,0.0000113,0.0000156,0.0000246,0.0000327,0.0000356,0.0000299,0.0000262,0.0000265,0.0000149],"detections":[{"range_m":2.805707,"snr_db":26.0194,"width_m":0.171607}],"noise_floor":0.000035575,"blast_amplitude":0.2700244,"saturated":false} +{"timestamp":1756162803.4666634,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.7848856,-0.4531539,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000115,0.0000065,6e-7,0.0000051,0.0000476,0.0000416,0.0000142,0.0000138,0.0000362,0.0000276,0.0000231,0.0000229,0,0.0000125,0.0000507,0.0000552,0.0000587,0.0000601,0.0000083,0.0000192,0.0000585,0.0000548,0.0000412,0.0000313,0.0000057,0.000001,0.0000326,0.0000405,0.0000493,0.0000539,0.0000297,0,0.0000493,0.0000521,0.0000532,0.0000532,0.0000495,0,0.0000264,0.0000334,0.0000246,0.0000147,0.0000051,0.0000049,0.0000055,0.0000105,0.0000192,0.0000347,0.0000475,0.0000431,0.0000614,0.0000837,0.0000814,0.0000921,0.000099,0.0000829,0.0000558,0.0000666,0.0000616,0.0000304,0.0000114,0.0000281,0.0000456,0.000066,0.0000665,0.0000465,0.0000442,0.0000443,0.0000287,0.0000291,0.0000395,0.0000423,0.0000422,0.0000403,0.0000445,0.0000682,0.0000755,0.0000591,0.0000383,0.0000544,0.0000523,0.0000441,0.0000343,0.0000103,0.0000081,0.000019,0.000015,0.0000198,0.0000578,0.0000585,0.0000561,0.0000464,0.0000295,0,0.0000097,0.0000123,7e-7,0.0000204,0.0000376,0.0000537,0.0000792,0.0000935,0.0000923,0.0000818,0.0000536,0.000115,0.0003241,0.0006256,0.0010044,0.0014564,0.0020008,0.0023515,0.0025571,0.0025903,0.0025409,0.0022969,0.0019187,0.0014759,0.0009395,0.0005839,0.0003321,0.0001572,0.0000709,0.0000536,0.0000434,0.00006,0.0000658,0.0000644,0.0000711,0.0000771,0.0000746,0.0000735,0.0000713,0.0000483,0.0000346,0.0000425,0.0000391,0.0000314,0.0000248,0.0000148,0.0000182,0.0000321,0.0000327,0.0000411,0.0000544,0.000054,0.0000448,0.0000383,0.0000312,0.0000099,0.0000063,0.0000119,0.0000172,0.0000328,0.0000454,0.0000501,0.0000563,0.0000588,0.0000491,0.0000493,0.0000545,0.0000457,0.0000232,0.0000591,0.0000782,0.0000818,0.0000789,0.0000542,0.0000143,0.000034,0.0000426,0.0000577,0.0000757,0.0000757,0.000066,0.0000529,0.000046,0.0000311,0.0000225,0.0000114,0.0000261,0.0000416,0.0000469,0.0000462,0.0000652,0.000075,0.000075,0.0000734,0.0000699,0.0000549,0.0000368,0.0000376,0.0000358,0.0000344,0.000034,0.0000276,0.0000185,0.000034,0.0000381,0.0000445,0.0000518,0.0000509,0.0000518,0.0000668,0.0000699,0.0000634,0.0000468,0.0000434,0.0000643,0.0000753,0.0000757,0.000058,0.0000381,0.0000391,0.0000424,0.0000517,0.0000576,0.0000538,0.000049,0.0000544,0.0000522,0.0000469,0.0000445,0.0000369,0.0000189,0.0000173,0.0000214,0.0000262,0.0000342,0.0000331,0.0000248,0.0000178,0.0000169,0.0000236,0.0000334,0.0000375,0.0000357,0.00004,0.000041,0.0000282],"detections":[{"range_m":2.67729,"snr_db":23.9001,"width_m":0.171607}],"noise_floor":0.0000412,"blast_amplitude":0.2700642,"saturated":false} +{"timestamp":1756162803.53333,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.8754261,-0.2345697,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000475,0.0000525,0.0000496,0.0000445,0.0000386,0,0,0.0000014,0,0.0000025,0.0000334,0.0000313,0.0000465,0.0000658,0.0000606,0.000064,0.0000671,0.000032,0.0000035,0.0000142,0.0000022,0,0.0000312,0.0000479,0.0000615,0.0000627,0.0000327,0,0.0000039,0,0,0.0000122,0.0000171,0,0.0000117,0.0000216,0.0000241,0.0000345,0.0000392,0.0000188,0.0000204,0.0000292,0.0000192,0.000027,0.000042,0.0000349,0.0000253,0.0000145,0.0000198,0.00004,0.0000555,0.0000475,0.0000437,0.0000648,0.0000618,0.0000539,0.0000736,0.0000716,0.0000696,0.0000784,0.0000771,0.0000508,0.0000305,0.0000182,0.0000055,0.0000306,0.0000416,0.000041,0.000037,0.0000264,0.0000131,0.0000292,0.000034,0.0000168,0.0000011,0.0000068,0.0000016,0,0.0000208,0.0000209,0.0000182,0.0000144,0.0000298,0.0000473,0.0000578,0.0000494,0.0000254,0.0000317,0.0000325,0.0000408,0.000056,0.000058,0.00003,0.0001783,0.0004186,0.0007797,0.0012623,0.0017955,0.0023901,0.0027588,0.0029129,0.0029133,0.0027817,0.0024581,0.0019905,0.0014667,0.0009863,0.0005239,0.0002558,0.0001076,0.0000481,0.0000331,0.0000349,0.0000323,0.0000241,0.0000381,0.0000371,0.000019,0.0000079,0.000018,0.0000184,0.000018,0.0000184,9e-7,0.0000017,0.0000129,0.0000124,0.0000104,0.0000096,0,0.0000232,0.00005,0.0000598,0.000067,0.0000713,0.0000653,0.0000473,0.0000412,0.0000335,0.0000362,0.000064,0.0000682,0.0000679,0.0000635,0.0000567,0.000045,0.0000465,0.0000463,0.0000224,0.0000022,0.000016,0.0000221,0.0000282,0.0000301,0.0000187,0.0000181,0.0000252,0.0000208,0.0000259,0.000041,0.000041,0.0000365,0.0000256,0.0000266,0.000044,0.0000574,0.0000541,0.0000432,0.0000375,0.0000332,0.000017,0.0000205,0.000023,0.0000241,0.000032,0.0000338,0.0000252,0.0000261,0.0000305,0.000028,0.0000298,0.0000304,0.0000176,0.0000058,0.0000132,0.000017,0.0000296,0.0000462,0.0000484,0.0000524,0.0000557,0.0000507,0.0000373,0.0000333,0.0000322,0.0000428,0.0000648,0.0000671,0.0000659,0.0000583,0.0000483,0.0000424,0.0000626,0.0000654,0.0000579,0.0000445,0.0000302,0.0000168,0.0000381,0.0000537,0.0000539,0.0000534,0.0000524,0.0000411,0.0000252,0.0000257,0.0000277,0.0000342,0.0000385,0.0000346,0.000024,0.0000189,0.0000172,0.000022,0.000047,0.0000554,0.0000642,0.0000741,0.000076,0.0000717,0.0000809,0.0000862,0.0000838,0.0000805,0.0000798,0.0000724],"detections":[{"range_m":2.522419,"snr_db":29.933,"width_m":0.175182}],"noise_floor":0.00003173,"blast_amplitude":0.2700129,"saturated":false} +{"timestamp":1756162803.5999966,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.9063078,0,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000093,0.0000193,0.0000275,0.0000503,0.0000396,0.0000226,0.0000313,0.0000351,0,0.0000018,0.0000141,0.0000059,0.0000316,0.0000491,0.0000186,0.0000276,0.0000651,0.0000706,0.0000713,0.00007,0.0000242,0.0000217,0.0000602,0.0000587,0.0000606,0.0000661,0.0000449,0.0000254,0.0000649,0.0000599,0.0000575,0.0000736,0.0000661,0.0000344,0.0000399,0.0000474,0.0000468,0.000046,0.0000414,0,0.0000292,0.0000451,0.0000426,0.0000516,0.0000555,0.0000341,0.0000173,0.0000079,0.000025,0.0000482,0.0000664,0.0000533,0.0000332,0.0000198,0.0000185,0.0000246,0.0000265,0.0000148,0.0000124,0.0000395,0.0000373,0.0000278,0.0000362,0.0000361,0.0000162,0.0000014,0.0000026,0,0.0000162,0.0000433,0.0000563,0.000068,0.0000731,0.0000603,0.0000883,0.0001098,0.00011,0.0001058,0.0000917,0.0000518,6e-7,0,0,0,0.0000273,0.0000291,0.000032,0.000038,0.0000321,0.0000278,0.000037,0.000033,0.0000864,0.0002958,0.0006725,0.0011035,0.0016185,0.0021337,0.0025895,0.0029129,0.0030132,0.0029981,0.0027304,0.0023054,0.0017667,0.0012316,0.0007879,0.000448,0.0002122,0.000085,0.0000556,0.0000634,0.0000779,0.0000769,0.0000728,0.0000648,0.0000477,0.0000071,0.0000044,0.000009,0.0000172,0.0000264,0.0000281,0.0000155,0.000033,0.0000415,0.0000458,0.0000509,0.0000528,0.0000397,0.0000411,0.0000509,0.0000453,0.0000331,0.0000198,0.0000027,0.0000067,0.0000168,0.0000229,0.0000304,0.0000364,0.0000326,0.0000466,0.0000816,0.0001001,0.0001139,0.0001187,0.0001137,0.0000806,0.0000489,0.0000241,0.000013,0.0000344,0.0000425,0.0000367,0.0000271,0.0000196,0.000003,0.0000094,0.0000372,0.0000521,0.0000619,0.0000622,0.0000481,0.0000278,0.0000276,0.0000239,0.000017,0.000035,0.0000372,0.0000385,0.0000385,0.0000345,0.0000159,0.0000174,0.0000199,0.0000151,0.0000121,0.0000136,0.0000159,0.0000182,0.0000184,0.0000065,0.000011,0.0000132,0.000003,0.0000185,0.000035,0.0000406,0.0000531,0.0000696,0.0000697,0.0000661,0.0000543,0.0000286,0.000019,0.0000411,0.000042,0.0000435,0.0000509,0.0000497,0.0000378,0.0000248,0.0000121,0.0000104,0.0000226,0.0000244,0.0000173,0.0000135,0.0000132,3e-7,0.0000097,0.0000233,0.000032,0.0000515,0.0000684,0.0000697,0.000069,0.0000665,0.0000599,0.000045,0.0000287,0.0000395,0.0000572,0.0000676,0.0000661,0.0000538,0.0000448,0.0000341,0.0000179,0.0000438,0.0000599,0.0000695,0.0000793,0.0000837,0.0000813,0.0000783,0.0000708,0.0000469],"detections":[{"range_m":2.46053,"snr_db":26.8058,"width_m":0.175182}],"noise_floor":0.000037435,"blast_amplitude":0.2699634,"saturated":false} +{"timestamp":1756162803.6666632,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.8754261,0.2345697,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000262,0.0000363,0.0000413,0.0000414,0.0000711,0.0000657,0.0000896,0.0001134,0.0001206,0.000066,0.0000922,0.000115,0.000103,0.0000881,0.0000757,0,0.0000168,0.0000428,0.0000319,0.0000102,0.0000409,0.0000654,0.0000754,0.0000764,0.0000344,0.0000174,0.0000593,0.0000554,0.0000454,0.0000388,0.0000234,0.0000209,0.0000257,0.0000139,0,0.0000212,0.0000296,0.000026,0.0000468,0.0000522,0.0000511,0.0000621,0.0000677,0.0000576,0.0000741,0.000079,0.0000573,0.0000508,0.0000547,0.0000313,0.0000051,0.0000182,0.0000131,0.000004,0.0000182,0.0000255,0.000035,0.0000477,0.0000432,0.0000274,0.000031,0.0000253,0.0000207,0.0000399,0.0000417,0.0000562,0.0000969,0.0001021,0.0000987,0.0000842,0.0000594,0.000015,0.0000586,0.0000842,0.0000895,0.0001005,0.000106,0.0000921,0.0000762,0.0000738,0.0000626,0.0000478,0.0000436,0.0000245,0,0.0000496,0.0001218,0.0002656,0.0005635,0.0009023,0.0013502,0.0018668,0.0023454,0.0027314,0.0029509,0.0029749,0.0028535,0.0025349,0.0020828,0.001549,0.0010479,0.0006448,0.0003392,0.0001192,0.000047,0.0000358,0.0000408,0.0000442,0.0000339,0.0000357,0.0000566,0.000055,0.0000475,0.0000367,0.0000237,0.0000342,0.0000474,0.0000461,0.0000335,0.0000464,0.0000458,0.0000328,0.000022,0.0000174,0.0000011,0.0000156,0.0000219,0.0000215,0.0000211,0.0000224,0.0000122,0.0000118,0.0000189,0.0000197,0.0000236,0.0000246,0.0000115,0.0000032,0.0000212,0.0000205,0.0000145,0.0000096,0.0000127,0.0000339,0.0000632,0.0000709,0.0000764,0.0000817,0.000079,0.0000577,0.0000412,0.0000338,0.0000138,0.0000079,0.0000356,0.0000493,0.000058,0.0000595,0.0000459,0.0000524,0.0000698,0.0000703,0.0000687,0.0000609,0.0000427,0.000034,0.0000526,0.000061,0.0000732,0.0000835,0.0000788,0.0000632,0.0000555,0.0000531,0.000035,0.0000167,0.0000104,0.0000184,0.0000272,0.0000291,0.0000208,0.0000115,0.0000043,0.0000091,0.0000137,0.0000175,0.0000226,0.0000304,0.000035,0.0000304,0.0000367,0.0000469,0.0000436,0.0000357,0.0000344,0.0000304,0.00002,0.0000132,0.0000127,0.0000128,0.0000123,0.0000085,0.0000089,0.0000289,0.0000471,0.0000655,0.0000841,0.0000861,0.0000758,0.0000654,0.0000631,0.0000547,0.000053,0.0000558,0.0000451,0.0000285,0.0000229,0.0000284,0.0000321,0.000036,0.000035,0.000043,0.0000462,0.0000409,0.0000263,0.0000243,0.0000233,0.000018,0.0000289,0.000029,0.0000332,0.0000401,0.0000408,0.0000239,0.0000173,0.0000415,0.0000491,0.0000487,0.0000413,0.0000188],"detections":[{"range_m":2.341614,"snr_db":24.7451,"width_m":0.175182}],"noise_floor":0.000034277,"blast_amplitude":0.2700113,"saturated":false} +{"timestamp":1756162803.7333298,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.7848856,0.4531539,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000309,0.0000344,0.0000356,0.0000369,0.000042,0.0000223,0,0,0.0000176,0.0000138,0.0000057,0.0000188,0.0000362,0.0000558,0.0000645,0.000018,0.0000594,0.0000943,0.0000998,0.0001034,0.0001036,0.0000594,0.0000299,0.000056,0.0000438,0.000034,0.0000503,0.0000372,0.0000185,0.0000405,0.0000344,0.0000314,0.000048,0.0000426,0.0000407,0.0000707,0.0000765,0.0000596,0.000053,0.0000536,0.0000523,0.0000744,0.0000799,0.0000538,0.0000451,0.0000503,0.0000284,0.0000178,0.0000412,0.0000434,0.0000435,0.0000426,0.0000109,0,0.0000293,0.0000303,0.0000317,0.0000357,0.0000278,0.0000121,0.000038,0.0000435,0.0000555,0.0000691,0.0000681,0.0000462,0.0000753,0.000079,0.0000754,0.000072,0.0000723,0.0000539,0.0000735,0.0000825,0.0000648,0.0000551,0.0000772,0.0000808,0.0000784,0.0000506,0.0001688,0.0004056,0.0007399,0.0011665,0.0016958,0.0022548,0.0027115,0.003003,0.0030724,0.0030096,0.0027119,0.0022615,0.0017412,0.0012057,0.0007544,0.0004233,0.0001532,0.0000512,0.0000326,0.0000236,0.0000444,0.000057,0.0000534,0.0000415,0.0000306,0.000004,0.0000017,0.0000212,0.0000232,0.000024,0.0000239,0.0000092,0.0000192,0.0000509,0.0000617,0.0000732,0.0000864,0.000086,0.0000817,0.0000885,0.0000861,0.0000638,0.0000473,0.0000383,0.0000098,0.0000247,0.0000319,0.0000294,0.0000216,0.0000187,0.0000314,0.0000491,0.0000598,0.0000548,0.0000491,0.0000514,0.000044,0.0000511,0.0000632,0.0000599,0.0000404,0.0000273,0.0000208,0.0000141,0.0000236,0.0000236,0.0000193,0.0000207,0.0000226,0.0000301,0.0000365,0.0000375,0.0000246,0.0000397,0.0000465,0.0000371,0.0000189,0.0000169,0.0000283,0.0000328,0.0000329,0.0000214,0.0000131,0.0000225,0.0000243,0.0000315,0.0000414,0.0000411,0.0000486,0.0000569,0.0000513,0.0000289,0.0000147,0.0000335,0.0000435,0.000049,0.0000502,0.0000507,0.0000569,0.0000572,0.0000368,0.0000204,0.0000336,0.0000499,0.0000762,0.0000837,0.0000786,0.0000635,0.0000521,0.0000522,0.0000582,0.0000596,0.0000478,0.000028,0.0000172,0.0000114,0.0000122,0.0000313,0.0000338,0.0000345,0.0000411,0.0000408,0.0000407,0.0000472,0.0000489,0.0000436,0.0000401,0.0000394,0.0000358,0.0000453,0.0000492,0.0000446,0.0000461,0.0000507,0.0000443,0.0000348,0.000027,0.0000183,0.000021,0.0000257,0.0000231,0.0000264,0.000032,0.0000288,0.0000206,0.0000242,0.0000234,0.0000187,0.0000269,0.0000266,0.0000283,0.0000388,0.0000417,0.0000451,0.0000551,0.0000597,0.0000559,0.000056,0.0000584,0.0000528],"detections":[{"range_m":2.219249,"snr_db":25.9613,"width_m":0.171607}],"noise_floor":0.000038964,"blast_amplitude":0.270044,"saturated":false} +{"timestamp":1756162803.7999964,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.6408564,0.6408564,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000682,0.0000653,0.0000411,0.0000602,0.0000809,0.000058,0.0000388,0.0000322,0.0000029,0.000004,0.0000065,0,0.0000314,0.0000618,0.0000634,0.0000633,0.0000592,0.0000125,0.0000018,0.0000184,0.0000046,7e-7,0.0000258,0.0000217,0.0000207,0.0000214,0,0,7e-7,0,0,0.0000122,0.0000204,0.0000141,0.0000228,0.0000264,0.000016,0.0000149,0.0000159,0,0.0000069,0.0000217,0.0000204,0.0000211,0.0000221,0,0.0000017,0.0000357,0.0000357,0.0000355,0.0000366,0.0000182,0.0000118,0.000047,0.0000461,0.0000354,0.0000343,0.0000371,0.0000455,0.0000539,0.0000524,0.0000272,0.0000164,0.0000177,0.0000196,0.0000198,0.0000187,0,0.0000111,0.0000477,0.0000739,0.0000982,0.0001055,0.000091,0.0000864,0.0001038,0.0001034,0.0001122,0.0001198,0.0001112,0.0001084,0.0001182,0.00011,0.0001347,0.0004118,0.0007585,0.001218,0.0017485,0.0022495,0.0026703,0.0029304,0.0029688,0.0028701,0.0025488,0.0020734,0.0015119,0.0009941,0.000589,0.0002873,0.0000636,0.000069,0.0000772,0.0000757,0.0000648,0.0000448,0.0000369,0.0000374,0.0000208,0.0000066,0.0000269,0.0000266,0.0000252,0.0000244,0.0000196,0,0.0000032,0.0000031,0.0000074,0.0000357,0.0000493,0.0000521,0.0000523,0.0000523,0.0000328,0.0000253,0.0000272,0.0000173,0.0000359,0.0000528,0.0000505,0.0000389,0.0000208,0.0000251,0.0000419,0.0000516,0.0000477,0.0000472,0.0000681,0.0000712,0.0000716,0.0000703,0.0000629,0.0000423,0.0000655,0.0000789,0.0000957,0.0001086,0.0001099,0.0000891,0.0000635,0.0000407,0.0000041,0.0000284,0.0000372,0.0000325,0.0000193,0.0000077,0.000017,0.000024,0.0000263,0.0000178,0.0000183,0.000033,0.0000325,0.0000276,0.0000198,0.0000112,0.0000111,0.0000095,0.0000151,0.0000359,0.0000542,0.000057,0.0000478,0.0000467,0.0000479,0.000038,0.0000352,0.0000381,0.0000387,0.0000637,0.0000782,0.0000792,0.0000782,0.0000733,0.0000571,0.0000407,0.0000439,0.0000393,0.0000295,0.0000186,0.0000028,0.000013,0.0000194,0.0000176,0.0000053,0.0000126,0.0000133,0.0000201,0.0000343,0.0000389,0.0000414,0.0000461,0.0000466,0.0000318,0.0000205,0.0000239,0.0000204,0.000015,0.0000139,0.0000163,0.0000189,0.0000198,0.0000119,0.00001,0.0000272,0.000035,0.0000455,0.0000521,0.0000495,0.0000485,0.0000597,0.0000595,0.0000523,0.00004,0.000021,0.0000257,0.0000376,0.0000392,0.0000297,0.0000237,0.0000266,0.0000314,0.0000441,0.0000493,0.0000467],"detections":[{"range_m":2.343495,"snr_db":22.8944,"width_m":0.171607}],"noise_floor":0.000033884,"blast_amplitude":0.2699887,"saturated":false} +{"timestamp":1756162803.866663,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.4531539,0.7848856,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000112,0.0000041,0,0,0.0000404,0.0000373,0.0000086,0,0.000035,0.0000393,0.0000455,0.0000481,0.000012,0.0000337,0.0000634,0.0000761,0.0000908,0.0000968,0.0000546,0.0000716,0.000105,0.0000962,0.0000813,0.0000832,0.000058,0.0000626,0.0000824,0.0000682,0.0000479,0.0000536,0.0000333,0.000001,0.0000067,0.0000014,0,0.0000287,0.0000441,0.0000552,0.0000698,0.0000703,0.0000361,0.0000388,0.0000454,0.0000248,0.0000071,0.0000063,0,0.000015,0.00005,0.000055,0.0000555,0.0000541,0.0000176,0,0.0000092,0.0000027,8e-7,0.0000153,0.0000113,0.0000135,0.0000443,0.0000425,0.0000288,0.0000102,0.0000246,0.0000387,0.0000496,0.0000486,0.0000299,0.0000501,0.0000592,0.0000587,0.0000641,0.0000671,0.0000469,0.0000542,0.0000638,0.0000566,0.0000525,0.0000572,0.0000461,0.0000264,0.0000178,0.0000028,0.0000204,0.000036,0.0000329,0.0000231,0.0000356,0.0000297,0.0000024,0.0000341,0.0000373,0.0000288,0.0001921,0.0005164,0.0009044,0.0013815,0.0018694,0.0023129,0.0026415,0.0027615,0.0027554,0.0025351,0.0021553,0.001658,0.0011537,0.0007268,0.0003912,0.0001506,0.0000559,0.0000696,0.0000683,0.0000521,0.0000218,0.0000019,0.0000096,0.0000151,0.0000365,0.000064,0.0000657,0.0000619,0.0000576,0.0000577,0.0000384,0.0000353,0.0000487,0.000057,0.0000606,0.0000612,0.0000468,0.0000492,0.000055,0.0000415,0.0000305,0.0000532,0.0000619,0.0000645,0.0000643,0.0000531,0.0000293,0.0000402,0.0000393,0.0000351,0.0000308,0.0000235,0.0000174,0.0000559,0.0000724,0.0000847,0.000091,0.0000913,0.0000714,0.00006,0.0000625,0.0000502,0.0000385,0.0000305,0.0000157,0.0000249,0.0000341,0.0000317,0.0000266,0.0000258,0.000016,0.0000161,0.0000426,0.000051,0.0000537,0.0000535,0.0000426,0.0000218,0.0000169,0.0000168,0.0000217,0.0000297,0.0000315,0.0000277,0.0000462,0.0000534,0.0000532,0.0000478,0.000035,0.0000093,0.000029,0.0000357,0.0000354,0.0000419,0.0000459,0.0000381,0.0000219,0.0000272,0.0000373,0.0000449,0.0000478,0.0000419,0.0000275,0.0000383,0.0000393,0.0000386,0.0000321,0.0000185,0.0000038,0.0000118,0.0000124,7e-7,0.0000235,0.0000321,0.0000389,0.0000497,0.0000543,0.0000502,0.0000443,0.0000395,0.0000238,0.0000297,0.0000392,0.0000372,0.0000272,0.0000137,0.0000093,0.000018,0.0000293,0.0000389,0.0000565,0.0000754,0.0000764,0.000075,0.0000728,0.0000697,0.0000563,0.0000477,0.0000405,0.0000145,0.0000281,0.0000555,0.000064,0.0000675,0.0000683,0.0000592],"detections":[{"range_m":2.462697,"snr_db":27.6475,"width_m":0.175182}],"noise_floor":0.000039104,"blast_amplitude":0.2698932,"saturated":false} +{"timestamp":1756162803.9333296,"source":"simulated","device_id":"batvu-reference-01","beam":[-0.2345697,0.8754261,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000818,0.0000688,0.0000454,0.0000194,0.0000311,0.0000311,0.0000221,0.0000071,0.0000294,0.0000437,0.0000639,0.0000706,0.0000381,0.0000354,0.0000522,0.00004,0.000044,0.0000457,0,0.000031,0.0000724,0.0000691,0.0000551,0.0000425,0,0,0.0000422,0.0000545,0.0000654,0.0000688,0.0000455,0.0000144,0.0000608,0.000062,0.0000628,0.0000626,0.0000609,0.0000369,0.0000623,0.0000653,0.0000409,0.000059,0.000072,0.0000656,0.0000753,0.0000787,0.0000551,0.000039,0.0000399,0.0000255,0.0000258,0.0000353,0.0000234,0.0000316,0.0000475,0.0000402,0.0000258,0.0000185,0.0000097,0,0.0000291,0.0000349,0.0000374,0.0000426,0.0000418,0.00003,0.0000321,0.0000287,4e-7,0.0000107,0.0000137,0.0000088,0.0000292,0.0000433,0.0000391,0.0000427,0.0000452,0.0000271,0.0000292,0.0000451,0.0000404,0.0000247,0.0000193,0.0000132,0.0000146,0.0000213,0.0000153,0.0000092,0.0000562,0.0000662,0.0000683,0.0000676,0.0000602,0.0000348,0.0000411,0.0000433,0.0000331,0.0000561,0.0000626,0.0000513,0.000041,0.0001884,0.0004723,0.0008233,0.0012484,0.0017136,0.0021705,0.0025173,0.0026628,0.0026664,0.0025565,0.0021691,0.0017199,0.0012678,0.0008631,0.0005262,0.00029,0.0001516,0.0000871,0.0000848,0.0000767,0.0000446,0.0000264,0.0000192,0.0000104,0.0000415,0.0000517,0.0000554,0.00006,0.000062,0.0000506,0.0000576,0.0000671,0.0000623,0.0000683,0.0000785,0.0000762,0.000071,0.0000682,0.0000597,0.0000405,0.000044,0.0000403,0.000034,0.000041,0.0000392,0.0000217,0.0000055,0.0000104,0.0000106,0.0000106,0.0000111,0,0.0000192,0.0000459,0.0000559,0.0000585,0.0000579,0.0000394,0.0000496,0.0000686,0.0000714,0.0000761,0.0000797,0.0000721,0.0000574,0.0000431,0.000022,0.0000025,0.0000167,0.0000153,0.0000147,0.0000255,0.000025,0.0000166,0.0000099,0.0000128,0.0000191,0.0000298,0.000032,0.0000226,0.000008,0.0000249,0.0000379,0.0000444,0.0000446,0.0000299,0.0000193,0.0000248,0.0000245,0.0000306,0.0000371,0.0000327,0.0000392,0.0000534,0.0000532,0.0000505,0.0000464,0.0000406,0.0000266,0.0000332,0.0000328,0.0000359,0.0000533,0.0000601,0.0000602,0.0000578,0.0000504,0.0000323,0.0000257,0.0000241,0.0000109,0.0000258,0.0000419,0.0000471,0.0000542,0.0000586,0.0000535,0.0000462,0.0000438,0.000035,0.0000104,0.0000445,0.0000659,0.0000796,0.0000839,0.0000812,0.0000625,0.0000478,0.0000377,0.0000237,0.0000309,0.0000373,0.000043,0.0000484,0.0000492,0.000032,0.0000134,0.0000269,0.0000338],"detections":[{"range_m":2.595257,"snr_db":24.5685,"width_m":0.175182}],"noise_floor":0.000035931,"blast_amplitude":0.27005,"saturated":false} +{"timestamp":1756162803.9999962,"source":"simulated","device_id":"batvu-reference-01","beam":[0,0.9063078,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000431,0.0000512,0.0000582,0.000076,0.0000902,0.0000624,0.0000886,0.0001007,0.0000741,0.0000569,0.0000544,0,0.0000184,0.0000599,0.0000623,0.0000619,0.0000577,0.000017,0.0000091,0.000018,0,0.0000168,0.0000383,0.0000238,0.000013,0.0000745,0.0000771,0.0000767,0.0000714,0.0000479,0,0.0000101,0.0000168,0.0000022,0.0000456,0.0000709,0.0000877,0.0000977,0.0000984,0.000055,0.0000531,0.0000671,0.0000601,0.0000502,0.0000432,0.0000269,0.0000482,0.0000675,0.0000568,0.0000488,0.0000557,0.000044,0.0000527,0.0001056,0.0001229,0.0001277,0.0001275,0.0001115,0.0000792,0.0001004,0.0001016,0.0000831,0.0000704,0.000068,0.0000417,0.0000455,0.0000524,0.0000458,0.000039,0.0000378,0.0000407,0.0000521,0.0000549,0.0000325,0.0000094,0.0000094,0,0,0.0000106,0.0000035,0.0000274,0.0000798,0.0000993,0.0001096,0.0001114,0.0000998,0.0000703,0.0000768,0.0000774,0.0000685,0.0000616,0.000055,0.0000146,0.0000112,0.0000263,0.0000288,0.0000447,0.0001202,0.0003343,0.0006511,0.0010659,0.0015391,0.0020205,0.0024136,0.0026314,0.0026696,0.0026341,0.0023397,0.001923,0.0014545,0.001002,0.0006147,0.0003456,0.0001888,0.0000884,0.0000533,0.0000275,0.0000369,0.000047,0.0000471,0.0000186,0.0000147,0.0000189,0.0000157,0.0000372,0.0000526,0.0000529,0.0000512,0.0000467,0.0000317,0.0000473,0.0000585,0.0000555,0.0000497,0.0000543,0.0000493,0.0000386,0.0000562,0.0000602,0.0000629,0.0000632,0.0000587,0.000037,0.0000496,0.0000507,0.0000494,0.0000485,0.0000486,0.0000306,0.0000337,0.000052,0.0000605,0.0000649,0.0000651,0.0000478,0.0000338,0.00004,0.0000359,0.000027,0.0000199,0.0000156,0.0000264,0.0000441,0.0000525,0.0000686,0.0000892,0.0000889,0.0000854,0.0000814,0.0000746,0.0000535,0.0000409,0.0000357,0.0000164,0.0000314,0.0000366,0.0000365,0.0000354,0.000033,0.0000156,0.0000026,0.0000104,0.00002,0.000042,0.000063,0.0000704,0.0000767,0.0000804,0.0000715,0.000054,0.0000411,0.0000324,0.0000229,0.0000164,0.0000068,0.0000181,0.0000372,0.0000429,0.0000477,0.000049,0.0000484,0.0000299,0.0000287,0.0000311,0.0000282,0.0000332,0.0000374,0.0000432,0.000061,0.0000772,0.0000782,0.0000779,0.000076,0.0000697,0.0000888,0.0001087,0.0001086,0.0000986,0.0000701,0.0000409,0.000046,0.0000589,0.0000577,0.0000434,0.0000282,0.0000147,0.000015,0.0000316,0.0000375,0.0000418,0.0000529,0.0000602,0.0000604,0.0000587,0.0000519,0.00003],"detections":[{"range_m":2.60293,"snr_db":23.7083,"width_m":0.175182}],"noise_floor":0.000045183,"blast_amplitude":0.2700521,"saturated":false} +{"timestamp":1756162804.0666628,"source":"simulated","device_id":"batvu-reference-01","beam":[0.2345697,0.8754261,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000425,0.0000391,0.0000281,0.0000212,0.0000198,0.0000024,0.0000102,0.0000355,0.0000501,0.0000172,0.0000158,0.0000286,0.0000107,0.0000116,0.0000356,0.0000309,0.0000237,0.0000138,0.0000094,0.0000188,0.0000339,0.0000278,0.0000389,0.0000447,0.0000089,0,0.000042,0.0000393,0.000026,0.0000259,0.0000474,0.0000644,0.0000726,0.0000621,0.0000271,0.0000482,0.0000526,0.0000468,0.0000676,0.0000702,0.0000557,0.000094,0.0001105,0.0001035,0.0000869,0.0000756,0.0000564,0.0000793,0.0000888,0.0000709,0.0000696,0.0000931,0.0000854,0.0000723,0.000062,0.0000378,0.0000326,0.0000658,0.0000651,0.0000628,0.0000627,0.0000504,0.000016,0.000007,0.0000059,0.0000059,0.0000347,0.0000378,0.0000308,0.0000253,0.000025,0.0000022,0.0000021,0.0000161,0.0000125,0.0000146,0.0000268,0.0000283,0.0000294,0.0000297,0.000015,0.0000081,0.0000118,0,0,0.0000078,0.0000072,0.0000088,0.0000229,0.0000213,0.0000257,0.0000369,0.0000356,0.000019,0.0000394,0.0000461,0.0000368,0.0000476,0.0000675,0.0000778,0.000079,0.0000751,0.0000385,0.0000326,0.0001259,0.0002957,0.0005846,0.0009738,0.0014133,0.0018656,0.0023098,0.0025071,0.0025432,0.0025141,0.0023154,0.0019567,0.0015265,0.0010938,0.000632,0.0003651,0.0002006,0.000092,0.0000395,0.0000269,0.0000174,0.0000262,0.0000305,0.0000348,0.0000472,0.0000544,0.0000549,0.0000661,0.0000732,0.0000601,0.0000352,0.0000159,0.0000347,0.0000506,0.0000638,0.0000628,0.0000689,0.0000772,0.0000741,0.0000691,0.0000773,0.0000762,0.0000697,0.0000859,0.0000858,0.0000766,0.0000659,0.0000561,0.000031,0.0000286,0.0000362,0.0000417,0.0000569,0.0000672,0.0000736,0.0000873,0.0000962,0.0000918,0.0000794,0.0000855,0.0000941,0.0000989,0.0000987,0.0000837,0.0000528,0.0000338,0.0000216,0.0000088,0.0000252,0.0000268,0.0000286,0.0000377,0.0000486,0.0000696,0.0000989,0.0001113,0.0001138,0.0001141,0.000114,0.0000987,0.0000805,0.0000621,0.0000362,0.0000164,0.0000151,0.0000199,0.0000265,0.0000299,0.0000211,0.0000111,0.0000245,0.0000327,0.0000519,0.0000768,0.0000796,0.0000779,0.0000651,0.0000464,0.0000211,0.0000286,0.0000306,0.0000234,0.0000254,0.0000281,0.0000298,0.0000339,0.0000357,0.0000278,0.00003,0.0000364,0.0000317,0.0000242,0.0000367,0.0000504,0.000064,0.000071,0.000067,0.0000546,0.0000508,0.0000475,0.0000413,0.000049,0.0000489,0.0000516,0.0000552,0.0000522,0.0000289,0.0000273,0.0000551,0.0000696,0.000074,0.0000746,0.0000773,0.0000955,0.0001015,0.0000971],"detections":[{"range_m":2.660501,"snr_db":25.2765,"width_m":0.175182}],"noise_floor":0.000038032,"blast_amplitude":0.2700114,"saturated":false} +{"timestamp":1756162804.1333294,"source":"simulated","device_id":"batvu-reference-01","beam":[0.4531539,0.7848856,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000041,0.0000043,0.000005,0.0000171,0.0000339,0.0000482,0.0000269,0.0000404,0.0000446,0.0000048,0.0000155,0.0000441,0.0000397,0.0000333,0.000028,0,0,0.0000197,0.0000275,0.0000553,0.0000804,0.000066,0.0000601,0.0000875,0.0000792,0.0000629,0.0000464,0,0,0.0000123,0.0000097,0.0000097,0.000022,0.0000266,0.0000308,0.0000397,0.0000389,0,0.0000073,0.0000224,0.0000164,0.0000064,0.0000116,0.0000206,0.000033,0.0000411,0.0000283,0.0000418,0.000063,0.0000526,0.0000341,0.0000264,0.0000312,0.000041,0.0000557,0.0000527,0.0000679,0.0000872,0.0000834,0.0000687,0.0000742,0.0000733,0.0000492,0.0000356,0.0000332,0.000006,0.0000272,0.0000611,0.0000826,0.0000999,0.0001055,0.0000864,0.0000789,0.0000804,0.0000565,0.0000376,0.0000671,0.000077,0.0000796,0.0000794,0.0000602,0.000038,0.000052,0.0000494,0.0000381,0.0000374,0.0000313,0.0000144,0.0000413,0.0000611,0.0000779,0.0000893,0.0000907,0.0000626,0.0000586,0.0000637,0.0000665,0.000076,0.0000804,0.0000696,0.0000821,0.0000909,0.0000834,0.0000654,0.0000437,0.0000113,0.0000054,0.0000108,0.0000649,0.0001826,0.0003804,0.0006521,0.0010278,0.0014748,0.0019735,0.0022894,0.0024422,0.0024458,0.0023353,0.0020564,0.0016674,0.0012124,0.0007875,0.000392,0.0001574,0.0000316,0.0000155,0.0000087,0.0000234,0.0000568,0.0000759,0.0000943,0.0001016,0.0000916,0.0000661,0.0000405,0.0000131,0.0000028,0.0000044,0,0.0000082,0.0000231,0.0000234,0.0000185,0.0000327,0.0000403,0.0000504,0.0000634,0.0000666,0.0000677,0.0000738,0.0000756,0.0000577,0.0000408,0.0000282,0.0000037,0.0000153,0.0000298,0.0000297,0.0000274,0.0000199,7e-7,0.0000115,0.0000182,0.0000139,0.0000032,0.000021,0.0000272,0.0000333,0.0000357,0.0000328,0.0000154,0.0000278,0.000038,0.0000517,0.0000724,0.000078,0.0000773,0.0000744,0.0000701,0.000052,0.0000416,0.000045,0.000039,0.0000307,0.0000227,0.0000055,0.0000036,0.0000095,0.0000036,0.0000098,0.0000286,0.0000339,0.0000385,0.0000401,0.0000372,0.0000186,0.0000118,0.000014,0.0000249,0.000041,0.0000441,0.0000389,0.0000294,0.0000229,0.0000181,0.0000288,0.0000353,0.0000345,0.0000305,0.0000209,0.0000173,0.0000371,0.000048,0.0000473,0.0000509,0.0000572,0.0000543,0.0000427,0.0000241,0.0000119,0.0000292,0.00004,0.0000388,0.0000293,0.0000444,0.0000464,0.0000519,0.000064,0.0000671,0.0000655,0.0000617,0.0000566,0.0000416,0.0000307,0.0000318,0.0000238],"detections":[{"range_m":2.798828,"snr_db":23.4996,"width_m":0.175182}],"noise_floor":0.000036393,"blast_amplitude":0.27,"saturated":false} +{"timestamp":1756162804.199996,"source":"simulated","device_id":"batvu-reference-01","beam":[0.6408564,0.6408564,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.000042,0.0000463,0.0000526,0.0000576,0.0000601,0.0000088,0.00003,0.0000426,0.000017,0,0.0000227,0.0000315,0.0000484,0.0000597,0.0000335,0.0000373,0.0000528,0.0000287,0.0000091,0.0000442,0.0000436,0.0000403,0.0000337,0.0000126,0.0000234,0.0000321,0.0000112,0,0.0000448,0.000044,0.0000388,0.0000494,0.000052,0.0000276,0.0000222,0.0000215,0,0.000012,0.0000283,0.0000276,0.000029,0.0000304,0.0000102,0.0000175,0.0000271,0.0000163,0.0000211,0.0000487,0.0000543,0.0000624,0.0000677,0.000052,0.0000266,0.0000166,5e-7,0.0000318,0.0000754,0.0000786,0.0000799,0.0000827,0.0000799,0.000049,0.0000395,0.0000385,0.0000197,0.0000104,0.0000051,0.0000196,0.0000431,0.0000576,0.0000577,0.0000697,0.0000759,0.0000636,0.0000537,0.0000598,0.0000501,0.0000522,0.000079,0.0000805,0.0000788,0.0000682,0.0000428,0.0000402,0.000072,0.0000711,0.0000826,0.0001083,0.0001099,0.000099,0.0000819,0.0000615,0.0000152,0.0000445,0.0000604,0.0000728,0.0000893,0.0000957,0.0000858,0.0000775,0.0000809,0.0000689,0.0000633,0.0000725,0.0000691,0.0000716,0.0000944,0.0001245,0.0002153,0.0003913,0.0006396,0.0009835,0.001398,0.0018853,0.0022342,0.0024565,0.0024892,0.0024486,0.0022334,0.0018752,0.0014222,0.0009835,0.0005633,0.0003003,0.0001334,0.0000459,0.0000039,0.0000059,0.0000102,0.0000036,0.0000088,0.0000394,0.0000523,0.0000588,0.0000607,0.0000538,0.0000435,0.000053,0.0000509,0.0000439,0.0000391,0.0000307,0.000011,0.0000412,0.0000469,0.0000524,0.0000638,0.0000666,0.0000616,0.0000578,0.000058,0.0000446,0.0000503,0.0000549,0.0000472,0.0000388,0.000052,0.0000537,0.0000525,0.0000379,0.0000074,0.0000137,0.0000149,0.0000043,0.00002,0.0000414,0.0000438,0.000053,0.0000634,0.0000621,0.0000493,0.0000512,0.0000537,0.0000474,0.0000414,0.0000366,0.0000188,0.0000239,0.0000304,0.0000275,0.0000183,0.0000138,0.0000253,0.0000315,0.0000326,0.0000238,0.0000316,0.0000638,0.0000818,0.0000947,0.0000971,0.0000885,0.0000631,0.0000457,0.0000374,0.0000205,0.0000061,0.0000093,0.0000189,0.0000314,0.0000349,0.0000409,0.0000528,0.0000545,0.0000455,0.0000387,0.000041,0.0000408,0.0000514,0.0000579,0.0000513,0.00004,0.0000309,0.0000162,0.0000144,0.0000261,0.0000249,0.0000149,0.0000197,0.0000277,0.0000302,0.0000315,0.0000303,0.0000415,0.0000568,0.000056,0.0000455,0.0000354,0.0000329,0.0000332,0.0000465,0.0000563,0.0000642,0.0000755,0.000079,0.0000704],"detections":[{"range_m":2.805746,"snr_db":22.5491,"width_m":0.171607}],"noise_floor":0.00004025,"blast_amplitude":0.2700157,"saturated":false} +{"timestamp":1756162804.2666626,"source":"simulated","device_id":"batvu-reference-01","beam":[0.7848856,0.4531539,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000042,0.0000021,0,0,0.0000034,0,0,0.0000311,0.0000769,0.0000752,0.0000929,0.0001051,0.0000942,0.000106,0.00011,0.0000436,0.0000491,0.0000731,0.0000629,0.0000505,0.0000439,0.0000045,0.0000186,0.0000592,0.0000576,0.0000486,0.0000348,0.0000043,0.0000047,0.0000242,0.0000194,0.0000203,0.0000202,0.0000025,0.0000034,0.0000216,0.0000245,0.0000015,0.0000182,0.000024,0.0000181,0.0000151,0.0000166,0.0000255,0.0000459,0.0000519,0.0000311,0.0000303,0.0000455,0.000038,0.0000291,0.0000341,0.0000343,0.0000492,0.000061,0.0000507,0.0000308,0.0000204,0.0000108,0.0000198,0.0000384,0.0000355,0.0000384,0.0000574,0.0000576,0.0000324,0.0000256,0.0000361,0.000043,0.000052,0.0000542,0.0000337,0.0000257,0.000042,0.0000462,0.0000496,0.0000515,0.0000346,0.0000262,0.0000295,0.0000172,0.0000132,0.000033,0.0000337,0.0000349,0.000036,0.0000269,0.000008,0.0000119,0.0000052,0.000004,0.0000359,0.0000423,0.0000441,0.0000445,0.000044,0.0000088,0.000026,0.0000438,0.0000461,0.0000451,0.0000375,0.000009,0.0000323,0.0000868,0.0001951,0.0003941,0.0006831,0.0010463,0.0014809,0.0020055,0.0023443,0.0025447,0.0025784,0.0025318,0.0022959,0.0019269,0.0014908,0.0009558,0.0005947,0.0003327,0.000145,0.000046,0.0000361,0.0000357,0.0000328,0.0000355,0.0000442,0.0000579,0.0000637,0.0000567,0.0000466,0.0000385,0.0000158,0.0000165,0.000039,0.000045,0.0000467,0.000047,0.0000406,0.0000293,0.000027,0.0000134,0,0.0000062,0.0000035,0.000008,0.0000264,0.0000263,0.0000225,0.0000327,0.0000352,0.0000262,0.0000201,0.0000257,0.0000237,0.0000184,0.0000185,0.0000396,0.0000617,0.0000744,0.0000747,0.000084,0.0000888,0.0000806,0.0000644,0.0000538,0.0000426,0.0000264,0.0000285,0.0000255,0.0000202,0.0000311,0.0000312,0.0000279,0.0000289,0.0000305,0.0000432,0.0000602,0.0000634,0.0000595,0.0000667,0.0000719,0.0000695,0.0000725,0.000075,0.0000669,0.000059,0.000056,0.0000454,0.0000316,0.0000362,0.0000363,0.0000397,0.0000442,0.0000411,0.0000427,0.0000561,0.0000561,0.0000498,0.0000405,0.000035,0.0000258,0.0000269,0.0000281,0.0000342,0.000051,0.0000535,0.0000487,0.0000502,0.0000539,0.0000514,0.0000537,0.0000548,0.0000429,0.0000307,0.0000263,0.0000173,0.0000049,0.0000012,0.0000051,0.0000321,0.0000647,0.0000831,0.0000964,0.0001021,0.0000998,0.0000825,0.0000672,0.0000509,0.000026,0.000008,0.0000072,0.0000105,0.0000177,0.0000226,0.0000288,0.0000392,0.0000432,0.000038],"detections":[{"range_m":2.677521,"snr_db":26.6231,"width_m":0.171607}],"noise_floor":0.000034531,"blast_amplitude":0.2700238,"saturated":false} +{"timestamp":1756162804.3333292,"source":"simulated","device_id":"batvu-reference-01","beam":[0.8754261,0.2345697,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000153,0.0000089,0,0.0000011,0.0000486,0.0000431,0.0000107,0,0.0000459,0.0000517,0.000054,0.0000556,0.000023,0.0000494,0.0000655,0.0000384,0.0000488,0.0000608,0.0000305,0.0000068,0.000047,0.0000569,0.0000616,0.0000642,0.0000305,0.0000322,0.0000702,0.0000652,0.0000544,0.0000446,0.0000158,0.0000136,0.0000249,0.0000158,0,0.0000232,0.0000321,0.0000302,0.0000296,0.0000293,0.0000195,0.0000317,0.000034,0.0000028,0.0000159,0.0000278,0.0000155,0,0.0000302,0.0000414,0.0000495,0.0000544,0.0000356,0.0000453,0.0000654,0.0000598,0.0000501,0.0000461,0.0000309,0.000006,0.0000392,0.000038,0.0000341,0.0000498,0.0000508,0.0000361,0.0000344,0.0000346,0.000027,0.0000345,0.0000365,0.0000151,3e-7,0.0000045,0.000003,0.0000323,0.00006,0.0000644,0.0000643,0.0000594,0.0000329,0.000009,0.0000241,0.0000215,0.0000168,0.000013,0.0000013,0.0000183,0.0000462,0.0000455,0.0000519,0.0000668,0.0000665,0.0000556,0.0001144,0.000262,0.0004823,0.0008169,0.0012403,0.0017389,0.0022532,0.0026604,0.0028811,0.0028855,0.0027666,0.0024215,0.0019476,0.0014489,0.0009772,0.0005767,0.0002837,0.000056,0.0000434,0.0000465,0.0000311,0.0000041,0.0000241,0.000024,0.0000206,0.0000449,0.0000508,0.0000555,0.0000594,0.00006,0.0000423,0.0000563,0.0000619,0.0000591,0.0000535,0.0000446,0.0000167,0.0000072,0.0000151,0.0000087,0.0000097,0.0000151,0.0000075,0.000001,0.0000278,0.0000337,0.0000391,0.000048,0.0000462,0.0000398,0.0000323,0.000029,0.0000529,0.0000824,0.0000832,0.0000773,0.000078,0.0000794,0.0000676,0.0000585,0.0000542,0.0000369,0.0000359,0.0000416,0.00004,0.0000398,0.0000397,0.0000311,0.0000415,0.00005,0.0000463,0.0000387,0.0000379,0.0000334,0.000029,0.000037,0.0000338,0.0000262,0.0000336,0.0000327,0.0000271,0.0000382,0.0000428,0.0000551,0.0000782,0.0000855,0.0000851,0.0000805,0.0000768,0.000066,0.0000585,0.0000554,0.0000432,0.000033,0.0000277,0.0000188,0.0000153,0.0000179,0.0000252,0.0000461,0.0000669,0.0000705,0.0000734,0.0000747,0.000072,0.0000554,0.000054,0.0000534,0.0000569,0.0000689,0.0000712,0.0000617,0.0000443,0.0000176,0.0000199,0.0000325,0.0000355,0.0000281,0.0000329,0.0000394,0.0000343,0.0000271,0.0000275,0.0000318,0.000049,0.0000657,0.0000656,0.0000597,0.0000433,0.0000199,0.0000121,0.0000278,0.0000279,0.0000279,0.0000399,0.0000399,0.0000374,0.0000405,0.0000412,0.0000284,0.0000203,0.0000379,0.0000553,0.0000658,0.0000685,0.0000602],"detections":[{"range_m":2.488204,"snr_db":27.0248,"width_m":0.175182}],"noise_floor":0.000034473,"blast_amplitude":0.2700443,"saturated":false} +{"timestamp":1756162804.3999958,"source":"simulated","device_id":"batvu-reference-01","beam":[0.9063078,0,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000094,0.0000182,0.0000281,0.0000366,0.0000448,0.000025,0,0.0000249,0.0000515,0.00004,0.0000462,0.0000508,0.0000202,0.0000445,0.0000671,0.0000534,0.0000456,0.0000463,0.0000188,0.0000341,0.0000453,0.0000167,0.0000324,0.0000653,0.0000561,0.0000392,0.0000245,0.0000042,0.0000114,0.0000201,0.000007,0.0000028,0.0000133,0.0000053,0,0.000022,0.0000312,0.0000232,0.0000106,0.0000131,0.00002,0.0000241,0.000025,0,0.0000346,0.0000526,0.0000526,0.0000508,0.0000458,0.0000166,0.0000185,0.0000251,0.0000028,0.0000021,0.0000284,0.000026,0.0000202,0.0000151,0.0000015,0.0000217,0.0000489,0.0000473,0.0000629,0.0000986,0.0001026,0.0000946,0.0000785,0.0000633,0.0000287,0.0000265,0.0000279,0.0000025,0.0000099,0.0000313,0.0000324,0.0000341,0.0000348,0.0000116,0,0.0000221,0.0000201,0.0000095,0.0000113,0.0000087,0.0000023,0.0000354,0.0000419,0.0000415,0.0000332,0.0001457,0.0003449,0.0006521,0.0010483,0.0015535,0.0021156,0.0026915,0.0029911,0.0030703,0.0030514,0.0028172,0.0024108,0.0019194,0.0013963,0.0008384,0.0004897,0.0002358,0.0000858,0.0000312,0.0000373,0.0000551,0.0000761,0.0000747,0.0000713,0.0000707,0.0000624,0.0000399,0.0000237,0.0000129,0.000023,0.000032,0.0000301,0.0000142,0.0000244,0.0000286,0.0000237,0.0000242,0.0000264,0.0000207,0.0000217,0.0000225,0.0000066,0.0000088,0.0000289,0.000032,0.0000372,0.0000415,0.0000331,0.0000182,0.0000144,0.0000139,0.000013,0.0000173,0.000016,0.0000141,0.000017,0.0000184,0.0000226,0.0000251,0.0000226,0.0000107,0.0000239,0.0000346,0.0000448,0.000053,0.0000546,0.000037,0.0000361,0.000042,0.0000399,0.0000519,0.0000675,0.0000718,0.0000767,0.0000792,0.0000721,0.0000783,0.000096,0.0000993,0.0001077,0.000121,0.0001192,0.0001094,0.0000952,0.0000738,0.0000393,0.0000337,0.0000414,0.0000552,0.000076,0.0000814,0.0000804,0.0000774,0.0000758,0.0000647,0.000066,0.0000702,0.0000697,0.0000841,0.0000977,0.0000977,0.0000952,0.0000885,0.0000671,0.0000468,0.0000481,0.0000463,0.0000424,0.0000438,0.0000417,0.0000286,0.000017,0.0000121,0.0000303,0.000052,0.0000589,0.0000614,0.0000662,0.0000668,0.0000524,0.0000393,0.0000323,0.0000204,0.0000265,0.0000408,0.0000476,0.0000577,0.000064,0.0000604,0.0000534,0.0000507,0.0000456,0.0000332,0.0000304,0.0000286,0.00003,0.0000391,0.0000386,0.0000307,0.000025,0.0000386,0.0000562,0.0000775,0.0000923,0.0001053,0.0001125,0.0001126,0.0000901,0.0000677,0.0000544,0.0000391],"detections":[{"range_m":2.388322,"snr_db":28.2226,"width_m":0.171607}],"noise_floor":0.000038313,"blast_amplitude":0.2700501,"saturated":false} +{"timestamp":1756162804.4666624,"source":"simulated","device_id":"batvu-reference-01","beam":[0.8754261,-0.2345697,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000054,0,0,0,0.0000706,0.0000748,0.0000646,0.0000454,0.0000296,0,0.0000192,0.0000339,0.0000247,0.0000427,0.0000575,0.0000299,0.0000157,0.0000175,0.0000095,0.0000078,0.0000167,0.000039,0.000059,0.0000694,0.0000386,0.0000386,0.0000835,0.0000808,0.0000713,0.0000594,0.0000206,0,0.0000206,0.0000253,0.0000357,0.0000454,0.0000446,0,0.0000162,0.000021,0.0000013,0.0000079,0.0000284,0.0000289,0.000033,0.0000347,0.0000079,0.0000061,0.0000323,0.0000346,0.0000351,0.0000348,0.0000016,0,0.0000215,0.0000176,0.0000284,0.0000604,0.0000591,0.0000456,0.0000273,0.0000235,0.0000533,0.0000864,0.0000894,0.0000899,0.0001066,0.0001065,0.0000827,0.0000632,0.0000534,0.0000255,0.0000214,0.0000267,0.0000165,0.0000347,0.0000498,0.0000483,0.0000487,0.0000509,0.000037,0.0000221,0.0000315,0.0000261,0.0000175,0.0000212,0.000014,0.0000016,0.0000477,0.0000481,0.000032,0.0001372,0.0003508,0.0006721,0.0011041,0.0017026,0.0022279,0.0026961,0.0029819,0.0030407,0.0030117,0.0027789,0.0023442,0.0017141,0.0012176,0.0007816,0.0004434,0.0002139,0.0000648,0.0000297,0.0000335,0.0000158,0,0.0000035,0.0000066,0.000018,0.0000371,0.0000439,0.0000654,0.0000965,0.0001004,0.0001001,0.0000966,0.0000905,0.0000626,0.0000549,0.0000582,0.0000568,0.0000592,0.0000592,0.0000384,0.0000209,0.0000336,0.000032,0.0000276,0.0000225,0.0000063,0,0.0000195,0.0000259,0.0000329,0.0000394,0.0000354,0.0000324,0.0000459,0.000046,0.0000523,0.0000826,0.0000893,0.0000907,0.0000905,0.0000888,0.0000695,0.000061,0.0000609,0.0000386,0.0000165,0.0000139,0.0000234,0.0000372,0.0000488,0.000048,0.0000453,0.0000399,0.0000237,0.0000154,0.0000362,0.0000454,0.000055,0.0000616,0.0000564,0.0000468,0.0000619,0.0000627,0.0000613,0.0000579,0.0000503,0.0000179,0.0000175,0.0000203,0.0000164,0.0000195,0.0000226,0.0000182,0.0000371,0.0000461,0.0000451,0.0000434,0.0000511,0.0000551,0.0000606,0.000064,0.0000566,0.0000544,0.0000665,0.0000662,0.0000643,0.0000667,0.0000655,0.0000662,0.000073,0.0000715,0.0000609,0.0000595,0.0000599,0.0000419,0.0000192,0.0000225,0.0000362,0.0000435,0.0000445,0.0000317,0.0000168,0.0000077,0.0000169,0.0000414,0.0000601,0.0000613,0.0000597,0.0000529,0.0000387,0.0000206,0.0000309,0.0000385,0.000048,0.000055,0.0000533,0.0000402,0.0000294,0.0000174,0.0000091,0.0000293,0.0000332,0.0000281,0.0000144,0.0000212,0.0000293,0.0000295,0.0000268,0.0000107],"detections":[{"range_m":2.422224,"snr_db":26.5946,"width_m":0.171607}],"noise_floor":0.000035016,"blast_amplitude":0.2699404,"saturated":false} +{"timestamp":1756162804.533329,"source":"simulated","device_id":"batvu-reference-01","beam":[0.7848856,-0.4531539,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000537,0.0000461,0.0000269,0.0000053,0.0000255,0.0000393,0.0000395,0.0000317,0.0000194,0.0000162,0.0000284,0.0000344,0.0000243,0.0000459,0.0000587,0.0000233,0.0000094,0.0000198,0.0000097,0.000007,0.0000176,0.0000201,0.0000373,0.000054,0.0000344,0.0000269,0.0000583,0.0000541,0.0000511,0.0000538,0.0000346,0.0000421,0.0000916,0.0000903,0.0000801,0.0000653,0.0000496,0.0000094,0.0000224,0.0000263,0.0000284,0.0000311,0.0000302,0.0000014,0.0000244,0.0000296,0.0000067,0.0000347,0.0000606,0.0000591,0.0000538,0.0000469,0.0000085,0,0.0000284,0.0000282,0.0000298,0.0000319,0.0000215,0,0.0000048,0.0000056,0.0000195,0.0000512,0.0000554,0.0000588,0.0000661,0.0000643,0.000043,0.0000645,0.0000705,0.0000543,0.0000291,0.0000381,0.000053,0.00006,0.0000605,0.0000349,0.0000211,0.0000379,0.0000358,0.0000381,0.0000438,0.0000347,0.000032,0.000048,0.0000459,0.0000385,0.0000393,0.0000318,0.0000286,0.0000617,0.0000687,0.0000705,0.0000719,0.0000723,0.0000466,0.0000532,0.0000548,0.0000427,0.0001954,0.0004381,0.000846,0.0012847,0.0017556,0.0022024,0.0025672,0.0027581,0.0027669,0.0026761,0.0023823,0.0018377,0.0013333,0.0008945,0.0005539,0.000305,0.0001537,0.0000753,0.0000389,0.0000435,0.0000414,0.0000322,0.0000457,0.0000489,0.0000478,0.0000756,0.0000875,0.000092,0.0000952,0.0000956,0.0000776,0.0000608,0.0000667,0.0000616,0.0000545,0.0000461,0.0000256,0.0000132,0.000019,0.0000137,0.0000062,0.0000277,0.0000337,0.0000423,0.0000518,0.0000503,0.0000352,0.0000334,0.000042,0.0000552,0.0000663,0.0000679,0.000054,0.0000467,0.0000485,0.000033,0.0000194,0.0000137,0.0000081,0.0000292,0.0000556,0.0000669,0.0000723,0.0000721,0.0000557,0.0000303,0.0000135,0.000017,0.000025,0.0000291,0.0000227,0.0000083,0.0000212,0.0000278,0.0000506,0.0000855,0.0001057,0.0001093,0.0001091,0.0001034,0.0000759,0.0000555,0.0000475,0.0000315,0.0000269,0.0000394,0.0000427,0.0000429,0.000041,0.0000253,0.0000097,0.0000205,0.0000194,0.0000225,0.0000279,0.0000238,0.000011,0.0000251,0.0000283,0.0000279,0.0000206,0.0000121,0.0000157,0.0000179,0.0000179,0.0000064,0.0000144,0.0000154,0.0000043,0.0000212,0.0000317,0.0000318,0.0000299,0.0000335,0.0000415,0.0000543,0.0000659,0.000075,0.0000901,0.0001006,0.0000983,0.0000867,0.0000745,0.0000654,0.0000542,0.0000502,0.0000458,0.0000295,0.0000213,0.0000179,0.0000121,0.0000378,0.0000497,0.0000505,0.0000479,0.0000391,0.0000139,0.000009,0.0000153,0.0000152],"detections":[{"range_m":2.579358,"snr_db":23.8892,"width_m":0.171607}],"noise_floor":0.000034386,"blast_amplitude":0.26999,"saturated":false} +{"timestamp":1756162804.5999956,"source":"simulated","device_id":"batvu-reference-01","beam":[0.6408564,-0.6408564,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000347,0.0000438,0.0000453,0.0000442,0.0000441,0.00003,0.0000177,0.0000221,0.0000232,0,0.0000248,0.0000353,0.0000097,0.0000091,0.0000341,0.0000321,0.0000312,0.0000334,0.0000068,0.0000185,0.0000285,0.0000053,0.0000305,0.0000568,0.0000442,0.0000546,0.0000889,0.0000811,0.0000601,0.0000383,0,0.0000048,0.0000125,0.0000071,0.0000091,0.000026,0.0000297,0.0000204,0.0000523,0.0000591,0.0000568,0.0000651,0.0000686,0.0000462,0.0000375,0.0000389,0.0000108,0.0000106,0.000029,0.0000299,0.0000435,0.0000596,0.0000481,0.0000333,0.0000225,0,0.000014,0.0000402,0.0000382,0.000033,0.0000441,0.0000397,0.0000203,0.0000076,0.0000299,0.0000489,0.0000628,0.0000618,0.0000389,0.0000497,0.0000575,0.0000518,0.0000458,0.0000394,0.0000056,0.0000212,0.00004,0.0000449,0.0000522,0.000057,0.0000445,0.0000333,0.0000529,0.0000564,0.0000631,0.0000721,0.0000681,0.0000668,0.0000824,0.0000782,0.0000573,0.0000352,0.0000284,0.0000512,0.0000696,0.0000721,0.0000502,0.0000571,0.0000627,0.0000571,0.0000489,0.0000388,0.0000102,0.0000081,0.0000137,0.0000103,0.000013,0.0000139,0.0000299,0.000098,0.0002549,0.0004681,0.000799,0.0012238,0.0016632,0.0020685,0.0023614,0.0024576,0.0024431,0.0022855,0.0019796,0.001561,0.0011277,0.0007442,0.0004287,0.0002296,0.0001334,0.0000922,0.0000703,0.0000628,0.0000489,0.0000596,0.0000719,0.0000661,0.0000504,0.0000324,0.000026,0.0000464,0.0000658,0.000071,0.0000893,0.0001124,0.000115,0.0001178,0.0001209,0.0001181,0.0000977,0.0000837,0.0000721,0.000044,0.0000191,0.0000116,0.0000218,0.0000289,0.0000309,0.0000185,0.000036,0.0000482,0.0000518,0.0000611,0.0000691,0.0000655,0.0000556,0.0000436,0.0000317,0.0000431,0.0000586,0.0000583,0.0000576,0.0000615,0.0000565,0.0000424,0.0000329,0.0000312,0.00004,0.0000512,0.0000527,0.0000356,0.0000216,0.0000136,0.0000019,0.0000202,0.0000288,0.0000266,0.0000176,0.0000107,0.00002,0.0000264,0.0000292,0.0000225,0.000022,0.0000353,0.0000424,0.0000555,0.0000642,0.0000597,0.0000385,0.000032,0.0000547,0.0000743,0.0000815,0.0000796,0.0000664,0.0000654,0.000066,0.0000497,0.0000278,0.0000226,0.0000347,0.0000396,0.0000404,0.0000415,0.000065,0.0000817,0.0000815,0.0000783,0.0000772,0.0000684,0.00005,0.0000268,0.0000213,0.0000482,0.0000628,0.0000625,0.0000598,0.000057,0.0000501,0.0000298,0.0000197,0.0000188,0.000028,0.0000503,0.0000596,0.0000599,0.000056,0.0000451,0.00002,0.0000102,0.0000181,0.0000263],"detections":[{"range_m":2.756516,"snr_db":23.0926,"width_m":0.171607}],"noise_floor":0.000039018,"blast_amplitude":0.2699787,"saturated":false} +{"timestamp":1756162804.6666622,"source":"simulated","device_id":"batvu-reference-01","beam":[0.4531539,-0.7848856,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000111,0.0000167,0.0000198,0.0000249,0.0000511,0.0000403,0.0000061,0.0000073,0.0000337,0.0000354,0.0000512,0.0000572,0.0000275,0.0000704,0.0000979,0.0000888,0.0000797,0.0000747,0.0000254,0.000021,0.0000561,0.0000551,0.0000545,0.0000521,0.0000105,0.0000058,0.00005,0.0000498,0.0000526,0.0000573,0.0000396,0.0000427,0.0000759,0.0000713,0.0000562,0.0000895,0.000097,0.0000896,0.0000844,0.0000775,0.0000321,0.0000435,0.0000544,0.0000415,0.0000236,0.0000147,0.0000354,0.000054,0.0000596,0.0000361,0.0000219,0.0000469,0.0000431,0.0000442,0.0000571,0.0000512,0.0000445,0.0000416,0.0000253,0,0.0000075,0.0000026,0,0.0000067,0.0000085,0,0.0000194,0.0000279,0.0000282,0.0000264,0.0000196,0.0000128,0.0000314,0.0000384,0.0000232,0.0000355,0.0000481,0.0000424,0.0000318,0.0000219,0.0000162,0.0000302,0.0000384,0.000029,0.0000228,0.0000364,0.0000331,0.0000155,0.0000129,0.0000232,0.0000357,0.0000526,0.0000525,0.000038,0.0000258,0.0000173,0.0000306,0.0000606,0.000069,0.0000663,0.0000623,0.0000626,0.0000462,0.0000401,0.0000446,0.0000337,0.0000212,0.0000318,0.0000323,0.000033,0.0000329,0.000015,0.000004,0.0000518,0.0001265,0.0002861,0.0005458,0.0009447,0.0013461,0.0017613,0.0020998,0.0023256,0.0023928,0.0023821,0.0022009,0.001866,0.0013617,0.0009236,0.0005626,0.0003096,0.0001341,0.0000294,0.0000541,0.0000539,0.0000408,0.0000143,0.0000372,0.0000561,0.0000663,0.0000635,0.0000808,0.0001047,0.000106,0.0001059,0.0001037,0.0000945,0.0000628,0.0000385,0.0000327,0.0000291,0.0000272,0.0000223,0.000006,0.0000262,0.0000392,0.0000407,0.0000423,0.0000432,0.0000304,0.0000285,0.0000425,0.0000434,0.0000428,0.0000383,0.0000288,0.000032,0.0000374,0.0000334,0.0000328,0.0000465,0.0000453,0.0000399,0.000043,0.0000415,0.000029,0.0000155,0.0000175,0.0000322,0.0000454,0.0000478,0.0000416,0.0000365,0.0000341,0.0000236,0.0000224,0.0000233,0.0000139,0.0000234,0.0000319,0.0000303,0.0000311,0.0000359,0.0000303,0.0000266,0.0000369,0.0000399,0.0000493,0.0000625,0.0000625,0.000063,0.0000685,0.0000664,0.0000485,0.0000292,0.0000181,0.0000111,0.0000102,0.0000072,0,0.0000082,0.0000134,0.0000108,0.0000064,0.0000203,0.0000234,0.0000236,0.000022,0.0000101,0.0000029,0.0000181,0.0000189,0.000018,0.0000101,0.0000125,0.0000225,0.0000292,0.0000279,0.0000239,0.0000368,0.0000368,0.000034,0.0000321,0.0000319,0.000021,0.0000341,0.0000458,0.0000464,0.0000446,0.0000366,0.0000191],"detections":[{"range_m":2.846889,"snr_db":22.8317,"width_m":0.171607}],"noise_floor":0.000035156,"blast_amplitude":0.2700525,"saturated":false} +{"timestamp":1756162804.7333288,"source":"simulated","device_id":"batvu-reference-01","beam":[0.2345697,-0.8754261,0.4226183],"start_range_m":0.3,"range_step_m":0.018359375,"profile":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0000149,0.0000193,0.0000283,0.0000325,0.0000343,0.0000092,0.0000347,0.0000403,9e-7,0.0000555,0.0000936,0.0000941,0.0000929,0.0000868,0.0000184,0.0000071,0.0000419,0.0000348,0.0000592,0.0000892,0.0000811,0.0000824,0.0000964,0.0000786,0.000045,0.0000709,0.0000682,0.0000653,0.0000577,0.000028,0.0000097,0.0000385,0.0000416,0.0000163,0.0000457,0.0000496,0.0000203,0.000019,0.0000549,0.0000628,0.0000629,0.0000594,0.0000136,0,0.000016,0.0000085,0,0.0000088,0.000008,0.0000087,0.0000311,0.0000357,0.0000414,0.000045,0.0000343,0.000007,0.0000307,0.0000284,0.0000173,0.000019,0.0000201,0.0000167,0.0000446,0.0000521,0.0000586,0.0000658,0.0000673,0.000044,0.0000587,0.0000745,0.0000688,0.0000582,0.0000458,0.0000106,0.0000086,0.00003,0.0000282,0.0000202,0.0000157,0.0000237,0.0000411,0.0000584,0.0000564,0.0000733,0.000115,0.000116,0.000113,0.0001009,0.0000793,0.0000368,0.0000332,0.0000359,0.0000261,0.0000527,0.0000621,0.0000608,0.0000565,0.0000572,0.0000428,0.0000258,0.0000136,0.0000049,0.0000129,0.0000187,0.0000273,0.000107,0.0003059,0.0005509,0.0008874,0.0012892,0.0016919,0.0020647,0.0023346,0.0024101,0.002384,0.0021933,0.0018542,0.0014213,0.0010049,0.0006625,0.0003875,0.0002018,0.0000786,0.0000178,0.000016,0.0000179,0.0000042,0,0.0000041,0.0000101,0.0000394,0.0000671,0.0000693,0.0000666,0.0000527,0.0000275,0.000026,0.0000404,0.0000398,0.0000385,0.0000366,0.000024,0.0000137,0.0000462,0.0000507,0.0000574,0.0000675,0.0000693,0.000058,0.000061,0.0000672,0.0000638,0.0000716,0.0000777,0.0000755,0.0000734,0.0000712,0.0000555,0.0000327,0.0000157,0,0,0.0000053,0.0000024,0.0000136,0.0000527,0.0000711,0.0000846,0.0000894,0.0000864,0.0000653,0.0000589,0.0000607,0.0000513,0.0000478,0.0000496,0.0000587,0.0000742,0.0000795,0.0000721,0.0000577,0.0000462,0.0000355,0.0000304,0.000028,0.000015,0,0.00001,0.0000111,0.0000133,0.0000146,0.0000088,0.00001,0.0000308,0.0000373,0.0000531,0.0000761,0.0000833,0.000087,0.0000889,0.0000892,0.0000764,0.0000757,0.0000769,0.0000708,0.0000848,0.0000925,0.0000903,0.0000796,0.0000658,0.0000613,0.0000771,0.0000873,0.000089,0.0000979,0.0001064,0.0001034,0.0000872,0.0000578,0.0000244,0.0000167,0.0000392,0.0000437,0.0000471,0.0000536,0.0000528,0.0000537,0.0000633,0.0000654,0.0000612,0.0000597,0.0000604,0.0000472,0.0000351,0.0000333,0.0000406],"detections":[{"range_m":2.754237,"snr_db":26.402,"width_m":0.171607}],"noise_floor":0.000041085,"blast_amplitude":0.2699899,"saturated":false} diff --git a/v2/crates/wifi-densepose-rufield/tests/ultrasonic_gates.rs b/v2/crates/wifi-densepose-rufield/tests/ultrasonic_gates.rs new file mode 100644 index 000000000..f27440cc9 --- /dev/null +++ b/v2/crates/wifi-densepose-rufield/tests/ultrasonic_gates.rs @@ -0,0 +1,260 @@ +//! ADR-262 P4 gates for the second modality. +//! +//! Same shape as `p1_gates.rs`: round-trip, fusability, privacy-safety, +//! determinism. Not accuracy — nothing here claims an ultrasonic scan is +//! *right*, only that it is well-formed, signed, correctly classified, and that +//! the things it must refuse to do, it refuses. +//! +//! The fixture is not hand-written. `batvu_living_room.ultrasonic.jsonl` is +//! produced by BatVu's own `npm run artifacts` — a 72-ping simulated sweep, +//! written by its TypeScript emitter — and copied here verbatim. The same file +//! is a test fixture in `ruvnet/rufield`, so a schema drift between BatVu's +//! emitter and RuField's parser fails a build in one of three repositories +//! rather than an ingest in a deployment. + +use rufield_core::{FieldEvent, FusionEngine, InferenceQuery, Modality, PrivacyClass}; +use rufield_fusion::RuFieldFusion; +use rufield_provenance::{is_fusable, verify_event}; +use wifi_densepose_rufield::{ + network_egress_allowed, ScanSource, UltrasonicScan, ULTRASONIC_EGRESS_CLASS, +}; + +const SCAN: &str = include_str!("fixtures/batvu_living_room.ultrasonic.jsonl"); + +fn scan() -> UltrasonicScan { + UltrasonicScan::load(SCAN, "living_room", ScanSource::Simulated).expect("fixture loads") +} + +// ── round-trip ─────────────────────────────────────────────────────────────── + +#[test] +fn gate_round_trip_every_event_is_well_formed_and_serializes() { + let mut s = scan(); + assert_eq!(s.ping_count(), 72); + assert_eq!(s.device_id(), "batvu-reference-01"); + assert!(!s.calibration_id().is_empty()); + + let events = s.events().expect("events"); + assert_eq!(events.len(), 72); + + let mut previous = 0u64; + for event in &events { + event + .validate_evidence_at(event.timestamp_ns) + .expect("structural evidence invariants hold"); + + assert_eq!(event.tensor.modality, Modality::Ultrasonic); + assert_eq!(event.sensor.vendor, "batvu"); + assert_eq!(event.observation.zone_id.as_deref(), Some("living_room")); + + // Every profile value finite and non-negative. `FieldTensor::validate` + // checks only shape and axis rank, so a NaN would serialize to JSON + // `null` and then fail to deserialize as an f32 on the far side of the + // wire — the worst place to find it. The adapter rejects it at parse. + assert!( + event + .tensor + .values + .iter() + .all(|v| v.is_finite() && *v >= 0.0), + "profile values are finite and non-negative" + ); + + // Strictly increasing, which RuField's replay watermark requires in + // every trust mode and silently drops what does not satisfy. + assert!(event.timestamp_ns > previous); + previous = event.timestamp_ns; + + // serde round-trip, byte-stable. + let json = serde_json::to_string(event).expect("serializes"); + let back: FieldEvent = serde_json::from_str(&json).expect("deserializes"); + assert_eq!(*event, back); + } + + let mut ids: Vec<&str> = events.iter().map(|e| e.event_id.as_str()).collect(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), events.len(), "event ids are unique"); +} + +// ── fusability ─────────────────────────────────────────────────────────────── + +#[test] +fn gate_every_event_carries_a_signature_that_verifies() { + let mut s = scan(); + let events = s.events().expect("events"); + for event in &events { + verify_event(event).expect("ed25519 signature verifies"); + assert!(is_fusable(event)); + } + + // And tampering breaks it, so the signature is load-bearing rather than + // decorative. + let mut tampered = events[0].clone(); + tampered.tensor.values[0] = 999.0; + assert!(verify_event(&tampered).is_err()); +} + +#[test] +fn gate_fusion_ingests_every_event() { + let mut s = scan(); + let mut engine = RuFieldFusion::new(); + for event in s.events().expect("events") { + engine.ingest(event).expect("fusion accepts the event"); + } +} + +// ── privacy safety — the correctness item ──────────────────────────────────── + +#[test] +fn gate_coarse_scan_passes_the_egress_gate_intact() { + // The claim the module makes: configuring the adapter for the coarse output + // means the egress gate has nothing left to catch. If a submodule bump ever + // changes the adapter's default class, this is where it surfaces. + let mut s = scan(); + let all = s.events().expect("events").len(); + + let mut s = scan(); + let egress = s.egress_events().expect("egress events").len(); + + assert_eq!(all, 72); + assert_eq!( + egress, all, + "no event is dropped at the gate in coarse mode" + ); +} + +#[test] +fn gate_every_event_is_p1_on_both_tensor_and_observation() { + let mut s = scan(); + for event in s.events().expect("events") { + assert_eq!(event.tensor.privacy_class, ULTRASONIC_EGRESS_CLASS); + assert_eq!(event.observation.privacy_class, ULTRASONIC_EGRESS_CLASS); + // Both, because the guard is conjunctive over the pair — a P0 tensor + // under a P1 observation is exactly the composite leak the default + // guard exists to close. + assert!(network_egress_allowed(event.tensor.privacy_class, false)); + assert!(network_egress_allowed( + event.observation.privacy_class, + false + )); + } + assert_eq!(ULTRASONIC_EGRESS_CLASS, PrivacyClass::P1); +} + +#[test] +fn gate_ultrasonic_can_never_reach_the_identity_tiers() { + // P5 is only reachable through `identity_evidence`, which + // `validate_evidence_at` restricts to BLE advertisement RSSI — so an + // ultrasonic event carrying it is a hard validation failure rather than a + // policy question. The ceiling is structural. + let mut s = scan(); + for event in s.events().expect("events") { + assert!(event.tensor.privacy_class < PrivacyClass::P4); + assert!(event.observation.privacy_class < PrivacyClass::P4); + assert!(event.observation.identity_evidence.is_none()); + assert!(event.observation.channel_sounding_provenance.is_none()); + } +} + +#[test] +fn gate_no_event_claims_a_person() { + // The tempting mistake, refused deliberately. `presence` is one of exactly + // six feature keys the fusion window reads, and populating it would light + // up the shipped `person_present` rule. One transducer pair cannot + // distinguish a person from a coat over the back of a chair. + let mut s = scan(); + for event in s.events().expect("events") { + for forbidden in ["presence", "breathing_band", "posture_height", "transient"] { + assert!( + !event.observation.features.contains_key(forbidden), + "an ultrasonic event must not claim `{forbidden}`" + ); + } + for label in &event.observation.labels { + assert!( + !label.contains("person") && !label.contains("presence"), + "unexpected personhood label: {label}" + ); + } + } +} + +/// The honest negative result, asserted rather than avoided. +/// +/// Because nothing claims presence, a BatVu scan produces no fused inferences +/// under the shipped rules. Two independent reasons, and pinning both matters +/// because fixing only the first would look like progress and change nothing: +/// no rule lists `"ultrasonic"` among its inputs, and the engine's feature +/// vocabulary is entirely statements about a body, so `range_m` has nothing to +/// drive. RuField v0.1 has no predicate for static geometry. +#[test] +fn gate_no_inferences_and_that_is_the_correct_outcome() { + let mut s = scan(); + let mut engine = RuFieldFusion::new(); + for event in s.events().expect("events") { + engine.ingest(event).expect("ingest"); + } + let inferences = engine + .infer(&InferenceQuery { + zone_id: Some("living_room".into()), + labels: vec![], + track_id: None, + as_of_ns: None, + }) + .expect("infer"); + assert!( + inferences.is_empty(), + "no shipped rule can fire on a range-only sensor: {inferences:?}" + ); +} + +// ── trust tier ─────────────────────────────────────────────────────────────── + +#[test] +fn gate_a_recording_cannot_relabel_itself_into_a_higher_trust_tier() { + // The fixture declares `simulated`. An operator pointing a capture-trusting + // deployment at it gets a hard refusal rather than a silently upgraded + // tier — and the reverse is refused too, so neither direction is a quiet + // reinterpretation. + let err = UltrasonicScan::load(SCAN, "living_room", ScanSource::DeviceCapture) + .expect_err("must refuse"); + assert!( + err.to_string().contains("device_capture"), + "the refusal names the mismatch: {err}" + ); +} + +#[test] +fn gate_simulated_recordings_are_marked_synthetic() { + // Not cosmetic: `synthetic: true` is what keeps simulator output out of + // captured-replay and production trust, which reject it before any key + // lookup. Marking it otherwise to get it accepted is the §11 invariant + // violation ADR-262 forbids. + let mut s = scan(); + for event in s.events().expect("events") { + assert!(event.provenance.synthetic); + } +} + +// ── determinism ────────────────────────────────────────────────────────────── + +#[test] +fn gate_same_recording_yields_a_byte_identical_event_stream() { + let mut a = scan(); + let mut b = scan(); + let left = serde_json::to_string(&a.events().expect("events")).expect("serializes"); + let right = serde_json::to_string(&b.events().expect("events")).expect("serializes"); + assert_eq!(left, right); +} + +#[test] +fn gate_a_malformed_recording_is_refused_whole() { + // No partial ingest. A stream that half-replays and then dies is the worst + // outcome for a consumer, because it has already acted on the good prefix. + let mut bad = SCAN.lines().take(4).collect::<Vec<_>>().join("\n"); + bad.push_str("\n{\"timestamp\":1756162800,\"source\":\"simulated\",\"device_id\":\"x\"}\n"); + let err = + UltrasonicScan::load(&bad, "living_room", ScanSource::Simulated).expect_err("must refuse"); + assert!(err.to_string().contains("rejected")); +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/csi.rs b/v2/crates/wifi-densepose-sensing-server/src/csi.rs index 2aa2ef0d2..1eeadb6da 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/csi.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/csi.rs @@ -9,6 +9,17 @@ use crate::adaptive_classifier; use crate::types::*; use crate::vital_signs::VitalSigns; +const EDGE_MAX_PERSONS: u8 = 4; + +/// Person count is supporting evidence, never an independent occupancy claim. +/// Return zero and mark invalid for contradictory or out-of-range firmware. +fn sanitize_edge_person_count(presence: bool, raw: u8) -> (u8, bool) { + if raw > EDGE_MAX_PERSONS || (!presence && raw != 0) { + return (0, false); + } + (raw, true) +} + // ── ESP32 UDP frame parsers ───────────────────────────────────────────────── /// Parse a 32-byte edge vitals packet (magic 0xC511_0002). @@ -26,26 +37,63 @@ pub fn parse_esp32_vitals(buf: &[u8]) -> Option<Esp32VitalsPacket> { let breathing_raw = u16::from_le_bytes([buf[6], buf[7]]); let heartrate_raw = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]); let rssi = buf[12] as i8; - let n_persons = buf[13]; + let presence = (flags & 0x01) != 0; + let (n_persons, person_count_valid) = sanitize_edge_person_count(presence, buf[13]); let motion_energy = f32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]); let presence_score = f32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]); let timestamp_ms = u32::from_le_bytes([buf[24], buf[25], buf[26], buf[27]]); Some(Esp32VitalsPacket { node_id, - presence: (flags & 0x01) != 0, + presence, fall_detected: (flags & 0x02) != 0, motion: (flags & 0x04) != 0, breathing_rate_bpm: breathing_raw as f64 / 100.0, heartrate_bpm: heartrate_raw as f64 / 10000.0, rssi, n_persons, + person_count_valid, motion_energy, presence_score, timestamp_ms, }) } +#[cfg(test)] +mod edge_vitals_integrity_tests { + use super::*; + + fn packet(presence: bool, n_persons: u8) -> Vec<u8> { + let mut buf = vec![0u8; 32]; + buf[0..4].copy_from_slice(&0xC511_0002u32.to_le_bytes()); + buf[4] = 4; + buf[5] = u8::from(presence); + buf[13] = n_persons; + buf + } + + #[test] + fn contradictory_count_fails_closed() { + let parsed = parse_esp32_vitals(&packet(false, 4)).expect("valid packet"); + assert_eq!(parsed.n_persons, 0); + assert!(!parsed.person_count_valid); + } + + #[test] + fn bounded_present_count_is_preserved() { + let parsed = parse_esp32_vitals(&packet(true, 3)).expect("valid packet"); + assert_eq!(parsed.n_persons, 3); + assert!(parsed.person_count_valid); + } + + #[test] + fn out_of_range_count_fails_closed() { + let parsed = parse_esp32_vitals(&packet(true, 5)).expect("valid packet"); + assert_eq!(parsed.n_persons, 0); + assert!(!parsed.person_count_valid); + } +} + /// Parse a WASM output packet (magic 0xC511_0007 — reassigned per issue #928; /// the original 0xC511_0004 collided with ADR-063 fused vitals). pub fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> { diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index d05147883..ec87af92a 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -2203,11 +2203,21 @@ struct Esp32VitalsPacket { heartrate_bpm: f64, rssi: i8, n_persons: u8, + person_count_valid: bool, motion_energy: f32, presence_score: f32, timestamp_ms: u32, } +const EDGE_MAX_PERSONS: u8 = 4; + +fn sanitize_edge_person_count(presence: bool, raw: u8) -> (u8, bool) { + if raw > EDGE_MAX_PERSONS || (!presence && raw != 0) { + return (0, false); + } + (raw, true) +} + /// Parse a 32-byte edge vitals packet (magic 0xC511_0002). fn parse_esp32_vitals(buf: &[u8]) -> Option<Esp32VitalsPacket> { if buf.len() < 32 { @@ -2223,26 +2233,56 @@ fn parse_esp32_vitals(buf: &[u8]) -> Option<Esp32VitalsPacket> { let breathing_raw = u16::from_le_bytes([buf[6], buf[7]]); let heartrate_raw = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]); let rssi = buf[12] as i8; - let n_persons = buf[13]; + let presence = (flags & 0x01) != 0; + let (n_persons, person_count_valid) = sanitize_edge_person_count(presence, buf[13]); let motion_energy = f32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]); let presence_score = f32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]); let timestamp_ms = u32::from_le_bytes([buf[24], buf[25], buf[26], buf[27]]); Some(Esp32VitalsPacket { node_id, - presence: (flags & 0x01) != 0, + presence, fall_detected: (flags & 0x02) != 0, motion: (flags & 0x04) != 0, breathing_rate_bpm: breathing_raw as f64 / 100.0, heartrate_bpm: heartrate_raw as f64 / 10000.0, rssi, n_persons, + person_count_valid, motion_energy, presence_score, timestamp_ms, }) } +#[cfg(test)] +mod edge_vitals_integrity_tests { + use super::*; + + fn packet(presence: bool, n_persons: u8) -> Vec<u8> { + let mut buf = vec![0u8; 32]; + buf[0..4].copy_from_slice(&0xC511_0002u32.to_le_bytes()); + buf[4] = 4; + buf[5] = u8::from(presence); + buf[13] = n_persons; + buf + } + + #[test] + fn contradictory_count_fails_closed() { + let parsed = parse_esp32_vitals(&packet(false, 4)).expect("valid packet"); + assert_eq!(parsed.n_persons, 0); + assert!(!parsed.person_count_valid); + } + + #[test] + fn present_count_is_preserved() { + let parsed = parse_esp32_vitals(&packet(true, 3)).expect("valid packet"); + assert_eq!(parsed.n_persons, 3); + assert!(parsed.person_count_valid); + } +} + // ── ADR-040: WASM Output Packet (magic 0xC511_0007 — reassigned per #928) ───── /// Single WASM event (type + value). @@ -2319,6 +2359,7 @@ struct EdgeFusedVitalsPacket { heartrate_bpm: f32, rssi: i8, n_persons: u8, + person_count_valid: bool, /// `mmwave_type_t` enum value from firmware. mmwave_type: u8, /// 0-100 fusion quality score. @@ -2353,7 +2394,8 @@ fn parse_edge_fused_vitals(buf: &[u8]) -> Option<EdgeFusedVitalsPacket> { let breathing_raw = u16::from_le_bytes([buf[6], buf[7]]); let heartrate_raw = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]); let rssi = buf[12] as i8; - let n_persons = buf[13]; + let any_presence = (flags & 0x09) != 0; + let (n_persons, person_count_valid) = sanitize_edge_person_count(any_presence, buf[13]); let mmwave_type = buf[14]; let fusion_confidence = buf[15]; let motion_energy = f32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]); @@ -2373,6 +2415,7 @@ fn parse_edge_fused_vitals(buf: &[u8]) -> Option<EdgeFusedVitalsPacket> { heartrate_bpm: heartrate_raw as f32 / 10000.0, rssi, n_persons, + person_count_valid, mmwave_type, fusion_confidence, motion_energy, @@ -2435,6 +2478,7 @@ mod issue_928_magic_collision_tests { ); assert_eq!(pkt.rssi, -55); assert_eq!(pkt.n_persons, 1); + assert!(pkt.person_count_valid); assert_eq!(pkt.mmwave_type, 2); assert_eq!(pkt.fusion_confidence, 85); assert!((pkt.motion_energy - 0.42).abs() < 1e-6); @@ -7901,6 +7945,9 @@ async fn nodes_endpoint(State(state): State<SharedState>) -> Json<serde_json::Va "detected_device_type": detected_device_type, "device_type_evidence": device_type_evidence, "health": ns.latest_health.as_ref().map(|health| node_health_json(health, now)), + "person_count_valid": ns.edge_vitals + .as_ref() + .map(|vitals| vitals.person_count_valid), }) }) .collect(); @@ -8085,6 +8132,7 @@ async fn udp_receiver_task( "breathing_rate_bpm": vitals.breathing_rate_bpm, "heartrate_bpm": vitals.heartrate_bpm, "n_persons": vitals.n_persons, + "person_count_valid": vitals.person_count_valid, "motion_energy": vitals.motion_energy, "presence_score": vitals.presence_score, "rssi": vitals.rssi, @@ -8391,6 +8439,7 @@ async fn udp_receiver_task( "breathing_rate_bpm": fused.breathing_rate_bpm, "heartrate_bpm": fused.heartrate_bpm, "n_persons": fused.n_persons, + "person_count_valid": fused.person_count_valid, "fusion_confidence": fused.fusion_confidence, "mmwave": { "hr_bpm": fused.mmwave_hr_bpm, diff --git a/v2/crates/wifi-densepose-sensing-server/src/types.rs b/v2/crates/wifi-densepose-sensing-server/src/types.rs index ca30e5f34..a86ca8f2a 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/types.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/types.rs @@ -261,6 +261,9 @@ pub struct Esp32VitalsPacket { pub heartrate_bpm: f64, pub rssi: i8, pub n_persons: u8, + /// True only when the firmware count is protocol-bounded and consistent + /// with the packet presence flag. + pub person_count_valid: bool, pub motion_energy: f32, pub presence_score: f32, pub timestamp_ms: u32, diff --git a/vendor/rufield b/vendor/rufield index 43b1df3dc..995567283 160000 --- a/vendor/rufield +++ b/vendor/rufield @@ -1 +1 @@ -Subproject commit 43b1df3dc3c436a21e3fc85dc34475458a1eca9d +Subproject commit 995567283606b86594439d6594387ff551a21ede