mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-19 13:20:07 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d1a67d31b | ||
|
|
c19b846394 | ||
|
|
cf6e955196 | ||
|
|
dddc214a25 | ||
|
|
5da64c6fc5 | ||
|
|
1c088e67c2 | ||
|
|
1231088427 | ||
|
|
4d857a908f | ||
|
|
9b595b8333 | ||
|
|
944b7bea77 | ||
|
|
7f9df37380 | ||
|
|
f574450516 | ||
|
|
3621a211bd | ||
|
|
5eb8ddde12 | ||
|
|
fbecae4e02 | ||
|
|
de099c1f2d | ||
|
|
01460b98b0 | ||
|
|
26f444138e | ||
|
|
00737441c4 | ||
|
|
25d7be3c25 | ||
|
|
29b3968731 | ||
|
|
ed006abd58 | ||
|
|
5419bff2a8 | ||
|
|
f1ea275d5d | ||
|
|
5c50fa8588 | ||
|
|
f19938a7b6 | ||
|
|
db5a898372 | ||
|
|
b11422459e | ||
|
|
e5bf0d4892 |
No files matched your search
@@ -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<body>.*?)^\}}",
|
||||
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
|
||||
@@ -311,3 +311,9 @@ v2/crates/wifi-densepose-sensing-server/data/
|
||||
# ADR-324: wasm-bindgen output for ruview-offaxis is generated locally
|
||||
# (see the crate README); never commit generated artifacts.
|
||||
v2/crates/ruview-offaxis/pkg/
|
||||
|
||||
# fal.ai visual-teacher LoRA training data/weights -- regenerable, never
|
||||
# commit training images or model weights (ADR-353)
|
||||
scripts/fal-visual-teacher/training-data/
|
||||
scripts/fal-visual-teacher/training-data.zip
|
||||
scripts/fal-visual-teacher/output/
|
||||
@@ -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
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cognitum.one/seed">
|
||||
<img src="assets/ruview-seed.png" alt="RuView - WiFi DensePose" width="100%">
|
||||
<img src="assets/ruview-hero-h3-v3.gif" alt="RuView - WiFi DensePose — animated visualization of real-time pose estimation, breathing, and heart-rate sensing through WiFi" width="100%">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"endpoint": "fal-ai/flux-lora",
|
||||
"lora_url": "https://v3b.fal.media/files/b/0aa8c60d/OjaBs6Gi8pMhZqMo79iym_pytorch_lora_weights.safetensors",
|
||||
"prompt": "ruviewstyle room visualization, a living room with a glowing cyan pose skeleton overlay, wifi signal arcs radiating across the space, a vitals readout panel, dark tech aesthetic, synthetic sensor visualization",
|
||||
"scale": 1.0,
|
||||
"source_image_url": "https://v3b.fal.media/files/b/0aa8cf67/JH1qyC3GCKQ9ojkrAUXe9.png"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 585 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.5 MiB |
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,162 @@
|
||||
# ADR-353: fal.ai synthetic visual teacher LoRA
|
||||
|
||||
- **Status**: Accepted
|
||||
- **Date**: 2026-09-02
|
||||
- **Deciders**: ruv
|
||||
- **Owners**: RuView visualization / neural-styling maintainers
|
||||
- **Tags**: fal, lora, synthetic-visualization, display-only, cost-control
|
||||
- **Parent**: None
|
||||
- **Extends**: None
|
||||
- **Supersedes**: None
|
||||
|
||||
## Decision
|
||||
|
||||
RuView will use one real, fal.ai-trained LoRA fine-tune (a "synthetic visual
|
||||
teacher") to steer fal.ai image/video generation toward a consistent
|
||||
"RuView-style" room visualization — glowing pose overlays, WiFi signal arcs,
|
||||
vitals readout panels, dark tech aesthetic — instead of relying on ad hoc
|
||||
prompt engineering against a stock model.
|
||||
|
||||
**The architecture boundary is non-negotiable:**
|
||||
|
||||
> The implementation boundary is now clear: fal.ai will train an optional
|
||||
> synthetic visual teacher, while RuView's measured RF, pose, vitals,
|
||||
> identity, and confidence remain local and authoritative.
|
||||
|
||||
Concretely:
|
||||
|
||||
- The trained model produced here is ONLY ever allowed to generate
|
||||
supplementary synthetic visual/pixel output for display or broadcast.
|
||||
- It must NEVER be a source of, or influence, any measured/derived value: RF/
|
||||
CSI measurements, pose estimates, vitals (heart rate/breathing rate),
|
||||
identity, or confidence/quality scores. Those stay local, deterministic,
|
||||
and authoritative — the existing `wifi-densepose-engine` /
|
||||
`ruview-twin` / `ruview-witness` stack — completely untouched by this work.
|
||||
- Any output this model produces must be clearly framed as SYNTHETIC per this
|
||||
repo's rule: "Never present WiFi sensing as camera-grade."
|
||||
- This model has no read or write path into the RF/pose/vitals/identity
|
||||
pipeline. It consumes only a text prompt (and optionally a reference
|
||||
image) and returns pixels for display.
|
||||
|
||||
**Evidence status:** the training data extraction, training submission
|
||||
script, and validation script are real and committed. The training run
|
||||
completed and validation succeeded — trained model id, actual spend, and the
|
||||
validation sample are recorded in "Result" below. Actual-vs-billed cost
|
||||
reconciliation against the fal.ai account dashboard remains UNMEASURED (the
|
||||
figure below is a documented-pricing estimate).
|
||||
|
||||
## Context
|
||||
|
||||
A concurrent workstream (`ruview-fal-room-viz` / `ruview-room-viz-h3-upgrade`)
|
||||
generated real fal.ai room-visualization videos across several style presets
|
||||
(architectural, abstract, branded, and an "h3" refresh: architectural,
|
||||
cinematic, minimal, abstract) using stock fal.ai video models driven by
|
||||
prompt engineering alone. A separate concurrently-running agent
|
||||
("realtime-dual-renderer") is building a neural-styling layer that needs a
|
||||
*specific* trained model id to call, rather than continuing to rely on prompt
|
||||
engineering against a generic model.
|
||||
|
||||
This ADR's job is narrow: turn the real captured style presets into a real
|
||||
trained fal.ai LoRA, verify it actually works, and hand off the model id. It
|
||||
does not implement the renderer integration — that is
|
||||
`realtime-dual-renderer`'s scope — and it does not touch the measured
|
||||
RF/pose/vitals/identity/confidence pipeline in any way.
|
||||
|
||||
## Training data
|
||||
|
||||
Source: the real fal.ai-generated room-visualization MP4s already captured
|
||||
this session (read from `/tmp/room-viz-*/room-viz.mp4` on `ruvultra`, one per
|
||||
style preset — not committed to the repo; the repo has no camera or person
|
||||
data in these clips, they are synthetic renders). `scripts/fal-visual-teacher/extract_frames.py`
|
||||
extracts one JPEG frame every 1.5 seconds from each of the 7 real style-preset
|
||||
videos (architectural, abstract, branded, h3-architectural, h3-cinematic,
|
||||
h3-minimal, h3-abstract), producing 27 frames total, each paired with a
|
||||
caption `.txt` describing the shared RuView visual motifs. Frames and the
|
||||
training zip are gitignored (`scripts/fal-visual-teacher/training-data/`,
|
||||
`training-data.zip`) — they are regenerable from the source MP4s and are not
|
||||
committed, consistent with the repo's no-generated-artifacts rule.
|
||||
|
||||
## Training endpoint and pricing (verified 2026-09-02)
|
||||
|
||||
- **Endpoint**: `fal-ai/flux-lora-fast-training` — a real, currently-listed
|
||||
fal.ai LoRA training endpoint for FLUX.1, verified against
|
||||
`https://fal.ai/models/fal-ai/flux-lora-fast-training/api`.
|
||||
- **Pricing**: $2 per training run at the default 1000 steps, scaling
|
||||
linearly with steps (verified against fal.ai's own pricing text on the
|
||||
model page).
|
||||
- **Mode**: submitted with `is_style: true` (style LoRA, not
|
||||
subject/character LoRA) and explicit per-frame caption files, so fal.ai's
|
||||
auto-captioning/segmentation is skipped in favor of our own captions.
|
||||
- **Inference endpoint** (validation only): `fal-ai/flux-lora`, $0.035 per
|
||||
megapixel, accepting `loras: [{ path, scale }]` pointing at the trained
|
||||
`diffusers_lora_file` URL.
|
||||
|
||||
## Budget
|
||||
|
||||
- **Original cap**: $15 total (this task's cap only; not shared with other
|
||||
concurrent tasks' budgets this session).
|
||||
- **Raised cap**: the user explicitly raised this task's cap to **$100** on
|
||||
2026-09-02, mid-run, as real headroom for a larger/better run or retries —
|
||||
not an instruction to spend all of it.
|
||||
- **Planned spend**: ~$2 for the training run (1000 steps, default) + ~$0.04
|
||||
per validation inference image.
|
||||
- **Actual spend**: ~$2.04 (one 1000-step training run at $2/run + one
|
||||
landscape_16_9 validation image at $0.035/megapixel, rounded up to 1
|
||||
megapixel). This is a documented-pricing estimate (`CLAIMED`), not yet
|
||||
reconciled against the fal.ai account billing dashboard (`MEASURED`).
|
||||
Comfortably under both the original $15 cap and the raised $100 cap.
|
||||
- **Why no larger follow-up run**: the first validation image already showed
|
||||
a clean, on-style result (glowing pose overlay, WiFi arcs, vitals panels,
|
||||
dark aesthetic, all present and legible) on the first attempt. Spending
|
||||
further into the raised budget for marginal improvement was judged not
|
||||
worth it against a result that already meets the bar; the fallback path in
|
||||
"Consequences" was not needed.
|
||||
|
||||
## Result
|
||||
|
||||
- **Trained model id / LoRA weights URL**:
|
||||
`https://v3b.fal.media/files/b/0aa8c60d/OjaBs6Gi8pMhZqMo79iym_pytorch_lora_weights.safetensors`
|
||||
(131 MB `diffusers_lora_file`, paired `config.json` at
|
||||
`https://v3b.fal.media/files/b/0aa8c60d/-GU6dd_2ZFqq18I0CuPOB_config.json`).
|
||||
Use with `fal-ai/flux-lora` (or any fal.ai FLUX.1 endpoint accepting a
|
||||
`loras: [{ path, scale }]` array) — trigger word `ruviewstyle`. Full
|
||||
training record: `scripts/fal-visual-teacher/output/training-result.json`
|
||||
(gitignored — regenerate by re-running `train_lora.py`, or copy the URL
|
||||
above).
|
||||
- **Training request id**: `01a060b2-c940-7972-abf9-ea48847eb2ae` (fal.ai
|
||||
`fal-ai/flux-lora-fast-training`, 1000 steps, `is_style: true`, 27 training
|
||||
images, completed in 376s on an H100/H200 worker).
|
||||
- **Actual spend**: ~$2.04 (see Budget above).
|
||||
- **Validation sample**: `assets/fal-visual-teacher-sample.png` (+ sibling
|
||||
`assets/fal-visual-teacher-sample.json` with the exact prompt, LoRA scale
|
||||
1.0, and source fal.ai URL) — a real `fal-ai/flux-lora` inference call
|
||||
using the trained LoRA, generated 2026-09-02. The image shows a glowing
|
||||
cyan pose skeleton, WiFi signal arcs, vitals-style readout panels, and a
|
||||
HUD-style TV overlay in a dark living room — confirming the LoRA learned
|
||||
and reproduces the intended RuView visual motifs.
|
||||
- **Consumer**: `realtime-dual-renderer` (separate worktree
|
||||
`~/projects/ruview-worktrees/realtime-render`) — notified directly with the
|
||||
model id above.
|
||||
|
||||
## Consequences
|
||||
|
||||
A real trained LoRA gives the display layer a specific, repeatable style
|
||||
target instead of prompt engineering against a stock model, at a bounded and
|
||||
small real cost ($2 training + cents per generated image). Because the model
|
||||
only ever produces pixels for a prompt, and has no schema path into any
|
||||
measured/derived RuView value, it cannot silently become a second source of
|
||||
truth for pose, vitals, identity, or confidence — that boundary is structural
|
||||
(no shared types, no shared data path), not just documented convention.
|
||||
|
||||
If the trained LoRA does not meaningfully improve style consistency over the
|
||||
stock model (a genuine risk with 27 training images and default steps), the
|
||||
fallback is to keep using prompt engineering against the stock model and note
|
||||
that outcome honestly here rather than claim a training win that isn't real.
|
||||
|
||||
## References
|
||||
|
||||
- `scripts/fal-visual-teacher/extract_frames.py` — real frame extraction
|
||||
- `scripts/fal-visual-teacher/train_lora.py` — real training submission
|
||||
- `scripts/fal-visual-teacher/validate.py` — real validation inference
|
||||
- [fal.ai Flux LoRA Fast Training](https://fal.ai/models/fal-ai/flux-lora-fast-training)
|
||||
- [fal.ai FLUX.1 with LoRAs (inference)](https://fal.ai/models/fal-ai/flux-lora)
|
||||
@@ -0,0 +1,89 @@
|
||||
# ADR-354: fal.ai forecast governance and spend enforcement now real, in the vendored RuForecast submodule
|
||||
|
||||
- **Status**: Accepted
|
||||
- **Date**: 2026-09-02
|
||||
- **Deciders**: ruv
|
||||
- **Owners**: RuView forecast training, security, and release maintainers
|
||||
- **Tags**: forecasting, training, fal, governance, submodule, cost-control
|
||||
- **Parent**: ADR-349
|
||||
- **Extends**: ADR-348, ADR-349
|
||||
- **Supersedes**: None
|
||||
|
||||
## Decision
|
||||
|
||||
ADR-349 specified two governance controls for RuView Forecast's fal.ai
|
||||
hosted path: operator-verified dataset/schema/policy/recipe authorization,
|
||||
and an explicit local spend-approval record bound to the request digest and
|
||||
maximum units. A 9-agent code review of the training stack merged as PR
|
||||
#1766 found that neither control was actually wired into the code path that
|
||||
builds a submittable, fal-destined training contract — `FalGovernanceVerifier`
|
||||
existed with zero callers outside its own tests, and no spend-approval type
|
||||
existed anywhere in the codebase.
|
||||
|
||||
That code no longer lives directly in this repository. Between the review
|
||||
and this fix, `v2/crates/ruview-forecast-{core,model,train}` was extracted
|
||||
into its own repository, `github.com/ruvnet/RuForecast`, and is now vendored
|
||||
back into `v2/crates/ruforecast` as a git submodule (crates renamed to
|
||||
`ruforecast-{core,model,train}`). This ADR records two things at the RuView
|
||||
level:
|
||||
|
||||
1. **The governance and spend-approval gaps are fixed**, in
|
||||
`ruvnet/RuForecast` PR #2 (branch `fix/governance-and-spend-enforcement`).
|
||||
The full technical decision record — what changed, why, and the
|
||||
requirements/acceptance table — lives in that repository's own
|
||||
[ADR-350](https://github.com/ruvnet/RuForecast/blob/main/docs/adr/ADR-350-fal-governance-and-spend-enforcement.md),
|
||||
which this ADR defers to rather than duplicating. In summary: a
|
||||
fal-destined `TrainSpec` can no longer be constructed without a real,
|
||||
operator-signed, single-use-verified governance receipt; a hosted
|
||||
reservation can no longer be built without a real, operator-signed spend
|
||||
approval bound to the exact request digest and cost ceiling; and several
|
||||
related correctness/resilience gaps on the same trust boundary
|
||||
(unauthenticated Direct Server webhook, no execution timeout, mismatched
|
||||
local/hosted artifact budgets, cancellation permanently wedging a job)
|
||||
were fixed in the same pass.
|
||||
2. **The submodule pointer in this repository is bumped** to the commit
|
||||
containing that fix, once `ruvnet/RuForecast`'s own CI is green on that
|
||||
PR and it merges. This RuView-side change is deliberately small: a
|
||||
pointer bump plus this ADR, not a re-implementation or re-review of code
|
||||
that now lives, is tested, and is reviewed in its own repository.
|
||||
|
||||
## Context
|
||||
|
||||
RuView's own copies of ADR-348 and ADR-349 predate the extraction and still
|
||||
describe the crates as if they lived directly under `v2/crates/`. They are
|
||||
left as-is here (not corrected path-by-path) since the canonical, actively
|
||||
maintained copies of the forecast-specific ADRs now live in
|
||||
`ruvnet/RuForecast`'s own `docs/adr/`, which was seeded with copies of
|
||||
ADR-348/349 at extraction time and now also carries ADR-350. Consult that
|
||||
repository for the forecast subsystem's own decision history going forward;
|
||||
this repository's ADR series should record RuView-level integration
|
||||
decisions about the vendored submodule (like this one), not attempt to
|
||||
re-host the submodule's internal history.
|
||||
|
||||
This repo's CLAUDE.md requires an ADR for load-bearing upstream changes.
|
||||
The governance-enforcement fix is load-bearing for RuView even though none
|
||||
of its source lines are: it changes what RuView's vendored forecast
|
||||
subsystem actually requires before it will reach real, billable fal.ai
|
||||
infrastructure on behalf of a RuView deployment.
|
||||
|
||||
## Consequences
|
||||
|
||||
Once the submodule pointer here is bumped, any RuView-side code, CLI
|
||||
wrapper, or documentation that assumes the old, unenforced fal.ai
|
||||
submission path (a bare `SourceState::synthetic` claim with no verified
|
||||
governance receipt or spend approval) will no longer compile or run against
|
||||
the vendored crate — `TrainSpec::new_fal_synthetic` and
|
||||
`ReservedSyntheticSubmission::reserve` both gained new required parameters.
|
||||
A repository-wide search found no such RuView-side caller today; this ADR
|
||||
records the change in case one is added later without checking the
|
||||
submodule's current API first.
|
||||
|
||||
This pointer bump must not merge before `ruvnet/RuForecast` PR #2 merges and
|
||||
its CI is green — see that PR for status.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-349](./ADR-349-governed-local-and-fal-forecast-training.md) (this repository's copy; canonical copy is in `ruvnet/RuForecast`)
|
||||
- [ruvnet/RuForecast PR #2](https://github.com/ruvnet/RuForecast/pull/2)
|
||||
- [ruvnet/RuForecast ADR-350](https://github.com/ruvnet/RuForecast/blob/main/docs/adr/ADR-350-fal-governance-and-spend-enforcement.md)
|
||||
- [`v2/crates/ruforecast`](../../v2/crates/ruforecast) (submodule)
|
||||
@@ -92,6 +92,10 @@ 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 |
|
||||
| [ADR-354](ADR-354-forecast-governance-enforcement-in-ruforecast-submodule.md) | fal.ai forecast governance and spend enforcement now real, in the vendored RuForecast submodule | Accepted |
|
||||
|
||||
### Platform and UI
|
||||
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
# 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 <genome>.json --parent-genome
|
||||
<baseline>.json --judges 2 --work-dir <scratch>`) 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.
|
||||
|
||||
### Zero-shot pretrained-checkpoint comparison (2026-09-02)
|
||||
|
||||
Every RuForecast result above this line is a **from-scratch-trained** model
|
||||
(RuForecast's own Burn/Rust architecture, trained on a tiny real or synthetic
|
||||
corpus) evaluated against trivial baselines. All of them lost. A literature
|
||||
pass after the BIDMC result above (arXiv:2505.11349, "context parroting";
|
||||
arXiv:2607.20027, zero-shot HRV forecasting with TimesFM/Chronos/MOIRAI on
|
||||
wearables) converged on an explanation: published foundation-model wins over
|
||||
naive baselines on short-horizon, smooth physiological signals come from
|
||||
**massive real pretraining corpora** (Chronos: ~10M real+augmented series;
|
||||
TimesFM: ~100B real time points), not from architecture alone — and
|
||||
RuForecast's from-scratch training used a real corpus many orders of
|
||||
magnitude smaller (tens to low hundreds of windows), so re-implementing
|
||||
these architectures from scratch and training small was never going to
|
||||
replicate their wins.
|
||||
|
||||
This test checks the other half of that hypothesis directly: does a **real,
|
||||
public, pretrained checkpoint**, run **zero-shot** (no fine-tuning, no
|
||||
training of any kind on this repo's data) on the **exact same BIDMC
|
||||
cross-entity test windows and metrics** as the BIDMC result above, actually
|
||||
beat the naive baselines? If the massive-pretraining-corpus explanation is
|
||||
right, it should — unlike every from-scratch attempt this session.
|
||||
|
||||
**Setup (kept identical to the BIDMC test above for a fair comparison):**
|
||||
same `/tmp/bidmc-raw/` cache (53 real ICU patients, PhysioNet BIDMC, Open
|
||||
Data Commons Attribution License v1.0), same window construction (context
|
||||
64s + horizon 12s, 1 Hz, variates HR/RESP/SpO2, one window per patient from
|
||||
that patient's first 76 rows), same two disjoint patient-partition judges (A:
|
||||
train 1-37/test 38-53 contiguous; B: train odd/test even interleaved — train
|
||||
patients are irrelevant here since nothing is trained, only the *test*
|
||||
partition is used), same quantile set
|
||||
`[0.05,0.10,0.25,0.50,0.75,0.90,0.95]`, same seasonal period (12), and the
|
||||
same WQL formula (`2*sum(pinball)/(numQuantiles*sum|actual|)`) and MAE
|
||||
definition as `ruforecast-core::metrics.rs`. Baselines were independently
|
||||
re-derived from `ruforecast-core::baseline.rs`'s exact logic in Python and
|
||||
cross-checked against the BIDMC section above: reproduced last-value WQL was
|
||||
0.01171 (A) / 0.01013 (B) versus the Rust CLI's originally reported
|
||||
0.01159 / 0.01002 — a ~1% gap attributable to f32 (Rust) vs f64 (Python)
|
||||
accumulation, not a methodology difference, so this reproduction is treated
|
||||
as validated.
|
||||
|
||||
Four real, public, pretrained-checkpoint models were run zero-shot on GPU
|
||||
(RTX 5080, `ruvultra`), per-variate univariate (none of these checkpoints has
|
||||
a native multivariate mode for these two families, so HR/RESP/SpO2 were
|
||||
forecast independently and recombined into the same window-level WQL/MAE as
|
||||
the baselines and the from-scratch RuForecast model):
|
||||
|
||||
- **Chronos-Bolt base/small** (`amazon/chronos-bolt-base`,
|
||||
`amazon/chronos-bolt-small`) — direct quantile-regression heads,
|
||||
deterministic given input (no sampling).
|
||||
- **Chronos (T5) small** (`amazon/chronos-t5-small`) — the original
|
||||
sampling-based Chronos family; `predict_quantiles` internally draws
|
||||
samples (default `num_samples`), so this one model's numbers below carry
|
||||
run-to-run sampling variance the other three don't.
|
||||
- **TimesFM 2.5 (200M, PyTorch)** (`google/timesfm-2.5-200m-pytorch`) —
|
||||
quantile output uses 10 channels; per the installed package's source
|
||||
(`timesfm_2p5_base.py`, `decode_index: int = 5`) and empirical
|
||||
verification across multiple windows/variates, channel 5 is the point
|
||||
forecast and channels 1-9 are the native quantile levels [0.1..0.9] in
|
||||
order (channel 0 is an unused duplicate, not a real quantile). Levels 0.05
|
||||
and 0.95 were clamped to the nearest native level (0.10/0.90); 0.25 and
|
||||
0.75 were linearly interpolated between the adjacent native levels — the
|
||||
same class of approximation Chronos-Bolt's own pipeline applies
|
||||
internally when asked for quantile levels outside its trained range (a
|
||||
warning it printed for 0.05/0.95 on every call).
|
||||
|
||||
| Judge | Model | Test windows | MAE | WQL | WQL vs last-value |
|
||||
|---|---|---:|---:|---:|---:|
|
||||
| A | last-value (baseline) | 16 | 0.7882 | 0.01171 | — |
|
||||
| A | seasonal-naive (baseline) | 16 | 1.0885 | 0.01617 | worse |
|
||||
| A | Chronos-Bolt-base (zero-shot) | 16 | 0.7802 | 0.00756 | **-35.4%** |
|
||||
| A | Chronos-Bolt-small (zero-shot) | 16 | 0.7212 | 0.00684 | **-41.6%** |
|
||||
| A | Chronos-T5-small (zero-shot) | 16 | 0.9487 | 0.00955 | **-18.4%** |
|
||||
| A | TimesFM-2.5-200M (zero-shot) | 16 | 0.7490 | 0.00731 | **-37.6%** |
|
||||
| B | last-value (baseline) | 26 | 0.6944 | 0.01013 | — |
|
||||
| B | seasonal-naive (baseline) | 26 | 0.8697 | 0.01269 | worse |
|
||||
| B | Chronos-Bolt-base (zero-shot) | 26 | 0.7096 | 0.00719 | **-29.0%** |
|
||||
| B | Chronos-Bolt-small (zero-shot) | 26 | 0.6852 | 0.00676 | **-33.3%** |
|
||||
| B | Chronos-T5-small (zero-shot) | 26 | 0.8254 | 0.00886 | **-12.5%** |
|
||||
| B | TimesFM-2.5-200M (zero-shot) | 26 | 0.6511 | 0.00698 | **-31.1%** |
|
||||
|
||||
**Honest result: this is the first test all session where a model beats the
|
||||
trivial baselines, and it does so decisively and consistently** — all four
|
||||
pretrained checkpoints beat both last-value and seasonal-naive on WQL, on
|
||||
both independently-partitioned judges, by double-digit-to-40% margins. This
|
||||
is a materially larger and more consistent margin than the closest published
|
||||
direct analog found in the literature pass (zero-shot HRV forecasting,
|
||||
arXiv:2607.20027: Chronos beat EWMA by ~3%, MOIRAI showed no significant
|
||||
advantage over a mean baseline, p=0.953) — plausibly because BIDMC's 1 Hz
|
||||
ICU HR/RESP/SpO2 signals are smoother and more autocorrelated than the HRV
|
||||
series in that paper, which is itself a fair, disclosed difference between
|
||||
the two comparisons rather than a claim of a stronger general result.
|
||||
|
||||
On MAE (point accuracy) the picture is more mixed: Chronos-Bolt-small and
|
||||
TimesFM beat last-value's MAE on both judges, but Chronos-Bolt-base and
|
||||
Chronos-T5-small do not consistently (e.g. Chronos-Bolt-base's MAE is
|
||||
essentially tied with last-value on judge A, worse on judge B, despite a
|
||||
much better WQL there) — the pretrained models' advantage is concentrated in
|
||||
calibrated quantile spread (what WQL scores), not in shifting the central
|
||||
point forecast much past what last-value already gets from strong
|
||||
autocorrelation in these signals.
|
||||
|
||||
**This directly confirms the massive-pretraining-corpus hypothesis from the
|
||||
literature pass**: the same short-horizon, smooth physiological-signal task
|
||||
on which every from-scratch RuForecast configuration this session lost to
|
||||
naive baselines is won, cleanly, by checkpoints that carry a pretraining
|
||||
corpus many orders of magnitude larger than any real dataset available to
|
||||
this project. This is evidence *for* zero-shot/foundation-model inference
|
||||
(or later, fine-tuning a pretrained checkpoint) as the credible next lever
|
||||
for RuForecast-style short-horizon vitals forecasting — not for continuing
|
||||
to scale from-scratch training on this project's own small real corpora,
|
||||
which the BIDMC and household tests above already showed does not close
|
||||
this gap even with genuine, cross-entity real data.
|
||||
|
||||
**Scope and caveats, stated plainly:**
|
||||
- This is zero-shot inference only — no fine-tuning was attempted on any
|
||||
checkpoint. A fine-tuned comparison (e.g. LoRA-adapting Chronos-Bolt on
|
||||
RuForecast's own training windows) is a natural, not-yet-run follow-up.
|
||||
- Univariate-per-variate forecasting was used for all four models because
|
||||
neither Chronos nor TimesFM in these releases has a native multivariate
|
||||
mode matching RuForecast's joint HR/RESP/SpO2 modeling; a true
|
||||
multivariate foundation model (e.g. MOIRAI, which does support cross-variate
|
||||
attention) was not tested here and is a natural next step.
|
||||
- Chronos-T5-small's `predict_quantiles` samples internally (unlike
|
||||
Chronos-Bolt's direct quantile heads or TimesFM's quantile head); its
|
||||
numbers above are one run and carry unquantified sampling variance the
|
||||
other three models' numbers don't.
|
||||
- No inference-cost/latency comparison against RuForecast's own (much
|
||||
smaller, ~tens-of-KB) model is included; all four pretrained checkpoints
|
||||
are orders of magnitude larger (small-to-200M+ parameters) and ran on a
|
||||
discrete GPU (RTX 5080), not the ESP32-class edge target RuForecast's own
|
||||
architecture is designed for. A model that wins on WQL here is not
|
||||
automatically deployable to the same edge budget RuForecast targets — that
|
||||
tradeoff is unresolved by this test and is itself a reason zero-shot
|
||||
inference (call out to a hosted/cloud checkpoint) and from-scratch
|
||||
small-model training remain different tools for different deployment
|
||||
constraints, not a strict replacement of one by the other.
|
||||
|
||||
Reproducer: `scripts/ruforecast-zeroshot/bidmc_windows.py` (window builder,
|
||||
baselines, and metrics — ported line-for-line from
|
||||
`ruforecast-train/examples/bidmc_prepare.rs` and
|
||||
`ruforecast-core/src/{baseline,metrics}.rs`, verified against this document's
|
||||
own BIDMC last-value/seasonal-naive numbers) and
|
||||
`scripts/ruforecast-zeroshot/run_zeroshot_eval.py` (zero-shot inference and
|
||||
scoring), both in this worktree (`research/ruforecast-zeroshot-eval`). Run
|
||||
against `/tmp/bidmc-raw/` (downloaded fresh from PhysioNet if not already
|
||||
cached) with `python run_zeroshot_eval.py --models chronos-bolt-base
|
||||
chronos-bolt-small chronos-t5-small timesfm-2.5-200m --judges a b` inside a
|
||||
venv with `chronos-forecasting`, `timesfm`, and a CUDA-enabled `torch`
|
||||
installed (`amazon/chronos-*` and `google/timesfm-2.5-200m-pytorch` download
|
||||
automatically from HuggingFace Hub on first run; no HF token was required).
|
||||
Model weights and the raw BIDMC CSVs are not committed — `/tmp/bidmc-raw/`
|
||||
and the HuggingFace cache live only on the host that ran this evaluation.
|
||||
@@ -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 `<REQUIRED>` 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: <REQUIRED exact weight-license identifier>
|
||||
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 `<REQUIRED model name and version>` in the
|
||||
release copy.
|
||||
|
||||
## Release decision and evidence status
|
||||
|
||||
| Field | Required value |
|
||||
|---|---|
|
||||
| Release candidate ID | `<REQUIRED>` |
|
||||
| Weight SHA-256 | `<REQUIRED>` |
|
||||
| Git commit | `<REQUIRED>` |
|
||||
| Model-card SHA-256 | `<REQUIRED after finalization>` |
|
||||
| Proposed mode | `OFFLINE_EVAL`, `SHADOW`, or `ADVISORY`; `<REQUIRED>` |
|
||||
| ADR-348 highest gate passed | `G0` through `G5`, or `NONE`; `<REQUIRED>` |
|
||||
| Accuracy status | `UNMEASURED` until a qualifying report exists; `<REQUIRED>` |
|
||||
| Calibration status | `UNMEASURED` until a qualifying report exists; `<REQUIRED>` |
|
||||
| CPU latency status | `UNMEASURED` until a named CPU reproducer exists; `<REQUIRED>` |
|
||||
| Memory status | `UNMEASURED` until a named runtime reproducer exists; `<REQUIRED>` |
|
||||
| Cross-site generalization status | `UNMEASURED` until untouched site holdouts exist; `<REQUIRED>` |
|
||||
| Clean-room status | `OPEN`, `PASS`, or `FAIL`; `<REQUIRED>` |
|
||||
| Production approval | `NOT APPROVED` unless all G5 receipts are signed; `<REQUIRED>` |
|
||||
|
||||
### Release summary
|
||||
|
||||
`<REQUIRED: State what this exact artifact does, what evidence exists, which
|
||||
mode is requested, and the most important unresolved limitation in no more
|
||||
than 150 words. Do not copy competitor marketing language.>`
|
||||
|
||||
## Model details
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Developer/owner | `<REQUIRED>` |
|
||||
| Model family | `RuView Forecast` |
|
||||
| Model version | `<REQUIRED immutable version>` |
|
||||
| Artifact format | `<REQUIRED, including RVF/safetensors/version>` |
|
||||
| Parameter count | `<REQUIRED or UNMEASURED>` |
|
||||
| Numeric precision | `<REQUIRED>` |
|
||||
| Architecture/config digest | `<REQUIRED>` |
|
||||
| Input schema version | `<REQUIRED>` |
|
||||
| Output schema version | `<REQUIRED>` |
|
||||
| Maximum context | `<REQUIRED with cadence and units>` |
|
||||
| Supported horizons | `<REQUIRED with cadence and units>` |
|
||||
| Declared quantiles | `<REQUIRED>` |
|
||||
| Training framework | `<REQUIRED exact Rust crate/features/version>` |
|
||||
| Inference runtime | `<REQUIRED exact Rust crate/features/version>` |
|
||||
| Source license | `<REQUIRED SPDX expression>` |
|
||||
| Weight license | `<REQUIRED exact license and URL>` |
|
||||
|
||||
### Rust implementation boundary
|
||||
|
||||
Describe the exact code surfaces used by this candidate:
|
||||
|
||||
| Crate or binary | Version/digest | Responsibility | Enabled features |
|
||||
|---|---|---|---|
|
||||
| `ruview-forecast-core` | `<REQUIRED>` | Backend-neutral schemas, invariants, metrics, receipts, and `Forecaster` trait | `<REQUIRED; expected default only>` |
|
||||
| `ruview-forecast-model` | `<REQUIRED>` | Independent Burn 0.21 patch mixer and artifact execution | `<REQUIRED; CPU/CUDA/WGPU are opt-in and default off>` |
|
||||
| `ruview-forecast-train` / `ruforecast` | `<REQUIRED>` | Dataset splits, trainer, evaluator, training receipt, and Linux/fal.ai assets | `<REQUIRED>` |
|
||||
| `<optional service/runtime>` | `<REQUIRED or NONE>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
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:
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
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`:
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
## Intended use
|
||||
|
||||
### Approved candidate use
|
||||
|
||||
`<REQUIRED: identify the exact RuView feature streams, deployment class,
|
||||
forecast horizons, mode, users, and decision support purpose.>`
|
||||
|
||||
### 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:
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
## Functional architecture
|
||||
|
||||
Describe independently implemented components without reproducing external
|
||||
source expression, diagrams, identifiers, or constants:
|
||||
|
||||
```text
|
||||
<REQUIRED: compact original diagram of feature windows, optional split-safe
|
||||
retrieval, normalization, temporal/cross-stream model, quantile heads,
|
||||
abstention, receipt, and policy boundary>
|
||||
```
|
||||
|
||||
| Component | Candidate implementation | Security/resource bound |
|
||||
|---|---|---|
|
||||
| Feature validation | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Normalization | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Temporal encoding/mixing | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Cross-stream fusion | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Missing-data handling | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Point/quantile head | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Quantile crossing policy | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| OOD/abstention | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| RuVector retrieval | `<REQUIRED or NONE>` | `<REQUIRED>` |
|
||||
| Artifact verification | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
## Input contract
|
||||
|
||||
### Feature schema
|
||||
|
||||
| Field | Unit | Cadence/aggregation | Range | Missing/invalid semantics | Source provenance |
|
||||
|---|---|---|---|---|---|
|
||||
| `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
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.
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
### Data not accepted
|
||||
|
||||
`<REQUIRED: include raw/unbounded payloads, unknown schema, non-finite values,
|
||||
untrusted future covariates, stale data, unsupported cadence, and candidate
|
||||
specific exclusions.>`
|
||||
|
||||
## Output contract
|
||||
|
||||
| Output | Shape/unit | Meaning | Evidence class |
|
||||
|---|---|---|---|
|
||||
| Point forecast | `<REQUIRED>` | `<REQUIRED>` | `DERIVED_FORECAST` |
|
||||
| Quantile forecast | `<REQUIRED>` | `<REQUIRED>` | `DERIVED_FORECAST` |
|
||||
| Validity mask | `<REQUIRED>` | `<REQUIRED>` | `DERIVED_METADATA` |
|
||||
| Abstention/disposition | `<REQUIRED>` | `<REQUIRED>` | `DERIVED_METADATA` |
|
||||
| OOD score/state | `<REQUIRED>` | `<REQUIRED>` | `DERIVED_METADATA` |
|
||||
| Receipt/provenance | `<REQUIRED>` | `<REQUIRED>` | `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:
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
## RuVector retrieval
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Enabled | `<REQUIRED true/false>` |
|
||||
| Index artifact/version/digest | `<REQUIRED or NONE>` |
|
||||
| Allowed retrieval corpus | `<REQUIRED>` |
|
||||
| Split-isolation receipt | `<REQUIRED>` |
|
||||
| Neighbour exclusion rules | `<REQUIRED>` |
|
||||
| Maximum neighbours/search budget | `<REQUIRED>` |
|
||||
| Behaviour when index is absent/stale | `<REQUIRED>` |
|
||||
|
||||
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 | `<REQUIRED or NOT INTEGRATED>` |
|
||||
| Explanation cites artifact and forecast receipt | `<REQUIRED or NOT INTEGRATED>` |
|
||||
| No model promotion, spending, actuation, or safety-critical authority | `<REQUIRED or NOT INTEGRATED>` |
|
||||
| Prompt/data retention and tenant policy | `<REQUIRED or NOT INTEGRATED>` |
|
||||
|
||||
## Independent-development record
|
||||
|
||||
| Record | Digest/approval |
|
||||
|---|---|
|
||||
| Approved source allowlist | `<REQUIRED>` |
|
||||
| Specification digest | `<REQUIRED>` |
|
||||
| Contributor exposure manifest | `<REQUIRED>` |
|
||||
| Contributor attestation bundle | `<REQUIRED>` |
|
||||
| AI-tool prompt/session manifest | `<REQUIRED>` |
|
||||
| Prohibited-artifact scan | `<REQUIRED>` |
|
||||
| Source-similarity review | `<REQUIRED>` |
|
||||
| Clean-room custodian approval | `<REQUIRED>` |
|
||||
| Legal release approval | `<REQUIRED for production>` |
|
||||
|
||||
Declaration:
|
||||
|
||||
`<REQUIRED: State exactly what was independently authored and trained. Do not
|
||||
state that clean room eliminates patent, trademark, dataset, privacy, or
|
||||
jurisdiction risk.>`
|
||||
|
||||
Prior exposure disclosures and dispositions, without private details:
|
||||
|
||||
`<REQUIRED or NONE>`
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---:|---|
|
||||
| `<REQUIRED>` | train/validation/calibration | `<REQUIRED>` | `<REQUIRED>` | `YES` required | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
### Excluded data
|
||||
|
||||
Explicitly state that TimesFM 3 weights, outputs, activations, generated labels,
|
||||
and derivatives were excluded, then list all other exclusions:
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
### Collection, consent, minimization, and retention
|
||||
|
||||
`<REQUIRED: collection authority, purpose, consent/contract, controller and
|
||||
processor, sensitive inferences, pseudonymization, fields removed, geographic
|
||||
scope, retention, deletion, access, and incident contact.>`
|
||||
|
||||
### Synthetic data
|
||||
|
||||
| Generator/version | Seed-data rights | Provider/output terms | Seeds/config digest | Proportion | Use |
|
||||
|---|---|---|---|---:|---|
|
||||
| `<REQUIRED or NONE>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
## Split and leakage protocol
|
||||
|
||||
| Split | Sites | Subjects | Sessions | Devices | Time blocks | Windows | Manifest digest |
|
||||
|---|---:|---:|---:|---:|---:|---:|---|
|
||||
| Train | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Validation | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Calibration | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Test | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
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:
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
## Training procedure
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Random initialization | `true` required for this model family |
|
||||
| Parent checkpoint | `NONE` or approved RuView artifact and digest |
|
||||
| Source commit | `<REQUIRED>` |
|
||||
| Cargo.lock digest | `<REQUIRED>` |
|
||||
| Rust toolchain | `<REQUIRED exact>` |
|
||||
| Build/training container | `<REQUIRED digest>` |
|
||||
| Training config digest | `<REQUIRED>` |
|
||||
| Seeds | `<REQUIRED>` |
|
||||
| Optimizer/schedule/loss | `<REQUIRED>` |
|
||||
| Batch/epochs/stopping | `<REQUIRED>` |
|
||||
| Hardware | `<REQUIRED exact CPU/GPU/RAM>` |
|
||||
| Provider | `<REQUIRED local Linux or approved provider>` |
|
||||
| Provider job ID | `<REQUIRED non-secret identifier>` |
|
||||
| Wall time | `<REQUIRED and evidence label>` |
|
||||
| Peak host/GPU memory | `<REQUIRED and evidence label>` |
|
||||
| Estimated/actual cost | `<REQUIRED and evidence label>` |
|
||||
| Emitted checkpoint digests | `<REQUIRED>` |
|
||||
| Training receipt digest | `<REQUIRED>` |
|
||||
|
||||
If local and hosted runs are compared, list numerical-determinism differences
|
||||
and prove that governed code, data, config, and parent identities match:
|
||||
|
||||
`<REQUIRED or NOT APPLICABLE>`
|
||||
|
||||
## Evaluation
|
||||
|
||||
### Baselines and ablations
|
||||
|
||||
Every row uses the same frozen examples and metric implementation.
|
||||
|
||||
| Model | Artifact/version | Retrieval | Parameters | Evidence label | Notes |
|
||||
|---|---|---|---:|---|---|
|
||||
| Last value | `<REQUIRED>` | no | n/a | `<REQUIRED>` | deterministic baseline |
|
||||
| Seasonal naive | `<REQUIRED>` | no | n/a | `<REQUIRED>` | `<REQUIRED period>` |
|
||||
| Small classical/recurrent baseline | `<REQUIRED>` | no | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| RuView Forecast | `<REQUIRED>` | no | `<REQUIRED>` | `<REQUIRED>` | required ablation |
|
||||
| RuView Forecast | `<REQUIRED>` | yes | `<REQUIRED>` | `<REQUIRED>` | 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 | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| MAE or scaled error | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Nominal 80% interval coverage | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Interval width | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Quantile crossing before/after policy | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Abstention coverage/selective risk | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
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 | `<REQUIRED or UNMEASURED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Occupied-room recall | `<REQUIRED or UNMEASURED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Abstention by deployment | `<REQUIRED or UNMEASURED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Drift/OOD rate | `<REQUIRED or UNMEASURED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
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 |
|
||||
|---|---|---:|---|---|---:|---|---|
|
||||
| `<REQUIRED named CPU>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| `<optional GPU>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
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 | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Quantile calibration | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| OOD threshold | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Minimum validity/context | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Drift threshold | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
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:
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|
|
||||
| `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
## 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 | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Oversized/malformed request | Dimension, allocation, horizon, context, batch and deadline caps | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Poisoned data or split leakage | Approved manifests, immutable transforms, lineage and overlap checks | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Cross-tenant analogue retrieval | Tenant and split-scoped RuVector indexes | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Routine/location inference | Minimization, purpose, retention, deletion, access and audit | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Model extraction/membership inference | Rate/access control and privacy evaluation | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Hosted-worker data retention | Approved provider terms, least data, deletion receipt | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| LLM numeric mutation or overclaim | Signed numeric record and capability policy | `<REQUIRED or NOT INTEGRATED>` | `<REQUIRED>` |
|
||||
|
||||
### Data handling
|
||||
|
||||
`<REQUIRED: tenant isolation, encryption, keys, access roles, logs, metrics
|
||||
allowlist, retention, deletion, export, incident handling, and whether feature
|
||||
or forecast persistence is enabled.>`
|
||||
|
||||
## Deployment
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Supported mode | `<REQUIRED>` |
|
||||
| Supported platform/CPU/GPU | `<REQUIRED>` |
|
||||
| Minimum RAM/storage | `<REQUIRED and evidence-labelled>` |
|
||||
| Offline artifact source | `<REQUIRED>` |
|
||||
| Signature/trust root | `<REQUIRED public key ID>` |
|
||||
| Configuration/calibration artifact | `<REQUIRED digest>` |
|
||||
| RuVector index requirement | `<REQUIRED>` |
|
||||
| Startup self-test | `<REQUIRED>` |
|
||||
| Health/drift metrics | `<REQUIRED allowlisted fields>` |
|
||||
| Timeout/backpressure policy | `<REQUIRED>` |
|
||||
| Rollback artifact/mode | `<REQUIRED>` |
|
||||
|
||||
No deployment may fetch an unpinned model at runtime. Describe exact startup
|
||||
failure and fail-closed behaviour:
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
## Monitoring, rollback, and retirement
|
||||
|
||||
| Signal | Threshold | Window | Response | Owner |
|
||||
|---|---:|---|---|---|
|
||||
| Calibration/coverage drift | `<REQUIRED>` | `<REQUIRED>` | shadow/off | `<REQUIRED>` |
|
||||
| OOD/abstention rate | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Latency/resource regression | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Empty/occupied outcome regression | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Provenance/signature failure | any | immediate | disable artifact | `<REQUIRED>` |
|
||||
| Security/privacy incident | any material incident | immediate | isolate, preserve evidence, disable | `<REQUIRED>` |
|
||||
|
||||
Rollback drill receipt and result:
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
Retirement and data/index/checkpoint deletion policy:
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
## Limitations and largest uncertainty
|
||||
|
||||
`<REQUIRED: List observed and unmeasured limitations. The default largest
|
||||
uncertainty is whether training diversity supports unseen-room and unseen-device
|
||||
generalization without suppressing legitimate occupied states. State the
|
||||
specific data/evaluation fix path.>`
|
||||
|
||||
## Cost, energy, and operational burden
|
||||
|
||||
| Item | Value | Evidence label | Method |
|
||||
|---|---:|---|---|
|
||||
| Training accelerator hours | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Training cost | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Training energy/emissions | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| CPU inference cost/energy | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Storage/index overhead | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Operator/calibration burden | `<REQUIRED or UNMEASURED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
## Licenses and notices
|
||||
|
||||
| Surface | Exact license/terms | URL/file | Obligations | Approval |
|
||||
|---|---|---|---|---|
|
||||
| Rust source | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Weights | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Each dataset | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Dependencies/SBOM | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Training provider | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
Trademark clearance ID and scope:
|
||||
|
||||
`<REQUIRED for public production release>`
|
||||
|
||||
Patent freedom-to-operate disposition and launch jurisdictions:
|
||||
|
||||
`<REQUIRED for production; keep privileged analysis outside this public card>`
|
||||
|
||||
## Provenance and reproducibility
|
||||
|
||||
| Artifact | Digest/signature/location |
|
||||
|---|---|
|
||||
| Source allowlist | `<REQUIRED>` |
|
||||
| Specification | `<REQUIRED>` |
|
||||
| Source commit | `<REQUIRED>` |
|
||||
| Cargo.lock | `<REQUIRED>` |
|
||||
| SBOM | `<REQUIRED>` |
|
||||
| Dataset manifest | `<REQUIRED>` |
|
||||
| Split/leakage report | `<REQUIRED>` |
|
||||
| Training config | `<REQUIRED>` |
|
||||
| Training receipt | `<REQUIRED>` |
|
||||
| Evaluation report | `<REQUIRED>` |
|
||||
| Clean-room report | `<REQUIRED>` |
|
||||
| Model weights | `<REQUIRED>` |
|
||||
| RVF signature | `<REQUIRED or NONE with blocker>` |
|
||||
| Reproducer | `<REQUIRED>` |
|
||||
|
||||
Reproduction commands must use pinned artifacts and must not contain secrets or
|
||||
download restricted material:
|
||||
|
||||
```bash
|
||||
<REQUIRED safe, exact commands>
|
||||
```
|
||||
|
||||
## 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 | `<OPEN, PASS, or FAIL>` |
|
||||
| G1 data and lineage | RF-002, RF-003, RF-010 | dataset approvals, lineage, local/hosted receipts | `<OPEN, PASS, or FAIL>` |
|
||||
| G2 bounded Rust contract | RF-004, RF-005, RF-006 | contract/fuzz/replay/resource/dependency reports | `<OPEN, PASS, or FAIL>` |
|
||||
| G3 leakage-free model evidence | RF-007, RF-008, RF-011 | frozen split, baselines, ablations, calibration, reproducers | `<OPEN, PASS, or FAIL>` |
|
||||
| G4 RuView shadow value | RF-006, RF-007, RF-008, RF-009 | 14-day outcomes, drift, abstention, no action authority | `<OPEN, PASS, or FAIL>` |
|
||||
| G5 deployment fitness | RF-004, RF-009, RF-010, RF-011, RF-012 | runtime, approvals, signed bundle, rollback | `<OPEN, PASS, or FAIL>` |
|
||||
|
||||
Highest permitted deployment mode from these results:
|
||||
|
||||
`<REQUIRED>`
|
||||
|
||||
## Approvals
|
||||
|
||||
Signatures cover this exact model-card digest and weight digest.
|
||||
|
||||
| Role | Approver/receipt | Decision | Timestamp |
|
||||
|---|---|---|---|
|
||||
| Model owner | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Clean-room custodian | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Data steward/privacy | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Security owner | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Runtime owner | `<REQUIRED>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
| Legal release owner | `<REQUIRED for production>` | `<REQUIRED>` | `<REQUIRED>` |
|
||||
|
||||
## Citation
|
||||
|
||||
Use the final public model name, exact version, weight digest, repository commit,
|
||||
and model-card URL:
|
||||
|
||||
```bibtex
|
||||
@software{<REQUIRED citation key>,
|
||||
title = {<REQUIRED>},
|
||||
author = {<REQUIRED>},
|
||||
year = {<REQUIRED>},
|
||||
version = {<REQUIRED>},
|
||||
url = {<REQUIRED>},
|
||||
note = {Weights SHA-256: <REQUIRED>; source commit: <REQUIRED>}
|
||||
}
|
||||
```
|
||||
|
||||
## Change history
|
||||
|
||||
| Card version | Weight digest | Change | Gate impact | Date |
|
||||
|---|---|---|---|---|
|
||||
| `<REQUIRED>` | `<REQUIRED>` | Initial candidate | All gates evaluated independently | `<REQUIRED>` |
|
||||
@@ -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: <required>
|
||||
artifact:
|
||||
id: <required>
|
||||
version: <required>
|
||||
git_commit: <sha>
|
||||
source_spec_digest: <sha256>
|
||||
cargo_lock_digest: <sha256>
|
||||
rust_toolchain: <exact>
|
||||
target_triple: <exact>
|
||||
build_container_digest: <digest>
|
||||
sbom_digest: <sha256>
|
||||
|
||||
contributors:
|
||||
- pseudonymous_id: <stable id>
|
||||
role: <role>
|
||||
exposure_class: <unexposed|disclosed-reviewed>
|
||||
attestation_digest: <sha256>
|
||||
signed_at: <rfc3339>
|
||||
|
||||
references:
|
||||
- title: <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.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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.
|
||||
@@ -1,5 +1,6 @@
|
||||
set(SRCS
|
||||
"main.c" "csi_collector.c" "stream_sender.c" "nvs_config.c"
|
||||
"main.c" "csi_collector.c"
|
||||
"thermal.c" "stream_sender.c" "nvs_config.c"
|
||||
"edge_processing.c" "ota_update.c" "power_mgmt.c"
|
||||
"wasm_runtime.c" "wasm_upload.c" "rvf_parser.c"
|
||||
"mmwave_sensor.c"
|
||||
|
||||
@@ -517,3 +517,137 @@ menu "Onboard LED (ADR-183)"
|
||||
colormap, in milli-units (250 = 0.25). Lower = more sensitive
|
||||
(reaches yellow with less motion).
|
||||
endmenu
|
||||
|
||||
|
||||
menu "Thermal monitoring"
|
||||
|
||||
config THERMAL_MONITOR
|
||||
bool "Read the on-chip temperature sensor and throttle the radio"
|
||||
depends on IDF_TARGET_ESP32C6
|
||||
default y
|
||||
help
|
||||
The C6 has a temperature sensor this firmware otherwise never reads.
|
||||
Harmless on a bare bench board; not once a node is sealed in a printed
|
||||
enclosure on a wall with the radio transmitting continuously.
|
||||
|
||||
What is being protected is the ENCLOSURE, not the silicon. The part is
|
||||
rated far past anything it sees here, but PLA softens at its glass
|
||||
transition (~55-60 C) and sags slowly under its own weight, which never
|
||||
announces itself.
|
||||
|
||||
config THERMAL_THROTTLE
|
||||
bool "Act on the temperature by reducing transmit power"
|
||||
depends on THERMAL_MONITOR
|
||||
default n
|
||||
help
|
||||
Separate from monitoring on purpose. Reading the sensor is inert;
|
||||
reducing transmit power moves every RSSI the node has, and doing that
|
||||
mid-experiment gives any surprise two candidate causes.
|
||||
|
||||
Leave off until the fleet is stable and the logs show a node genuinely
|
||||
running hot. If none ever does -- which is the likely outcome -- this
|
||||
never needs enabling, and the thresholds below cost nothing.
|
||||
|
||||
config THERMAL_WARN_C
|
||||
int "Warn above this die temperature (C)"
|
||||
depends on THERMAL_MONITOR
|
||||
range 40 100
|
||||
default 65
|
||||
|
||||
config THERMAL_THROTTLE_C
|
||||
int "Reduce transmit power above this die temperature (C)"
|
||||
depends on THERMAL_MONITOR
|
||||
range 45 105
|
||||
default 72
|
||||
help
|
||||
Die temperature, not enclosure temperature -- the sensor is on the chip
|
||||
and runs hotter than the plastic around it. ~72 C on the die in a small
|
||||
vented box is roughly a 50-55 C enclosure, which is where PLA begins to
|
||||
matter. Setting this at 55 would throttle a perfectly healthy node.
|
||||
|
||||
PETG (glass transition ~80 C) removes the concern; raise this if the
|
||||
whole fleet is printed in it.
|
||||
|
||||
config THERMAL_CRITICAL_C
|
||||
int "Minimum transmit power above this die temperature (C)"
|
||||
depends on THERMAL_MONITOR
|
||||
range 50 110
|
||||
default 80
|
||||
|
||||
config THERMAL_HYSTERESIS_C
|
||||
int "Degrees of cooling required before stepping down a thermal state"
|
||||
depends on THERMAL_MONITOR
|
||||
range 1 20
|
||||
default 8
|
||||
help
|
||||
Recovering at the same threshold that tripped would oscillate, and every
|
||||
oscillation is a visible RSSI step on every link the node has.
|
||||
|
||||
Applies to EVERY downward transition, each relative to the threshold that
|
||||
was crossed on the way up: leaving CRITICAL needs CRITICAL_C minus this,
|
||||
leaving THROTTLED needs THROTTLE_C minus this, leaving WARN needs WARN_C
|
||||
minus this. Rising transitions are immediate at the plain thresholds --
|
||||
heat is a reason to act now.
|
||||
|
||||
endmenu
|
||||
|
||||
menu "Network supervision"
|
||||
|
||||
config UPLINK_WATCHDOG
|
||||
bool "Restart the node if the CSI uplink stops for too long"
|
||||
default y
|
||||
help
|
||||
Bounds how long a node can sit powered, capturing, and unable to reach
|
||||
the network.
|
||||
|
||||
MEASURED 2026-08-30: an AP-side event at 03:37 EDT disconnected all three
|
||||
wall-mounted nodes within three minutes of each other, and every one of
|
||||
them stayed dead for 5.5-7.5 hours until it was physically unplugged. The
|
||||
specific cause of that outage (a reconnect counter that gave up
|
||||
permanently) is fixed in main.c, but this exists so the NEXT unknown
|
||||
wedge costs minutes instead of a night of data.
|
||||
|
||||
The watchdog counts a successful sendto(), which on UDP means the network
|
||||
stack accepted the datagram -- not that the aggregator received it. That
|
||||
is deliberate: stopping the server does NOT reboot the fleet, because
|
||||
sendto keeps succeeding into a socket whose peer is gone. It fires only
|
||||
when the node's own network path is broken.
|
||||
|
||||
It arms on the first successful send rather than at boot, so a node
|
||||
powered on while the AP is down does not reboot-loop; the unbounded
|
||||
reconnect handles that case instead.
|
||||
|
||||
config UPLINK_WATCHDOG_TIMEOUT_S
|
||||
int "Restart after this many seconds with no successful send"
|
||||
depends on UPLINK_WATCHDOG
|
||||
range 30 3600
|
||||
default 1200
|
||||
help
|
||||
Must sit above the worst SELF-HEALING gap, or the watchdog reboots nodes
|
||||
that were about to recover on their own -- which is worse than the fault
|
||||
it guards against, because every reboot re-runs ESP-NOW leader election
|
||||
and discards the mesh time-sync state.
|
||||
|
||||
This was 120 s on the reasoning that the ENOMEM backoff caps at 2 s so a
|
||||
healthy node is never quiet for long. A 58-minute soak on 2026-08-30
|
||||
falsified that: nodes 0 and 2 each went silent repeatedly with a median
|
||||
gap of 210 s and a maximum of 225 s, and recovered every time without
|
||||
intervention. 120 s would have rebooted them every few minutes forever.
|
||||
(The cause of those 210 s gaps is still unknown -- it is not the ENOMEM
|
||||
backoff, which cannot produce a gap that long.)
|
||||
|
||||
Sized from the completed 112-minute soak, not the partial one: 30
|
||||
silences across two nodes, median 225 s, p90 225 s, and a single 510 s
|
||||
outlier that looks like two cycles running together. A third such merge
|
||||
would reach ~735 s, so 600 s was too tight -- it left only 90 s of margin
|
||||
over an event already observed once.
|
||||
|
||||
1200 s is 2.35x the observed maximum while still bounding a true wedge to
|
||||
twenty minutes rather than the 7.5 hours measured the same day. Revisit
|
||||
it if the ~225 s flapping is ever explained and fixed; until then do not
|
||||
lower it below ~900 s.
|
||||
|
||||
If nodes do restart unexpectedly, check the reset reason in the sync
|
||||
packet's health field before assuming the watchdog is responsible.
|
||||
|
||||
endmenu
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
#include "adaptive_controller.h"
|
||||
#include "thermal.h"
|
||||
#include "rv_radio_ops.h"
|
||||
#include "rv_feature_state.h"
|
||||
#include "rv_mesh.h"
|
||||
@@ -331,6 +332,10 @@ static void emit_feature_state(void)
|
||||
static void slow_loop_cb(TimerHandle_t t)
|
||||
{
|
||||
(void)t;
|
||||
/* Sample the die and apply the thermal policy. The slow loop (30 s) is the
|
||||
* right cadence: the die does not change quickly, and reading it more
|
||||
* often buys nothing but noise. No-op unless CONFIG_THERMAL_MONITOR. */
|
||||
thermal_tick();
|
||||
/* ADR-081 L3: publish a HEALTH mesh message every slow tick
|
||||
* (default 30 s). The coordinator uses these to track liveness and
|
||||
* detect sync-error drift. */
|
||||
|
||||
@@ -33,7 +33,31 @@
|
||||
#define EDGE_MAX_IQ_BYTES 1024 /**< Max I/Q payload per slot. */
|
||||
#define EDGE_PHASE_HISTORY_LEN 256 /**< Phase history buffer depth. */
|
||||
#define EDGE_TOP_K 8 /**< Top-K subcarriers to track. */
|
||||
#define EDGE_MAX_SUBCARRIERS 128 /**< Max subcarriers per frame. */
|
||||
/**
|
||||
* Max subcarriers per frame the edge pipeline will process.
|
||||
*
|
||||
* Target-conditional since 2026-08-28. The original 128 was an ESP32-S3
|
||||
* assumption: pre-HE chips report at most 128 CSI bins. ADR-110 onboarded the
|
||||
* ESP32-C6 to the *capture* path but not to this one, and a C6 associated to an
|
||||
* HE-capable AP delivers HE20 frames with 256 bins (iq_len = 512 bytes).
|
||||
*
|
||||
* `process_frame()` guards with `n_subcarriers > EDGE_MAX_SUBCARRIERS -> return`,
|
||||
* so on C6 every frame was rejected and the whole edge pipeline — vitals,
|
||||
* presence, fall detection, per-slot counting — silently did nothing. The Edge
|
||||
* DSP task started, logged its banner, and never processed a frame. Confirmed
|
||||
* on hardware: no edge_proc log past init and no vitals packet ever reaching
|
||||
* the sink.
|
||||
*
|
||||
* HE-capable parts get 256; pre-HE parts keep 128 so they don't pay ~3.5 KB of
|
||||
* .bss they can never use. CONFIG_SOC_WIFI_HE_SUPPORT is the same switch
|
||||
* csi_collector.c uses to pick its rx_ctrl layout.
|
||||
*/
|
||||
#if CONFIG_SOC_WIFI_HE_SUPPORT
|
||||
#define EDGE_MAX_SUBCARRIERS 256 /**< HE20 carries 256 CSI bins (C6/C5). */
|
||||
#else
|
||||
#define EDGE_MAX_SUBCARRIERS 128 /**< Pre-HE parts (S3 etc). */
|
||||
#endif
|
||||
|
||||
|
||||
/* ---- Measured sample-rate tracking ----
|
||||
*
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include "led_strip.h"
|
||||
|
||||
#include "csi_collector.h"
|
||||
#include "thermal.h"
|
||||
#include "stream_sender.h"
|
||||
#include "nvs_config.h"
|
||||
#include "edge_processing.h"
|
||||
@@ -59,7 +60,52 @@ nvs_config_t g_nvs_config;
|
||||
|
||||
static EventGroupHandle_t s_wifi_event_group;
|
||||
static int s_retry_num = 0;
|
||||
#define MAX_RETRY 10
|
||||
|
||||
/* WiFi reconnection.
|
||||
*
|
||||
* This used to be `#define MAX_RETRY 10` with the disconnect handler giving up
|
||||
* permanently once s_retry_num reached it: it set WIFI_FAIL_BIT and never
|
||||
* called esp_wifi_connect() again. s_retry_num is only cleared on a successful
|
||||
* IP_EVENT_STA_GOT_IP, so it accumulated over the node's entire uptime -- ten
|
||||
* disconnects spread across days left the node permanently off the network
|
||||
* with nothing but a power cycle to clear it.
|
||||
*
|
||||
* MEASURED 2026-08-30: a single AP-side event at 03:37:03 EDT disconnected all
|
||||
* three wall-mounted nodes within three minutes of each other. Every one of
|
||||
* them latched off and stayed off for 5.5-7.5 hours, until it was physically
|
||||
* unplugged. CSI capture kept running throughout (it is driver-level and needs
|
||||
* no IP), so the boards looked alive on the bench and dead to the server.
|
||||
*
|
||||
* Retry is now unbounded with exponential backoff. Two things that were
|
||||
* conflated are now separate: WIFI_BOOT_WAIT_ATTEMPTS only releases app_main
|
||||
* from its startup wait so the rest of the node can boot; it does not stop the
|
||||
* node trying to reconnect. Nothing stops the node trying to reconnect. */
|
||||
#define WIFI_BOOT_WAIT_ATTEMPTS 10
|
||||
#define WIFI_RETRY_BASE_MS 500
|
||||
#define WIFI_RETRY_MAX_MS 30000
|
||||
|
||||
static esp_timer_handle_t s_reconnect_timer;
|
||||
|
||||
/* 500 ms doubling to a 30 s ceiling. Backoff matters because the failure this
|
||||
* guards against is an AP that is down or rebooting: hammering
|
||||
* esp_wifi_connect() at full rate for the minutes an AP takes to come back
|
||||
* wastes power and floods the log without reconnecting any sooner. */
|
||||
static uint32_t wifi_retry_delay_ms(int attempt)
|
||||
{
|
||||
int shift = attempt - 1;
|
||||
if (shift < 0) shift = 0;
|
||||
if (shift > 6) shift = 6;
|
||||
uint32_t d = (uint32_t)WIFI_RETRY_BASE_MS << shift;
|
||||
return d > WIFI_RETRY_MAX_MS ? WIFI_RETRY_MAX_MS : d;
|
||||
}
|
||||
|
||||
/* Runs on the esp_timer task, not the event loop task -- esp_wifi_connect()
|
||||
* must not be reached through a delay inside the event handler itself. */
|
||||
static void wifi_reconnect_cb(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
esp_wifi_connect();
|
||||
}
|
||||
|
||||
static void event_handler(void *arg, esp_event_base_t event_base,
|
||||
int32_t event_id, void *event_data)
|
||||
@@ -69,13 +115,22 @@ static void event_handler(void *arg, esp_event_base_t event_base,
|
||||
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
|
||||
wifi_event_sta_disconnected_t *disc = (wifi_event_sta_disconnected_t *)event_data;
|
||||
ESP_LOGW(TAG, "WiFi disconnected, reason=%d rssi=%d", disc->reason, disc->rssi);
|
||||
if (s_retry_num < MAX_RETRY) {
|
||||
esp_wifi_connect();
|
||||
s_retry_num++;
|
||||
ESP_LOGI(TAG, "Retrying WiFi connection (%d/%d)", s_retry_num, MAX_RETRY);
|
||||
} else {
|
||||
s_retry_num++;
|
||||
/* Release the boot wait once, so a node that comes up while the AP is
|
||||
* down still starts CSI capture and the mesh instead of blocking in
|
||||
* app_main. Retrying continues regardless. */
|
||||
if (s_retry_num == WIFI_BOOT_WAIT_ATTEMPTS) {
|
||||
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
|
||||
}
|
||||
uint32_t delay_ms = wifi_retry_delay_ms(s_retry_num);
|
||||
if (s_reconnect_timer != NULL) {
|
||||
esp_timer_stop(s_reconnect_timer); /* may not be running; harmless */
|
||||
esp_timer_start_once(s_reconnect_timer, (uint64_t)delay_ms * 1000);
|
||||
} else {
|
||||
esp_wifi_connect(); /* timer unavailable: retry now */
|
||||
}
|
||||
ESP_LOGI(TAG, "Reconnecting in %lu ms (attempt %d)",
|
||||
(unsigned long)delay_ms, s_retry_num);
|
||||
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
|
||||
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
|
||||
ESP_LOGI(TAG, "Got IP: " IPSTR, IP2STR(&event->ip_info.ip));
|
||||
@@ -84,6 +139,41 @@ static void event_handler(void *arg, esp_event_base_t event_base,
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef CONFIG_UPLINK_WATCHDOG
|
||||
/* Uplink supervision.
|
||||
*
|
||||
* The reconnect fix above addresses the one wedge whose mechanism we have
|
||||
* proven. This catches the ones we have not: any state in which the node is
|
||||
* powered, capturing, and unable to put a packet on the wire. It bounds such a
|
||||
* state to CONFIG_UPLINK_WATCHDOG_TIMEOUT_S instead of the 7.5 hours measured
|
||||
* on 2026-08-30.
|
||||
*
|
||||
* The signal is a successful sendto(), which on UDP means the stack accepted
|
||||
* the datagram -- NOT that the server received it. That distinction is what
|
||||
* makes this safe: stopping the aggregator does not reboot the fleet, because
|
||||
* sendto keeps succeeding into a socket whose peer is gone. It fires only when
|
||||
* the node's own network path is broken.
|
||||
*
|
||||
* It arms on the first successful send rather than at boot, so a node that has
|
||||
* never reached the network does not reboot-loop while the AP is down; the
|
||||
* unbounded reconnect above owns that case. */
|
||||
static void uplink_watchdog_tick(void)
|
||||
{
|
||||
int64_t last = stream_sender_last_success_us();
|
||||
if (last == 0) {
|
||||
return; /* never delivered -- not armed yet */
|
||||
}
|
||||
int64_t idle_s = (esp_timer_get_time() - last) / 1000000;
|
||||
if (idle_s < CONFIG_UPLINK_WATCHDOG_TIMEOUT_S) {
|
||||
return;
|
||||
}
|
||||
ESP_LOGE(TAG, "uplink watchdog: no successful send for %lld s "
|
||||
"(limit %d s) -- restarting",
|
||||
(long long)idle_s, CONFIG_UPLINK_WATCHDOG_TIMEOUT_S);
|
||||
esp_restart();
|
||||
}
|
||||
#endif /* CONFIG_UPLINK_WATCHDOG */
|
||||
|
||||
static void wifi_init_sta(void)
|
||||
{
|
||||
s_wifi_event_group = xEventGroupCreate();
|
||||
@@ -135,6 +225,17 @@ static void wifi_init_sta(void)
|
||||
}
|
||||
#endif
|
||||
|
||||
const esp_timer_create_args_t reconnect_args = {
|
||||
.callback = &wifi_reconnect_cb,
|
||||
.name = "wifi_reconnect",
|
||||
};
|
||||
esp_err_t rc_ret = esp_timer_create(&reconnect_args, &s_reconnect_timer);
|
||||
if (rc_ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "reconnect timer create failed: %s (falling back to "
|
||||
"immediate retry)", esp_err_to_name(rc_ret));
|
||||
s_reconnect_timer = NULL;
|
||||
}
|
||||
|
||||
ESP_ERROR_CHECK(esp_wifi_start());
|
||||
|
||||
ESP_LOGI(TAG, "WiFi STA initialized, connecting to SSID: %s", g_nvs_config.wifi_ssid);
|
||||
@@ -147,7 +248,9 @@ static void wifi_init_sta(void)
|
||||
if (bits & WIFI_CONNECTED_BIT) {
|
||||
ESP_LOGI(TAG, "Connected to WiFi");
|
||||
} else if (bits & WIFI_FAIL_BIT) {
|
||||
ESP_LOGE(TAG, "Failed to connect to WiFi after %d retries", MAX_RETRY);
|
||||
ESP_LOGW(TAG, "Not connected after %d attempts -- continuing boot; "
|
||||
"reconnection keeps retrying in the background",
|
||||
WIFI_BOOT_WAIT_ATTEMPTS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,6 +421,36 @@ void app_main(void)
|
||||
ESP_LOGI(TAG, "Mock CSI active (scenario=%d)", CONFIG_CSI_MOCK_SCENARIO);
|
||||
}
|
||||
#else
|
||||
/* Why did we just start? Nothing recorded this before, and it is the
|
||||
* single most useful line in a fleet log: a node that reboots tells you
|
||||
* almost nothing, but a node that reboots with ESP_RST_BROWNOUT tells you
|
||||
* its supply sagged, which on shared outlets is a wiring problem rather
|
||||
* than a firmware one. Three nodes dropping together on 2026-08-28 would
|
||||
* have been answered by this line. */
|
||||
{
|
||||
esp_reset_reason_t rr = esp_reset_reason();
|
||||
const char *why =
|
||||
rr == ESP_RST_POWERON ? "power-on" :
|
||||
rr == ESP_RST_EXT ? "external reset" :
|
||||
rr == ESP_RST_SW ? "software restart" :
|
||||
rr == ESP_RST_PANIC ? "PANIC (crash)" :
|
||||
rr == ESP_RST_INT_WDT ? "interrupt watchdog" :
|
||||
rr == ESP_RST_TASK_WDT ? "task watchdog" :
|
||||
rr == ESP_RST_WDT ? "other watchdog" :
|
||||
rr == ESP_RST_BROWNOUT ? "BROWNOUT (supply sagged)" :
|
||||
rr == ESP_RST_DEEPSLEEP? "deep-sleep wake" : "unknown";
|
||||
if (rr == ESP_RST_PANIC || rr == ESP_RST_BROWNOUT ||
|
||||
rr == ESP_RST_INT_WDT || rr == ESP_RST_TASK_WDT) {
|
||||
ESP_LOGW(TAG, "reset reason: %s (%d) <-- not a clean start", why, (int)rr);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "reset reason: %s (%d)", why, (int)rr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Bring thermal monitoring up before the radio is loaded, so the first
|
||||
* reading is taken against a known-idle baseline rather than mid-burst. */
|
||||
thermal_init();
|
||||
|
||||
csi_collector_init();
|
||||
|
||||
/* ADR-073: Start multi-frequency channel hopping if configured in NVS. */
|
||||
@@ -510,8 +643,11 @@ void app_main(void)
|
||||
(swarm_ret == ESP_OK) ? g_nvs_config.seed_url : "off",
|
||||
(adapt_ret == ESP_OK) ? "on" : "off");
|
||||
|
||||
/* Main loop — keep alive */
|
||||
/* Main loop — keep alive, and supervise the uplink. */
|
||||
while (1) {
|
||||
vTaskDelay(pdMS_TO_TICKS(10000));
|
||||
#ifdef CONFIG_UPLINK_WATCHDOG
|
||||
uplink_watchdog_tick();
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,12 @@ static uint32_t s_enomem_suppressed = 0;
|
||||
* succeeds. */
|
||||
static uint32_t s_enomem_streak = 0;
|
||||
|
||||
/* Timestamp of the last sendto() the stack accepted, on either path; 0 until
|
||||
* the node has ever delivered anything. Read by the uplink watchdog in main.c
|
||||
* -- see the rationale there for why "accepted by the stack" is deliberately
|
||||
* not "received by the server". */
|
||||
static int64_t s_last_success_us = 0;
|
||||
|
||||
static int sender_init_internal(const char *ip, uint16_t port)
|
||||
{
|
||||
s_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
|
||||
@@ -118,6 +124,7 @@ int stream_sender_send(const uint8_t *data, size_t len)
|
||||
|
||||
/* A send got through — buffer pressure cleared; reset the backoff streak. */
|
||||
s_enomem_streak = 0;
|
||||
s_last_success_us = esp_timer_get_time();
|
||||
return sent;
|
||||
}
|
||||
|
||||
@@ -146,9 +153,18 @@ int stream_sender_send_priority(const uint8_t *data, size_t len)
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
/* Counts for the watchdog: the mesh sync packet rides this path at ~1 Hz,
|
||||
* and its getting through proves the node's network path is intact even
|
||||
* when the bulk CSI stream is being suppressed by the ENOMEM backoff. */
|
||||
s_last_success_us = esp_timer_get_time();
|
||||
return sent;
|
||||
}
|
||||
|
||||
int64_t stream_sender_last_success_us(void)
|
||||
{
|
||||
return s_last_success_us;
|
||||
}
|
||||
|
||||
void stream_sender_deinit(void)
|
||||
{
|
||||
if (s_sock >= 0) {
|
||||
|
||||
@@ -50,6 +50,18 @@ int stream_sender_send(const uint8_t *data, size_t len);
|
||||
*/
|
||||
int stream_sender_send_priority(const uint8_t *data, size_t len);
|
||||
|
||||
/**
|
||||
* esp_timer timestamp (microseconds) of the last sendto() the network stack
|
||||
* accepted, on either the bulk or the priority path. Returns 0 if the node has
|
||||
* not successfully sent anything since boot.
|
||||
*
|
||||
* This reports that the datagram was accepted for transmission, NOT that the
|
||||
* aggregator received it -- UDP gives no such signal. Used by the uplink
|
||||
* watchdog to detect a node that is powered and capturing but cannot reach the
|
||||
* network.
|
||||
*/
|
||||
int64_t stream_sender_last_success_us(void);
|
||||
|
||||
/**
|
||||
* Close the UDP sender socket.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/* On-chip thermal monitoring and radio throttling.
|
||||
*
|
||||
* The ESP32-C6 carries a temperature sensor (SOC_TEMP_SENSOR_SUPPORTED) that
|
||||
* nothing in this firmware has ever read. That was tolerable on a bare board
|
||||
* on a bench; it is not once nine nodes are sealed in printed enclosures and
|
||||
* left on a wall for months with the radio transmitting continuously.
|
||||
*
|
||||
* What we are protecting is the enclosure, not the silicon. The part is rated
|
||||
* well past anything it will see here, but PLA goes soft at its glass
|
||||
* transition around 55-60 C -- and that is sagging under its own weight over
|
||||
* weeks, not a dramatic failure, so it would never announce itself.
|
||||
*
|
||||
* The sensor reads DIE temperature, which runs hotter than the plastic around
|
||||
* it. Thresholds are therefore set against the die with that offset in mind:
|
||||
* a die at ~72 C in a small vented box is roughly a 50-55 C enclosure, which
|
||||
* is where PLA starts to matter. They are deliberately not set at 55 C -- that
|
||||
* would throttle a perfectly healthy node.
|
||||
*
|
||||
* Throttling reduces WiFi transmit power, which is the dominant heat source
|
||||
* and degrades gracefully: links weaken but keep working, where cutting the
|
||||
* capture rate would blind the node entirely.
|
||||
*
|
||||
* IMPORTANT for anyone reading link diagnostics: a throttled node's RSSI drops
|
||||
* at both ends, which looks exactly like an obstruction. `thermal_state()` and
|
||||
* `thermal_tx_dbm()` exist so that can be distinguished from a cage or a wall
|
||||
* rather than misdiagnosed as one.
|
||||
*/
|
||||
|
||||
#include "sdkconfig.h"
|
||||
#include "thermal.h"
|
||||
|
||||
#if defined(CONFIG_THERMAL_MONITOR) && defined(CONFIG_IDF_TARGET_ESP32C6)
|
||||
|
||||
#include "driver/temperature_sensor.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_heap_caps.h"
|
||||
|
||||
static const char *TAG = "thermal";
|
||||
|
||||
static temperature_sensor_handle_t s_sensor;
|
||||
static bool s_ready;
|
||||
static float s_last_c = -273.0f;
|
||||
static float s_peak_c = -273.0f;
|
||||
static thermal_state_t s_state = THERMAL_OK;
|
||||
|
||||
/* esp_wifi_set_max_tx_power() takes 0.25 dBm units. 80 = 20 dBm, the default
|
||||
* on this part; each step down is 3 dB, which halves radiated power. */
|
||||
#define TXP_FULL 80 /* 20 dBm */
|
||||
#define TXP_STEP1 68 /* 17 dBm */
|
||||
#define TXP_STEP2 56 /* 14 dBm */
|
||||
#define TXP_MIN 44 /* 11 dBm */
|
||||
|
||||
static int8_t s_txp = TXP_FULL;
|
||||
|
||||
#ifdef CONFIG_THERMAL_THROTTLE
|
||||
/* Only referenced from the throttling block in thermal_tick(). Guarded so a
|
||||
* monitor-only build (the default, and what is on the wall) does not warn
|
||||
* about an unused function. */
|
||||
static void apply_tx_power(int8_t q)
|
||||
{
|
||||
if (q == s_txp) {
|
||||
return;
|
||||
}
|
||||
esp_err_t r = esp_wifi_set_max_tx_power(q);
|
||||
if (r == ESP_OK) {
|
||||
ESP_LOGW(TAG, "tx power %d.%02d dBm -> %d.%02d dBm (thermal)",
|
||||
s_txp / 4, (s_txp % 4) * 25, q / 4, (q % 4) * 25);
|
||||
s_txp = q;
|
||||
} else {
|
||||
ESP_LOGW(TAG, "esp_wifi_set_max_tx_power(%d) failed: %s",
|
||||
q, esp_err_to_name(r));
|
||||
}
|
||||
}
|
||||
#endif /* CONFIG_THERMAL_THROTTLE */
|
||||
|
||||
esp_err_t thermal_init(void)
|
||||
{
|
||||
/* The requested span must fit ENTIRELY inside one of the sensor's
|
||||
* predefined measurement ranges -- the driver walks a table and takes the
|
||||
* first that contains it, so a span crossing two boundaries is rejected
|
||||
* with ESP_ERR_INVALID_ARG rather than clamped. (-10,110) looks reasonable
|
||||
* and is invalid for exactly that reason; it failed on real hardware.
|
||||
*
|
||||
* (20,100) is the right first choice here: the warn/throttle/critical
|
||||
* thresholds all sit comfortably inside it, and indoor ambient is
|
||||
* essentially never below 20 C. The wider fallbacks exist so an unheated
|
||||
* room, or a different chip revision with a different table, degrades to a
|
||||
* usable range instead of leaving the node unmonitored. */
|
||||
static const struct { int lo, hi; } spans[] = {
|
||||
{20, 100}, {-10, 80}, {-30, 50}, {50, 125},
|
||||
};
|
||||
esp_err_t r = ESP_ERR_INVALID_ARG;
|
||||
for (size_t i = 0; i < sizeof(spans) / sizeof(spans[0]); i++) {
|
||||
temperature_sensor_config_t cfg =
|
||||
TEMPERATURE_SENSOR_CONFIG_DEFAULT(spans[i].lo, spans[i].hi);
|
||||
r = temperature_sensor_install(&cfg, &s_sensor);
|
||||
if (r == ESP_OK) {
|
||||
ESP_LOGI(TAG, "sensor range %d..%d C", spans[i].lo, spans[i].hi);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (r != ESP_OK) {
|
||||
ESP_LOGW(TAG, "sensor install failed on every range: %s "
|
||||
"(continuing unmonitored)", esp_err_to_name(r));
|
||||
return r;
|
||||
}
|
||||
r = temperature_sensor_enable(s_sensor);
|
||||
if (r != ESP_OK) {
|
||||
ESP_LOGW(TAG, "sensor enable failed: %s", esp_err_to_name(r));
|
||||
return r;
|
||||
}
|
||||
s_ready = true;
|
||||
ESP_LOGI(TAG, "monitoring on: warn %d C, throttle %d C, critical %d C "
|
||||
"(die temperature; protecting the enclosure, not the chip)",
|
||||
CONFIG_THERMAL_WARN_C, CONFIG_THERMAL_THROTTLE_C,
|
||||
CONFIG_THERMAL_CRITICAL_C);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void thermal_tick(void)
|
||||
{
|
||||
if (!s_ready) {
|
||||
return;
|
||||
}
|
||||
float c;
|
||||
if (temperature_sensor_get_celsius(s_sensor, &c) != ESP_OK) {
|
||||
return;
|
||||
}
|
||||
s_last_c = c;
|
||||
if (c > s_peak_c) {
|
||||
s_peak_c = c;
|
||||
}
|
||||
|
||||
/* Hysteresis on the way down: recovering at the same threshold that
|
||||
* tripped would oscillate, and each oscillation is a visible RSSI step on
|
||||
* every link this node has. The decision itself lives in thermal.h as a
|
||||
* pure function so a host test can exercise the same code this runs. */
|
||||
const thermal_state_t next = thermal_next_state(s_state, c,
|
||||
CONFIG_THERMAL_WARN_C,
|
||||
CONFIG_THERMAL_THROTTLE_C,
|
||||
CONFIG_THERMAL_CRITICAL_C,
|
||||
CONFIG_THERMAL_HYSTERESIS_C);
|
||||
|
||||
if (next != s_state) {
|
||||
ESP_LOGW(TAG, "%.1f C: %s -> %s",
|
||||
c, thermal_state_name(s_state), thermal_state_name(next));
|
||||
s_state = next;
|
||||
}
|
||||
|
||||
#ifdef CONFIG_THERMAL_THROTTLE
|
||||
/* Acting on the reading is separate from taking it. Measuring is inert;
|
||||
* changing transmit power mid-experiment moves every RSSI this node has,
|
||||
* which is not something to introduce during a fleet bring-up when any
|
||||
* surprise needs a single candidate cause. Turn this on once the fleet is
|
||||
* stable AND the logs show a node actually gets hot -- if none ever does,
|
||||
* it never needs turning on at all. */
|
||||
switch (s_state) {
|
||||
case THERMAL_CRITICAL: apply_tx_power(TXP_MIN); break;
|
||||
case THERMAL_THROTTLED: apply_tx_power(TXP_STEP2); break;
|
||||
case THERMAL_WARN: apply_tx_power(TXP_STEP1); break;
|
||||
case THERMAL_OK: apply_tx_power(TXP_FULL); break;
|
||||
}
|
||||
#endif
|
||||
|
||||
static int64_t s_last_log_us;
|
||||
int64_t now = esp_timer_get_time();
|
||||
if (now - s_last_log_us > 60LL * 1000 * 1000) {
|
||||
s_last_log_us = now;
|
||||
/* Heap rides along because it is the other thing that fails slowly
|
||||
* and silently over weeks. A node that reboots mysteriously after ten
|
||||
* days looks identical to one that overheated, unless the minimum
|
||||
* free heap has been visibly walking downwards the whole time. */
|
||||
ESP_LOGI(TAG, "die %.1f C (peak %.1f C) state=%s tx=%d.%02d dBm "
|
||||
"heap %u min %u",
|
||||
s_last_c, s_peak_c, thermal_state_name(s_state),
|
||||
s_txp / 4, (s_txp % 4) * 25,
|
||||
(unsigned)esp_get_free_heap_size(),
|
||||
(unsigned)esp_get_minimum_free_heap_size());
|
||||
}
|
||||
}
|
||||
|
||||
float thermal_celsius(void) { return s_last_c; }
|
||||
float thermal_peak_celsius(void) { return s_peak_c; }
|
||||
thermal_state_t thermal_state(void) { return s_state; }
|
||||
int8_t thermal_tx_dbm(void) { return (int8_t)(s_txp / 4); }
|
||||
|
||||
const char *thermal_state_name(thermal_state_t s)
|
||||
{
|
||||
switch (s) {
|
||||
case THERMAL_OK: return "ok";
|
||||
case THERMAL_WARN: return "warn";
|
||||
case THERMAL_THROTTLED: return "throttled";
|
||||
case THERMAL_CRITICAL: return "critical";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
#endif /* CONFIG_THERMAL_MONITOR && ESP32C6 */
|
||||
@@ -0,0 +1,106 @@
|
||||
/* On-chip thermal monitoring and radio throttling. See thermal.c for why the
|
||||
* thresholds are set against die temperature rather than the plastic. */
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <stdint.h>
|
||||
|
||||
typedef enum {
|
||||
THERMAL_OK = 0,
|
||||
THERMAL_WARN,
|
||||
THERMAL_THROTTLED,
|
||||
THERMAL_CRITICAL,
|
||||
} thermal_state_t;
|
||||
|
||||
/**
|
||||
* Decide the next thermal state from a reading and the current state.
|
||||
*
|
||||
* Pure: no globals, no hardware. Kept in the public header so a host test can
|
||||
* exercise the exact function the device build runs, rather than a copy of it
|
||||
* that is free to drift.
|
||||
*
|
||||
* Rising edges act immediately -- heat is a reason to react now. EVERY falling
|
||||
* edge requires cooling a full `hyst_c` below the threshold that was crossed on
|
||||
* the way up, because recovering at the same point that tripped oscillates, and
|
||||
* each oscillation steps this node's transmit power, which moves the RSSI of
|
||||
* every link it has. A reader cannot distinguish that from a wall moving.
|
||||
*
|
||||
* The CRITICAL -> THROTTLED edge is the reason this exists as a function. It
|
||||
* used to be unconditional: any reading below `critical_c` dropped the state
|
||||
* immediately, so a node sitting on the critical boundary flapped between
|
||||
* minimum and step-2 transmit power indefinitely -- precisely the oscillation
|
||||
* the hysteresis elsewhere was added to prevent. With defaults (critical 80,
|
||||
* hysteresis 8) leaving CRITICAL now needs 72 C, which is exactly the THROTTLED
|
||||
* entry point.
|
||||
*
|
||||
* @param cur Current state.
|
||||
* @param c Die temperature, degrees C.
|
||||
* @param warn_c Warn threshold.
|
||||
* @param throttle_c Throttle threshold.
|
||||
* @param critical_c Critical threshold.
|
||||
* @param hyst_c Degrees of cooling required before any step down.
|
||||
* @return The state to adopt.
|
||||
*/
|
||||
static inline thermal_state_t thermal_next_state(thermal_state_t cur, float c,
|
||||
int warn_c, int throttle_c,
|
||||
int critical_c, int hyst_c)
|
||||
{
|
||||
const float h = (float)hyst_c;
|
||||
|
||||
/* Rising: immediate, at the plain thresholds. */
|
||||
if (c >= (float)critical_c) return THERMAL_CRITICAL;
|
||||
|
||||
/* Falling out of CRITICAL needs a full hysteresis band, like every other
|
||||
* downward step. Without this the state tracks `c` across critical_c with
|
||||
* no damping at all. */
|
||||
if (cur == THERMAL_CRITICAL && c >= (float)critical_c - h) {
|
||||
return THERMAL_CRITICAL;
|
||||
}
|
||||
|
||||
if (c >= (float)throttle_c) return THERMAL_THROTTLED;
|
||||
|
||||
if (cur == THERMAL_THROTTLED && c >= (float)throttle_c - h) {
|
||||
return THERMAL_THROTTLED;
|
||||
}
|
||||
|
||||
if (c >= (float)warn_c) return THERMAL_WARN;
|
||||
|
||||
if (cur == THERMAL_WARN && c >= (float)warn_c - h) {
|
||||
return THERMAL_WARN;
|
||||
}
|
||||
|
||||
return THERMAL_OK;
|
||||
}
|
||||
|
||||
#if defined(CONFIG_THERMAL_MONITOR) && defined(CONFIG_IDF_TARGET_ESP32C6)
|
||||
|
||||
esp_err_t thermal_init(void);
|
||||
|
||||
/* Sample and apply policy. Call from a slow loop -- the die does not change
|
||||
* fast, and reading it more often buys nothing. */
|
||||
void thermal_tick(void);
|
||||
|
||||
float thermal_celsius(void);
|
||||
float thermal_peak_celsius(void);
|
||||
thermal_state_t thermal_state(void);
|
||||
|
||||
/* Current WiFi transmit ceiling in whole dBm.
|
||||
*
|
||||
* Exposed because a throttled node's RSSI falls at both ends, which is
|
||||
* indistinguishable from an obstruction in the link diagnostics. Anything
|
||||
* reading link strength should check this before concluding a wall moved. */
|
||||
int8_t thermal_tx_dbm(void);
|
||||
|
||||
const char *thermal_state_name(thermal_state_t s);
|
||||
|
||||
#else /* stubs: S3 build and when monitoring is off */
|
||||
|
||||
static inline esp_err_t thermal_init(void) { return ESP_OK; }
|
||||
static inline void thermal_tick(void) {}
|
||||
static inline float thermal_celsius(void) { return -273.0f; }
|
||||
static inline float thermal_peak_celsius(void) { return -273.0f; }
|
||||
static inline thermal_state_t thermal_state(void) { return THERMAL_OK; }
|
||||
static inline int8_t thermal_tx_dbm(void) { return 20; }
|
||||
static inline const char *thermal_state_name(thermal_state_t s) { (void)s; return "n/a"; }
|
||||
|
||||
#endif
|
||||
@@ -77,3 +77,22 @@ CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=8192
|
||||
# ── Power: keep CPU at max 160 MHz (C6 ceiling) for DSP throughput ──
|
||||
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160=y
|
||||
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ=160
|
||||
|
||||
# ADR-110 D1 / ruvnet/RuView#762: the 802.15.4 RX path never delivers a
|
||||
# protocol frame on IDF v5.4. Upstream tested five configurations -- channel
|
||||
# 15 and 26, OpenThread on and off, promiscuous on and off, PAN-matched and
|
||||
# broadcast -- and every one gave rx=0 with TX at 100% reliability. ESP-NOW
|
||||
# shipped as the workaround and is what this firmware actually syncs on
|
||||
# (measured 99.56% cross-board delivery, 104 us smoothed offset).
|
||||
#
|
||||
# The C6 has a single 2.4 GHz transceiver shared by WiFi, BLE and 802.15.4,
|
||||
# so leaving the 15.4 stack running costs coexistence time-slicing against
|
||||
# the CSI capture that is the entire point of the node -- beaconing every
|
||||
# 100 ms and listening on a radio that provably returns nothing. Disable it
|
||||
# and give the airtime back to WiFi, which matters most at nine nodes where
|
||||
# the 50 Hz receive gate is already the binding constraint.
|
||||
#
|
||||
# Safe to turn off: the ADR-018 byte 19 bit 4 "sync valid" flag is ORed from
|
||||
# both transports and the 15.4 branch is #ifdef-gated on this symbol, so the
|
||||
# working ESP-NOW path keeps setting it.
|
||||
CONFIG_C6_TIMESYNC_ENABLE=n
|
||||
@@ -44,9 +44,11 @@ FUZZ_DURATION ?= 30
|
||||
FUZZ_JOBS ?= 1
|
||||
|
||||
.PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 \
|
||||
test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests
|
||||
test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests \
|
||||
test_thermal run_thermal
|
||||
|
||||
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect
|
||||
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
|
||||
test_thermal
|
||||
|
||||
# --- ADR-110 encoding unit tests ---
|
||||
# Host-side, no libFuzzer needed — plain C99 deterministic table tests
|
||||
@@ -80,8 +82,19 @@ test_mmwave_detect: test_mmwave_detect.c $(MAIN_DIR)/mmwave_detect.h
|
||||
run_mmwave_detect: test_mmwave_detect
|
||||
./test_mmwave_detect
|
||||
|
||||
host_tests: run_adr110 run_vitals run_mmwave_detect
|
||||
@echo "Host tests passed (ADR-110 + vitals #998/#996 + mmwave detect #1135)"
|
||||
# --- Thermal state hysteresis ---
|
||||
# Host-side. Exercises thermal_next_state() from ../main/thermal.h directly --
|
||||
# the same function the device build runs -- so the test and the firmware cannot
|
||||
# drift. Pins every edge in both directions, including the CRITICAL -> THROTTLED
|
||||
# flap that had no hysteresis at all.
|
||||
test_thermal: test_thermal_hysteresis.c $(MAIN_DIR)/thermal.h
|
||||
cc -std=c99 -Wall -Wextra -Istubs -I$(MAIN_DIR) -o $@ $<
|
||||
|
||||
run_thermal: test_thermal
|
||||
./test_thermal
|
||||
|
||||
host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal
|
||||
@echo "Host tests passed (ADR-110 + vitals #998/#996 + mmwave detect #1135 + thermal)"
|
||||
|
||||
# --- Serialize fuzzer ---
|
||||
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @file test_thermal_hysteresis.c
|
||||
* @brief Pins every thermal transition, in both directions.
|
||||
*
|
||||
* Exercises `thermal_next_state()` from ../main/thermal.h directly -- the same
|
||||
* function the device build runs, not a copy -- so the test and the firmware
|
||||
* cannot drift apart.
|
||||
*
|
||||
* The regression that motivated this: the CRITICAL -> THROTTLED edge was
|
||||
* unconditional. Any reading below `critical_c` dropped the state at once, so a
|
||||
* node sitting on the critical boundary flapped between minimum and step-2
|
||||
* transmit power forever. Every oscillation moves the RSSI of every link that
|
||||
* node has, at both ends, which a reader cannot tell apart from an obstruction
|
||||
* -- exactly the failure the hysteresis on the other edges exists to prevent.
|
||||
*
|
||||
* Defaults under test: warn 65, throttle 72, critical 80, hysteresis 8.
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
#include "thermal.h"
|
||||
|
||||
#define WARN_C 65
|
||||
#define THROTTLE_C 72
|
||||
#define CRITICAL_C 80
|
||||
#define HYST_C 8
|
||||
|
||||
static int g_failures = 0;
|
||||
|
||||
static const char *name(thermal_state_t s)
|
||||
{
|
||||
switch (s) {
|
||||
case THERMAL_OK: return "OK";
|
||||
case THERMAL_WARN: return "WARN";
|
||||
case THERMAL_THROTTLED: return "THROTTLED";
|
||||
case THERMAL_CRITICAL: return "CRITICAL";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
static thermal_state_t step(thermal_state_t cur, float c)
|
||||
{
|
||||
return thermal_next_state(cur, c, WARN_C, THROTTLE_C, CRITICAL_C, HYST_C);
|
||||
}
|
||||
|
||||
static void expect(thermal_state_t cur, float c, thermal_state_t want,
|
||||
const char *why)
|
||||
{
|
||||
thermal_state_t got = step(cur, c);
|
||||
if (got != want) {
|
||||
printf(" FAIL: from %-9s at %5.1f C -> %s, expected %s\n %s\n",
|
||||
name(cur), c, name(got), name(want), why);
|
||||
g_failures++;
|
||||
}
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
printf("thermal hysteresis (warn %d, throttle %d, critical %d, hyst %d)\n",
|
||||
WARN_C, THROTTLE_C, CRITICAL_C, HYST_C);
|
||||
|
||||
/* ---- Rising edges are immediate. Heat is a reason to act now. ---- */
|
||||
expect(THERMAL_OK, 64.9f, THERMAL_OK, "below warn");
|
||||
expect(THERMAL_OK, 65.0f, THERMAL_WARN, "warn trips at the threshold");
|
||||
expect(THERMAL_WARN, 72.0f, THERMAL_THROTTLED, "throttle trips at the threshold");
|
||||
expect(THERMAL_THROTTLED, 80.0f, THERMAL_CRITICAL, "critical trips at the threshold");
|
||||
/* A jump straight past intermediate bands must not be damped on the way up. */
|
||||
expect(THERMAL_OK, 95.0f, THERMAL_CRITICAL, "a large rise skips straight to critical");
|
||||
|
||||
/* ---- THE REGRESSION: falling out of CRITICAL. ----
|
||||
*
|
||||
* Before the fix, 79.9 C dropped the state to THROTTLED immediately, so a
|
||||
* node hovering at 80 alternated TXP_MIN and TXP_STEP2 indefinitely. */
|
||||
expect(THERMAL_CRITICAL, 79.9f, THERMAL_CRITICAL,
|
||||
"0.1 C below critical must NOT step down -- this is the flap");
|
||||
expect(THERMAL_CRITICAL, 75.0f, THERMAL_CRITICAL,
|
||||
"still inside the hysteresis band");
|
||||
expect(THERMAL_CRITICAL, 72.1f, THERMAL_CRITICAL,
|
||||
"just inside the band (critical 80 - hyst 8 = 72)");
|
||||
/* Once it has cooled a full band it adopts the band it is ACTUALLY in,
|
||||
* which under these defaults is WARN, not THROTTLED: the critical exit
|
||||
* point (80 - 8 = 72) coincides exactly with the throttle threshold, so
|
||||
* anything cool enough to leave CRITICAL is already below THROTTLED's
|
||||
* entry. THROTTLED is therefore unreachable descending from CRITICAL at
|
||||
* hysteresis 8 -- a consequence of the chosen constants, not of the logic.
|
||||
* With a smaller hysteresis the intermediate step reappears, which the
|
||||
* next assertion pins. */
|
||||
expect(THERMAL_CRITICAL, 71.9f, THERMAL_WARN,
|
||||
"a full band below critical, and 71.9 is in the warn band");
|
||||
|
||||
/* Same descent with hysteresis 4: the exit point is 76, which lands inside
|
||||
* THROTTLED's band, so the intermediate power step is used. */
|
||||
{
|
||||
thermal_state_t got = thermal_next_state(THERMAL_CRITICAL, 75.9f,
|
||||
WARN_C, THROTTLE_C, CRITICAL_C, 4);
|
||||
if (got != THERMAL_THROTTLED) {
|
||||
printf(" FAIL: from CRITICAL at 75.9 C with hyst 4 -> %s, "
|
||||
"expected THROTTLED\n", name(got));
|
||||
g_failures++;
|
||||
}
|
||||
thermal_state_t held = thermal_next_state(THERMAL_CRITICAL, 76.1f,
|
||||
WARN_C, THROTTLE_C, CRITICAL_C, 4);
|
||||
if (held != THERMAL_CRITICAL) {
|
||||
printf(" FAIL: from CRITICAL at 76.1 C with hyst 4 -> %s, "
|
||||
"expected CRITICAL\n", name(held));
|
||||
g_failures++;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prove the flap is gone by walking across the boundary repeatedly: a node
|
||||
* dithering either side of 80 must not change state on the down-swings. */
|
||||
{
|
||||
thermal_state_t s = THERMAL_CRITICAL;
|
||||
int changes = 0;
|
||||
const float dither[] = {80.2f, 79.8f, 80.1f, 79.7f, 80.3f, 79.9f};
|
||||
for (unsigned i = 0; i < sizeof(dither) / sizeof(dither[0]); i++) {
|
||||
thermal_state_t n = step(s, dither[i]);
|
||||
if (n != s) changes++;
|
||||
s = n;
|
||||
}
|
||||
if (changes != 0) {
|
||||
printf(" FAIL: dithering around the critical boundary produced %d "
|
||||
"state change(s); each one steps transmit power\n", changes);
|
||||
g_failures++;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Falling out of THROTTLED. Behaviour preserved from before. ---- */
|
||||
expect(THERMAL_THROTTLED, 71.9f, THERMAL_THROTTLED, "just below the throttle point holds");
|
||||
expect(THERMAL_THROTTLED, 64.1f, THERMAL_THROTTLED, "still inside the band");
|
||||
expect(THERMAL_THROTTLED, 63.9f, THERMAL_OK, "throttle 72 - hyst 8 = 64 releases");
|
||||
|
||||
/* ---- Falling out of WARN. ---- */
|
||||
expect(THERMAL_WARN, 64.9f, THERMAL_WARN, "just below warn holds");
|
||||
expect(THERMAL_WARN, 57.1f, THERMAL_WARN, "still inside the band");
|
||||
expect(THERMAL_WARN, 56.9f, THERMAL_OK, "warn 65 - hyst 8 = 57 releases");
|
||||
|
||||
/* ---- OK is stable well below everything. ---- */
|
||||
expect(THERMAL_OK, 30.0f, THERMAL_OK, "a cold node stays OK");
|
||||
|
||||
if (g_failures == 0) {
|
||||
printf(" PASS\n");
|
||||
return 0;
|
||||
}
|
||||
printf(" %d check(s) FAILED\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract training stills from the real fal.ai room-visualization MP4s.
|
||||
|
||||
Reads the source videos (real fal.ai-generated room visualizations, one per
|
||||
style preset) and writes JPEG frames + a caption .txt per frame into a local
|
||||
(gitignored) training-data directory, ready to be zipped for fal.ai LoRA
|
||||
training. Frame interval is time-based (not frame-count-based) so each
|
||||
source contributes visual diversity proportional to its length.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
# (source mp4 path, style tag, caption) -- sources are real fal.ai output,
|
||||
# captured earlier this session by the fal-room-viz / room-viz-h3-upgrade
|
||||
# agents. Caption describes the RuView visual style so the LoRA learns the
|
||||
# recurring motifs (glowing pose overlay, WiFi signal arcs, vitals readout,
|
||||
# dark tech aesthetic) rather than any single room layout.
|
||||
SOURCES = [
|
||||
("/tmp/room-viz-architectural/room-viz.mp4", "architectural",
|
||||
"ruview style room visualization, architectural wireframe overlay, "
|
||||
"glowing cyan pose skeleton, wifi signal arcs, dark tech aesthetic"),
|
||||
("/tmp/room-viz-abstract/room-viz.mp4", "abstract",
|
||||
"ruview style room visualization, abstract particle field, "
|
||||
"glowing pose overlay, wifi signal arcs, dark tech aesthetic"),
|
||||
("/tmp/room-viz-branded/room-viz.mp4", "branded",
|
||||
"ruview style room visualization, branded hud overlay, "
|
||||
"glowing cyan and emerald pose skeleton, vitals readout, dark tech aesthetic"),
|
||||
("/tmp/room-viz-h3-architectural/room-viz.mp4", "h3-architectural",
|
||||
"ruview style room visualization, architectural wireframe overlay, "
|
||||
"glowing pose skeleton, wifi signal arcs, vitals readout, dark tech aesthetic"),
|
||||
("/tmp/room-viz-h3-cinematic/room-viz.mp4", "h3-cinematic",
|
||||
"ruview style room visualization, cinematic lighting, "
|
||||
"glowing pose overlay, wifi signal arcs, vitals readout, dark tech aesthetic"),
|
||||
("/tmp/room-viz-h3-minimal/room-viz.mp4", "h3-minimal",
|
||||
"ruview style room visualization, minimal clean overlay, "
|
||||
"glowing pose skeleton, subtle wifi signal arcs, dark tech aesthetic"),
|
||||
("/tmp/room-viz-h3-abstract/room-viz.mp4", "h3-abstract",
|
||||
"ruview style room visualization, abstract particle field, "
|
||||
"glowing pose overlay, wifi signal arcs, dark tech aesthetic"),
|
||||
]
|
||||
|
||||
FRAME_INTERVAL_SEC = 1.5
|
||||
|
||||
|
||||
def extract(src: Path, style: str, caption: str, out_dir: Path) -> int:
|
||||
pattern = out_dir / f"{style}_%03d.jpg"
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-loglevel", "error",
|
||||
"-i", str(src),
|
||||
"-vf", f"fps=1/{FRAME_INTERVAL_SEC}",
|
||||
"-q:v", "2",
|
||||
str(pattern),
|
||||
]
|
||||
subprocess.run(cmd, check=True)
|
||||
frames = sorted(out_dir.glob(f"{style}_*.jpg"))
|
||||
for frame in frames:
|
||||
frame.with_suffix(".txt").write_text(caption, encoding="utf-8")
|
||||
return len(frames)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--out", default=str(REPO_ROOT / "scripts" / "fal-visual-teacher" / "training-data"),
|
||||
help="Output directory for extracted frames + captions (gitignored)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
out_dir = Path(args.out)
|
||||
if out_dir.exists():
|
||||
shutil.rmtree(out_dir)
|
||||
out_dir.mkdir(parents=True)
|
||||
|
||||
total = 0
|
||||
for src_str, style, caption in SOURCES:
|
||||
src = Path(src_str)
|
||||
if not src.exists():
|
||||
print(f"SKIP (missing): {src}", file=sys.stderr)
|
||||
continue
|
||||
n = extract(src, style, caption, out_dir)
|
||||
print(f"{style}: {n} frames from {src}")
|
||||
total += n
|
||||
|
||||
print(f"\nTotal frames extracted: {total}")
|
||||
print(f"Training data dir: {out_dir}")
|
||||
if total < 4:
|
||||
print("ERROR: fal.ai training needs at least 4 images", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Submit a real fal.ai LoRA training job for the RuView synthetic visual teacher.
|
||||
|
||||
Zips the extracted training-data directory, uploads it to fal.ai storage,
|
||||
submits a training job to fal-ai/flux-lora-fast-training, polls until it
|
||||
completes, and writes the resulting trained-model reference (diffusers LoRA
|
||||
weights URL + config URL) to a local (gitignored) JSON file.
|
||||
|
||||
Architecture boundary (ADR-353): the model trained here is ONLY ever a
|
||||
source of supplementary synthetic visual/pixel output. It must never
|
||||
influence, or be treated as a source of, measured/derived RF, pose, vitals,
|
||||
identity, or confidence values.
|
||||
|
||||
Requires FAL_KEY in the environment (fetch from GCP Secret Manager,
|
||||
project cognitum-20260110, secret MINIMAX_MUSIC_FAL_KEY, before running).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
TRAINING_ENDPOINT = "fal-ai/flux-lora-fast-training"
|
||||
|
||||
|
||||
def zip_training_data(src_dir: Path, zip_path: Path) -> None:
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for f in sorted(src_dir.iterdir()):
|
||||
if f.is_file():
|
||||
zf.write(f, arcname=f.name)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--training-data", default=str(SCRIPT_DIR / "training-data"),
|
||||
help="Directory of extracted frames + captions (from extract_frames.py)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--steps", type=int, default=1000,
|
||||
help="Training steps (fal-ai/flux-lora-fast-training default is 1000, ~$2)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--trigger-word", default="ruviewstyle",
|
||||
help="Trigger token to prepend so the LoRA is invocable at inference time",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out", default=str(SCRIPT_DIR / "output" / "training-result.json"),
|
||||
help="Where to write the trained model reference JSON (gitignored)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.environ.get("FAL_KEY"):
|
||||
print("ERROR: FAL_KEY not set in environment", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
import fal_client # imported after the FAL_KEY check for a clearer error
|
||||
|
||||
training_dir = Path(args.training_data)
|
||||
if not training_dir.exists():
|
||||
print(f"ERROR: {training_dir} not found -- run extract_frames.py first", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
zip_path = training_dir.parent / "training-data.zip"
|
||||
print(f"Zipping {training_dir} -> {zip_path}")
|
||||
zip_training_data(training_dir, zip_path)
|
||||
print(f"Zip size: {zip_path.stat().st_size / 1024:.1f} KB")
|
||||
|
||||
print("Uploading training zip to fal.ai storage...")
|
||||
images_data_url = fal_client.upload_file(str(zip_path))
|
||||
print(f"Uploaded: {images_data_url}")
|
||||
|
||||
training_args = {
|
||||
"images_data_url": images_data_url,
|
||||
"steps": args.steps,
|
||||
"trigger_word": args.trigger_word,
|
||||
# This is a style LoRA (recurring visual aesthetic across varied room
|
||||
# layouts), not a subject/character LoRA -- fal.ai's is_style flag
|
||||
# disables segmentation/auto-captioning so our own caption .txt files
|
||||
# (one per frame, describing the shared style) are used as-is.
|
||||
"is_style": True,
|
||||
}
|
||||
print(f"Submitting training job to {TRAINING_ENDPOINT} (steps={args.steps})...")
|
||||
handle = fal_client.submit(TRAINING_ENDPOINT, arguments=training_args)
|
||||
print(f"request_id: {handle.request_id}")
|
||||
|
||||
start = time.time()
|
||||
for event in handle.iter_events(with_logs=True, interval=5.0):
|
||||
print(f"[{time.time() - start:.0f}s] {event}")
|
||||
|
||||
result = handle.get()
|
||||
elapsed = time.time() - start
|
||||
print(f"Training completed in {elapsed:.0f}s")
|
||||
|
||||
out_path = Path(args.out)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
record = {
|
||||
"endpoint": TRAINING_ENDPOINT,
|
||||
"request_id": handle.request_id,
|
||||
"steps": args.steps,
|
||||
"trigger_word": args.trigger_word,
|
||||
"training_images": sum(1 for f in training_dir.glob("*.jpg")),
|
||||
"elapsed_seconds": round(elapsed),
|
||||
"result": result,
|
||||
}
|
||||
out_path.write_text(json.dumps(record, indent=2), encoding="utf-8")
|
||||
print(f"Wrote {out_path}")
|
||||
|
||||
lora_file = (result or {}).get("diffusers_lora_file", {}).get("url")
|
||||
if lora_file:
|
||||
print(f"\nTrained LoRA weights URL: {lora_file}")
|
||||
else:
|
||||
print("\nWARNING: no diffusers_lora_file.url in result -- inspect the JSON", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run a real validation inference call against the trained visual-teacher LoRA.
|
||||
|
||||
Loads the trained model reference written by train_lora.py, calls
|
||||
fal-ai/flux-lora with the trained LoRA weights, and saves the generated
|
||||
image as evidence that training actually produced a working style LoRA.
|
||||
|
||||
This inference call, and the LoRA it exercises, produce SYNTHETIC display
|
||||
imagery only (ADR-353) -- never a source of measured RF/pose/vitals/identity
|
||||
or confidence values.
|
||||
|
||||
Requires FAL_KEY in the environment.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
INFERENCE_ENDPOINT = "fal-ai/flux-lora"
|
||||
|
||||
DEFAULT_PROMPT = (
|
||||
"ruviewstyle room visualization, a living room with a glowing cyan pose "
|
||||
"skeleton overlay, wifi signal arcs radiating across the space, a vitals "
|
||||
"readout panel, dark tech aesthetic, synthetic sensor visualization"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--training-result", default=str(SCRIPT_DIR / "output" / "training-result.json"),
|
||||
)
|
||||
parser.add_argument("--prompt", default=DEFAULT_PROMPT)
|
||||
parser.add_argument("--scale", type=float, default=1.0, help="LoRA application strength")
|
||||
parser.add_argument(
|
||||
"--out", default=str(REPO_ROOT / "assets" / "fal-visual-teacher-sample.png"),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.environ.get("FAL_KEY"):
|
||||
print("ERROR: FAL_KEY not set in environment", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
import fal_client
|
||||
|
||||
training_result_path = Path(args.training_result)
|
||||
record = json.loads(training_result_path.read_text(encoding="utf-8"))
|
||||
lora_url = record["result"]["diffusers_lora_file"]["url"]
|
||||
print(f"Using trained LoRA: {lora_url}")
|
||||
|
||||
print(f"Submitting validation inference to {INFERENCE_ENDPOINT}...")
|
||||
result = fal_client.subscribe(
|
||||
INFERENCE_ENDPOINT,
|
||||
arguments={
|
||||
"prompt": args.prompt,
|
||||
"loras": [{"path": lora_url, "scale": args.scale}],
|
||||
"image_size": "landscape_16_9",
|
||||
"num_images": 1,
|
||||
"output_format": "png",
|
||||
},
|
||||
with_logs=True,
|
||||
)
|
||||
|
||||
images = result.get("images", [])
|
||||
if not images:
|
||||
print(f"ERROR: no images in result: {result}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
image_url = images[0]["url"]
|
||||
print(f"Generated image: {image_url}")
|
||||
|
||||
out_path = Path(args.out)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
urllib.request.urlretrieve(image_url, out_path)
|
||||
print(f"Saved validation sample to {out_path}")
|
||||
|
||||
meta_path = out_path.with_suffix(".json")
|
||||
meta_path.write_text(json.dumps({
|
||||
"endpoint": INFERENCE_ENDPOINT,
|
||||
"lora_url": lora_url,
|
||||
"prompt": args.prompt,
|
||||
"scale": args.scale,
|
||||
"source_image_url": image_url,
|
||||
}, indent=2), encoding="utf-8")
|
||||
print(f"Saved validation metadata to {meta_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Faithful Python port of the window/baseline/metric definitions in
|
||||
v2/crates/ruforecast/crates/ruforecast-train/examples/bidmc_prepare.rs and
|
||||
v2/crates/ruforecast/crates/ruforecast-core/src/{baseline,metrics}.rs so the
|
||||
zero-shot pretrained-model comparison is apples-to-apples with RuForecast's
|
||||
own real-data evaluation.
|
||||
|
||||
CONTEXT=64, HORIZON=12, VARIATES=3 (HR, RESP, SpO2), quantiles as in
|
||||
ForecastModelConfig::tiny_ci(), seasonal_period=12 (the `evaluate` CLI default).
|
||||
"""
|
||||
import csv
|
||||
import math
|
||||
import os
|
||||
|
||||
CONTEXT = 64
|
||||
HORIZON = 12
|
||||
WINDOW_ROWS = CONTEXT + HORIZON
|
||||
VARIATE_NAMES = ["HR", "RESP", "SpO2"]
|
||||
QUANTILES = [0.05, 0.10, 0.25, 0.50, 0.75, 0.90, 0.95]
|
||||
SEASONAL_PERIOD = 12
|
||||
RAW_DIR = "/tmp/bidmc-raw"
|
||||
|
||||
|
||||
def read_patient(pid: int):
|
||||
path = os.path.join(RAW_DIR, f"bidmc_{pid:02d}_Numerics.csv")
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
rows = []
|
||||
with open(path, newline="") as f:
|
||||
reader = csv.reader(f)
|
||||
next(reader) # header
|
||||
for parts in reader:
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
parts = [p.strip() for p in parts]
|
||||
try:
|
||||
hr = float(parts[1])
|
||||
resp = float(parts[3])
|
||||
spo2 = float(parts[4])
|
||||
except ValueError:
|
||||
return None # malformed row -> treat like the Rust `?` early-return
|
||||
rows.append((hr, resp, spo2))
|
||||
return rows
|
||||
|
||||
|
||||
def build_window(pid: int):
|
||||
"""One window per patient: first CONTEXT rows are context, next HORIZON
|
||||
rows are targets. Mirrors build_window() in bidmc_prepare.rs exactly
|
||||
(context_start = row 0, no scanning for a "best" window)."""
|
||||
rows = read_patient(pid)
|
||||
if rows is None or len(rows) < WINDOW_ROWS:
|
||||
return None
|
||||
first = rows[:WINDOW_ROWS]
|
||||
for row in first:
|
||||
for v in row:
|
||||
if not math.isfinite(v):
|
||||
return None
|
||||
context = first[:CONTEXT] # list of (hr, resp, spo2), CONTEXT rows
|
||||
targets = first[CONTEXT:WINDOW_ROWS] # HORIZON rows
|
||||
return {"patient": pid, "context": context, "targets": targets}
|
||||
|
||||
|
||||
def judge_split(judge: str):
|
||||
if judge == "a":
|
||||
train_ids = list(range(1, 38))
|
||||
test_ids = list(range(38, 54))
|
||||
elif judge == "b":
|
||||
train_ids = [i for i in range(1, 54) if i % 2 == 1]
|
||||
test_ids = [i for i in range(1, 54) if i % 2 == 0]
|
||||
else:
|
||||
raise ValueError(judge)
|
||||
return train_ids, test_ids
|
||||
|
||||
|
||||
def build_test_windows(judge: str):
|
||||
_, test_ids = judge_split(judge)
|
||||
windows = []
|
||||
excluded = []
|
||||
for pid in test_ids:
|
||||
w = build_window(pid)
|
||||
if w is None:
|
||||
excluded.append(pid)
|
||||
else:
|
||||
windows.append(w)
|
||||
return windows, excluded
|
||||
|
||||
|
||||
# ---- baselines (mirror ruforecast-core::baseline.rs) ----
|
||||
|
||||
def last_value_forecast(context):
|
||||
"""Repeat the last observed context row for every future step (every
|
||||
quantile gets the same point value -> degenerate distribution)."""
|
||||
last = context[-1]
|
||||
return [[last[v] for _ in QUANTILES] for _ in range(HORIZON) for v in range(len(last))]
|
||||
# NOTE: shape handled explicitly in metric functions below instead; see
|
||||
# last_value_predictions() for the actually-used [horizon][variate] form.
|
||||
|
||||
|
||||
def last_value_predictions(context):
|
||||
last = context[-1]
|
||||
return [[last[v] for v in range(len(last))] for _ in range(HORIZON)]
|
||||
|
||||
|
||||
def seasonal_naive_predictions(context, period=SEASONAL_PERIOD):
|
||||
"""context has CONTEXT=64 rows; base = len(context) - period; for step in
|
||||
0..HORIZON: row = base + step % period."""
|
||||
base = len(context) - period
|
||||
preds = []
|
||||
for step in range(HORIZON):
|
||||
row = base + (step % period)
|
||||
preds.append(list(context[row]))
|
||||
return preds
|
||||
|
||||
|
||||
# ---- metrics (mirror ruforecast-core::metrics.rs) ----
|
||||
|
||||
def mae(actual_flat, predicted_flat):
|
||||
total, count = 0.0, 0
|
||||
for a, p in zip(actual_flat, predicted_flat):
|
||||
total += abs(a - p)
|
||||
count += 1
|
||||
return total / count if count else float("nan")
|
||||
|
||||
|
||||
def weighted_quantile_loss(actual_flat, quantile_preds_flat):
|
||||
"""actual_flat: list of HORIZON*VARIATES actuals in step-major order
|
||||
(step0-var0,step0-var1,step0-var2, step1-var0, ...).
|
||||
quantile_preds_flat: same order, each entry is a list over QUANTILES.
|
||||
Matches weighted_quantile_loss() in metrics.rs: sum pinball over every
|
||||
(cell, quantile), normalize by 2 / (num_quantiles * sum(|actual|))."""
|
||||
loss = 0.0
|
||||
scale = 0.0
|
||||
for actual, preds in zip(actual_flat, quantile_preds_flat):
|
||||
scale += abs(actual)
|
||||
for q, pred in zip(QUANTILES, preds):
|
||||
residual = actual - pred
|
||||
loss += q * residual if residual >= 0 else (q - 1.0) * residual
|
||||
if scale == 0.0:
|
||||
raise ValueError("weighted quantile loss undefined: all-zero actual targets")
|
||||
return 2.0 * loss / (len(QUANTILES) * scale)
|
||||
|
||||
|
||||
def flatten_targets_step_major(targets):
|
||||
"""targets: HORIZON rows of VARIATES values -> flat step-major list."""
|
||||
out = []
|
||||
for row in targets:
|
||||
out.extend(row)
|
||||
return out
|
||||
|
||||
|
||||
def point_to_quantile_preds(point_preds):
|
||||
"""point_preds: HORIZON rows of VARIATES point values (deterministic
|
||||
baseline) -> step-major flat list where every quantile equals the point
|
||||
value, matching how LastValue/SeasonalNaive fill every quantile slot
|
||||
with the same repeated value."""
|
||||
out = []
|
||||
for row in point_preds:
|
||||
for v in row:
|
||||
out.append([v for _ in QUANTILES])
|
||||
return out
|
||||
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Real zero-shot pretrained-checkpoint comparison for RuForecast (see
|
||||
docs/benchmarks/ruforecast.md). Runs Amazon Chronos-Bolt (and TimesFM, if
|
||||
installed) zero-shot inference against the SAME BIDMC test windows,
|
||||
baselines, and metrics RuForecast's own `evaluate` CLI uses, and prints a
|
||||
markdown table.
|
||||
|
||||
No fine-tuning. No training. Every "model" forecast below comes from a
|
||||
public pretrained checkpoint's zero-shot .predict()/.forecast() call.
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, "/tmp") # bidmc_windows.py copied there
|
||||
from bidmc_windows import (
|
||||
CONTEXT, HORIZON, VARIATE_NAMES, QUANTILES, SEASONAL_PERIOD,
|
||||
build_test_windows, judge_split,
|
||||
last_value_predictions, seasonal_naive_predictions,
|
||||
mae, weighted_quantile_loss, flatten_targets_step_major, point_to_quantile_preds,
|
||||
)
|
||||
|
||||
|
||||
def eval_baselines(windows):
|
||||
"""Returns dict: name -> (mae, wql) aggregated over ALL test windows and
|
||||
variates, matching how the Rust evaluate CLI aggregates (one running
|
||||
mean/scale accumulation across the whole test set, not averaged per
|
||||
window)."""
|
||||
results = {}
|
||||
for name, fn in [("last-value", last_value_predictions),
|
||||
("seasonal-naive", lambda ctx: seasonal_naive_predictions(ctx, SEASONAL_PERIOD))]:
|
||||
all_actual, all_point_pred, all_qpred = [], [], []
|
||||
for w in windows:
|
||||
preds = fn(w["context"]) # HORIZON rows x VARIATES
|
||||
actual_flat = flatten_targets_step_major(w["targets"])
|
||||
point_flat = flatten_targets_step_major(preds)
|
||||
qpred_flat = point_to_quantile_preds(preds)
|
||||
all_actual.extend(actual_flat)
|
||||
all_point_pred.extend(point_flat)
|
||||
all_qpred.extend(qpred_flat)
|
||||
results[name] = (mae(all_actual, all_point_pred), weighted_quantile_loss(all_actual, all_qpred))
|
||||
return results
|
||||
|
||||
|
||||
def chronos_zero_shot_predictions(windows, model_name="amazon/chronos-bolt-base", device="cuda"):
|
||||
"""Per-variate univariate zero-shot forecast (Chronos has no native
|
||||
multivariate mode) via ChronosPipeline. Returns list parallel to
|
||||
`windows`, each entry: HORIZON rows x VARIATES quantile-lists."""
|
||||
from chronos import BaseChronosPipeline
|
||||
pipeline = BaseChronosPipeline.from_pretrained(model_name, device_map=device, torch_dtype="bfloat16" if device == "cuda" else "float32")
|
||||
import torch
|
||||
|
||||
all_preds = []
|
||||
for w in windows:
|
||||
ctx = w["context"] # HORIZON... actually CONTEXT rows x VARIATES
|
||||
per_variate_q = []
|
||||
for v in range(len(VARIATE_NAMES)):
|
||||
series = torch.tensor([row[v] for row in ctx], dtype=torch.float32)
|
||||
quantiles, _mean = pipeline.predict_quantiles(
|
||||
inputs=series,
|
||||
prediction_length=HORIZON,
|
||||
quantile_levels=QUANTILES,
|
||||
)
|
||||
# quantiles shape: [1, HORIZON, len(QUANTILES)]
|
||||
per_variate_q.append(quantiles[0].numpy()) # [HORIZON, Q]
|
||||
# reshape to HORIZON rows, each VARIATES entries of Q-length lists
|
||||
rows = []
|
||||
for h in range(HORIZON):
|
||||
row = [per_variate_q[v][h].tolist() for v in range(len(VARIATE_NAMES))]
|
||||
rows.append(row)
|
||||
all_preds.append(rows)
|
||||
return all_preds
|
||||
|
||||
|
||||
def timesfm_zero_shot_predictions(windows, model_repo="google/timesfm-2.5-200m-pytorch"):
|
||||
"""Per-variate univariate zero-shot forecast via TimesFM 2.5 200M torch.
|
||||
Quantile output has 10 columns; per the installed package's source
|
||||
(timesfm_2p5_base.py: `decode_index: int = 5`), column 5 is the
|
||||
point/mean forecast and columns 1..9 are quantiles [0.1..0.9] in order
|
||||
(verified empirically: columns 1-9 are monotonic and column 5 exactly
|
||||
equals the returned point forecast on every sampled window/variate;
|
||||
column 0 is an unused duplicate channel, not a real quantile level).
|
||||
TimesFM has no 0.05/0.95 levels (clamped to 0.1/0.9, same treatment
|
||||
Chronos-Bolt applies for out-of-range levels) and no native 0.25/0.75
|
||||
(linearly interpolated between the adjacent 0.1-spaced levels it does
|
||||
have)."""
|
||||
import timesfm
|
||||
import numpy as np
|
||||
|
||||
m = timesfm.TimesFM_2p5_200M_torch.from_pretrained(model_repo)
|
||||
m.compile(timesfm.ForecastConfig(
|
||||
max_context=CONTEXT, max_horizon=HORIZON, normalize_inputs=True,
|
||||
use_continuous_quantile_head=True, fix_quantile_crossing=True,
|
||||
))
|
||||
native_levels = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9] # columns 1..9
|
||||
|
||||
def sample_at(row10, level):
|
||||
if level <= native_levels[0]:
|
||||
return row10[1]
|
||||
if level >= native_levels[-1]:
|
||||
return row10[9]
|
||||
for i in range(len(native_levels) - 1):
|
||||
lo, hi = native_levels[i], native_levels[i + 1]
|
||||
if lo <= level <= hi:
|
||||
col_lo, col_hi = 1 + i, 1 + i + 1
|
||||
if hi == lo:
|
||||
return row10[col_lo]
|
||||
frac = (level - lo) / (hi - lo)
|
||||
return row10[col_lo] + frac * (row10[col_hi] - row10[col_lo])
|
||||
raise ValueError(level)
|
||||
|
||||
all_preds = []
|
||||
for w in windows:
|
||||
ctx = w["context"]
|
||||
per_variate_rows = []
|
||||
for v in range(len(VARIATE_NAMES)):
|
||||
series = np.array([row[v] for row in ctx], dtype=np.float32)
|
||||
_point, quant = m.forecast(horizon=HORIZON, inputs=[series])
|
||||
per_variate_rows.append(quant[0]) # [HORIZON, 10]
|
||||
rows = []
|
||||
for h in range(HORIZON):
|
||||
row = []
|
||||
for v in range(len(VARIATE_NAMES)):
|
||||
row10 = per_variate_rows[v][h]
|
||||
row.append([sample_at(row10, q) for q in QUANTILES])
|
||||
rows.append(row)
|
||||
all_preds.append(rows)
|
||||
return all_preds
|
||||
|
||||
|
||||
def eval_pretrained(name, predict_fn, windows):
|
||||
t0 = time.time()
|
||||
preds = predict_fn(windows)
|
||||
elapsed = time.time() - t0
|
||||
all_actual, all_point_pred, all_qpred = [], [], []
|
||||
median_idx = QUANTILES.index(0.50)
|
||||
for w, pred_rows in zip(windows, preds):
|
||||
actual_flat = flatten_targets_step_major(w["targets"])
|
||||
qpred_flat = []
|
||||
point_flat = []
|
||||
for row in pred_rows: # HORIZON rows, each VARIATES quantile-lists
|
||||
for var_qs in row:
|
||||
qpred_flat.append(list(var_qs))
|
||||
point_flat.append(var_qs[median_idx])
|
||||
all_actual.extend(actual_flat)
|
||||
all_point_pred.extend(point_flat)
|
||||
all_qpred.extend(qpred_flat)
|
||||
m = mae(all_actual, all_point_pred)
|
||||
wql = weighted_quantile_loss(all_actual, all_qpred)
|
||||
return m, wql, elapsed
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--models", nargs="+", default=["chronos-bolt-base"])
|
||||
parser.add_argument("--judges", nargs="+", default=["a", "b"])
|
||||
args = parser.parse_args()
|
||||
|
||||
print("| Judge | Model | Test windows | MAE | WQL | Wall time (s) |")
|
||||
print("|---|---|---:|---:|---:|---:|")
|
||||
|
||||
for judge in args.judges:
|
||||
windows, excluded = build_test_windows(judge)
|
||||
train_ids, test_ids = judge_split(judge)
|
||||
print(f"<!-- judge {judge}: {len(windows)} test windows from {len(test_ids)} candidate patients, excluded={excluded} -->", file=sys.stderr)
|
||||
|
||||
baselines = eval_baselines(windows)
|
||||
for name, (m, wql) in baselines.items():
|
||||
print(f"| {judge} | {name} (baseline) | {len(windows)} | {m:.4f} | {wql:.5f} | - |")
|
||||
|
||||
for model in args.models:
|
||||
if model == "chronos-bolt-base":
|
||||
fn = lambda w: chronos_zero_shot_predictions(w, "amazon/chronos-bolt-base")
|
||||
elif model == "chronos-bolt-small":
|
||||
fn = lambda w: chronos_zero_shot_predictions(w, "amazon/chronos-bolt-small")
|
||||
elif model == "chronos-t5-small":
|
||||
fn = lambda w: chronos_zero_shot_predictions(w, "amazon/chronos-t5-small")
|
||||
elif model == "timesfm-2.5-200m":
|
||||
fn = timesfm_zero_shot_predictions
|
||||
else:
|
||||
print(f"unknown model {model}", file=sys.stderr)
|
||||
continue
|
||||
try:
|
||||
m, wql, elapsed = eval_pretrained(model, fn, windows)
|
||||
print(f"| {judge} | {model} (zero-shot) | {len(windows)} | {m:.4f} | {wql:.5f} | {elapsed:.1f} |")
|
||||
except Exception as e:
|
||||
print(f"| {judge} | {model} (zero-shot) | {len(windows)} | FAILED | FAILED | - |")
|
||||
print(f"ERROR running {model} on judge {judge}: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+288
@@ -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'
|
||||
@@ -28,6 +28,7 @@ import { i18n } from './utils/i18n.js';
|
||||
import { ScreenshotTool } from './utils/screenshot.js';
|
||||
import { UptimeClock } from './utils/uptime-clock.js';
|
||||
import { QuickSettings } from './utils/quick-settings.js';
|
||||
import { dataSourceBanner } from './utils/data-source-banner.js';
|
||||
|
||||
class WiFiDensePoseApp {
|
||||
constructor() {
|
||||
@@ -98,6 +99,10 @@ class WiFiDensePoseApp {
|
||||
this.showBackendStatus('Backend unavailable — start sensing-server', 'warning');
|
||||
}
|
||||
|
||||
// Mount the global provenance bar before the service starts, so the very
|
||||
// first non-live state is visible on whichever tab is showing.
|
||||
dataSourceBanner.init();
|
||||
|
||||
// Start the sensing WebSocket service early so the dashboard and
|
||||
// live-demo tabs can show the correct data-source status immediately.
|
||||
sensingService.start();
|
||||
@@ -360,6 +365,22 @@ class WiFiDensePoseApp {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.cleanup();
|
||||
});
|
||||
|
||||
// `beforeunload` above tears down every component and disconnects the
|
||||
// sensing WebSocket. If the browser restores this page from bfcache on
|
||||
// Back/Forward instead of doing a fresh navigation, that torn-down state
|
||||
// comes back frozen exactly as-is — nothing re-runs DOMContentLoaded, so
|
||||
// sensingService.start() never fires again and disposed components (e.g.
|
||||
// Room Builder) never get recreated. `pageshow`'s `event.persisted` is
|
||||
// the standard signal for "this is a bfcache restore, not a fresh load";
|
||||
// forcing a real reload here is simpler and more robust than trying to
|
||||
// hand-reinitialize every component/connection back to a clean state.
|
||||
window.addEventListener('pageshow', (event) => {
|
||||
if (event.persisted) {
|
||||
console.log('Page restored from bfcache after cleanup() — forcing reload');
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle window resize
|
||||
|
||||
@@ -95,7 +95,8 @@ export class DashboardTab {
|
||||
'live': { text: 'ESP32', status: 'healthy', msg: 'Real hardware connected' },
|
||||
'server-simulated': { text: 'SIMULATED', status: 'warning', msg: 'Server running without hardware' },
|
||||
'reconnecting': { text: 'RECONNECTING', status: 'degraded', msg: 'Attempting to connect...' },
|
||||
'simulated': { text: 'OFFLINE', status: 'unhealthy', msg: 'Server unreachable, local fallback' },
|
||||
'unreachable': { text: 'NO DATA', status: 'unhealthy', msg: 'Server unreachable — readings below are stale' },
|
||||
'simulated': { text: 'INVENTED', status: 'unhealthy', msg: 'Browser-generated data, not measured' },
|
||||
};
|
||||
const cfg = config[ds] || config['reconnecting'];
|
||||
el.className = `component-status status-${cfg.status}`;
|
||||
|
||||
@@ -1318,7 +1318,8 @@ export class LiveDemoTab {
|
||||
const ds = sensingService.dataSource;
|
||||
if (ds === 'live') return 'Active \u2014 ESP32 Live';
|
||||
if (ds === 'server-simulated') return 'Active \u2014 Simulated Data';
|
||||
if (ds === 'simulated') return 'Active \u2014 Offline Simulation';
|
||||
if (ds === 'unreachable') return 'No Data \u2014 Server Unreachable';
|
||||
if (ds === 'simulated') return 'INVENTED DATA \u2014 Not Measured';
|
||||
return 'Connecting...';
|
||||
}
|
||||
|
||||
@@ -1331,7 +1332,8 @@ export class LiveDemoTab {
|
||||
'live': { text: 'LIVE \u2014 ESP32 Hardware Connected', cls: 'demo-source-live' },
|
||||
'server-simulated': { text: 'SIMULATED DATA \u2014 No Hardware Detected', cls: 'demo-source-sim' },
|
||||
'reconnecting': { text: 'RECONNECTING TO SERVER...', cls: 'demo-source-reconnecting' },
|
||||
'simulated': { text: 'OFFLINE \u2014 Server Unreachable, Local Sim', cls: 'demo-source-offline' },
|
||||
'unreachable': { text: 'NO DATA \u2014 Server Unreachable, Display Is Stale', cls: 'demo-source-offline' },
|
||||
'simulated': { text: 'INVENTED DATA \u2014 Generated By Your Browser, Not Measured', cls: 'demo-source-offline' },
|
||||
};
|
||||
const cfg = config[ds] || config['reconnecting'];
|
||||
banner.textContent = cfg.text;
|
||||
|
||||
@@ -228,7 +228,8 @@ export class SensingTab {
|
||||
'live': { text: 'LIVE \u2014 ESP32 HARDWARE', cls: 'sensing-source-live' },
|
||||
'server-simulated': { text: 'SIMULATED \u2014 NO HARDWARE', cls: 'sensing-source-server-sim' },
|
||||
'reconnecting': { text: 'RECONNECTING...', cls: 'sensing-source-reconnecting' },
|
||||
'simulated': { text: 'OFFLINE \u2014 CLIENT SIMULATION', cls: 'sensing-source-simulated' },
|
||||
'unreachable': { text: 'NO DATA \u2014 SERVER UNREACHABLE', cls: 'sensing-source-simulated' },
|
||||
'simulated': { text: 'INVENTED DATA \u2014 NOT MEASURED', cls: 'sensing-source-simulated' },
|
||||
};
|
||||
const cfg = bannerConfig[dataSource] || bannerConfig.reconnecting;
|
||||
banner.textContent = cfg.text;
|
||||
|
||||
+122
-12
@@ -37,6 +37,39 @@ const MAX_RECONNECT_ATTEMPTS = 20;
|
||||
const SIM_FALLBACK_AFTER_ATTEMPTS = 5;
|
||||
const SIMULATION_INTERVAL = 500; // ms
|
||||
|
||||
export const SIM_FALLBACK_STORAGE_KEY = 'ruview-client-simulation';
|
||||
|
||||
/**
|
||||
* Whether the client may invent sensing frames when the server is unreachable.
|
||||
*
|
||||
* Default **off**, deliberately. The generated frames are plausible — presence
|
||||
* derived from `Math.sin(t * 0.3)` crossing a fixed threshold produces a steady
|
||||
* absent/present_still alternation that reads exactly like a real classifier
|
||||
* struggling at its decision boundary. A reader with no reason to suspect the
|
||||
* server had dropped will diagnose the sensing pipeline, and be wrong. That is
|
||||
* not a hypothetical: it cost a full debugging session on 2026-08-28, chasing a
|
||||
* "flip-flop" that was this function all along while the server sat at a
|
||||
* perfectly steady presence=true.
|
||||
*
|
||||
* For an instrument, "no signal" is the honest reading and a useful one.
|
||||
* Synthetic motion is only appropriate for a demo, so it must be asked for:
|
||||
* `?simulate=1`, or `localStorage['ruview-client-simulation'] = 'true'`.
|
||||
*
|
||||
* Repository rule: never present synthetic output as measurement (CLAUDE.md).
|
||||
*/
|
||||
function clientSimulationAllowed() {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('simulate') === '1') return true;
|
||||
if (params.get('simulate') === '0') return false;
|
||||
} catch { /* no window/location — non-browser test context */ }
|
||||
try {
|
||||
return localStorage.getItem(SIM_FALLBACK_STORAGE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class SensingService {
|
||||
constructor() {
|
||||
/** @type {WebSocket|null} */
|
||||
@@ -46,6 +79,11 @@ class SensingService {
|
||||
this._reconnectAttempt = 0;
|
||||
this._reconnectTimer = null;
|
||||
this._simTimer = null;
|
||||
// Guards the await window inside `_connect()` (see the comment there).
|
||||
this._connectInFlight = false;
|
||||
// Bumped per connect attempt and by `stop()`; lets a socket recognise that
|
||||
// it has been abandoned and stop driving reconnect state.
|
||||
this._connectEpoch = 0;
|
||||
// Connection state: disconnected | connecting | connected | reconnecting | simulated
|
||||
this._state = 'disconnected';
|
||||
// Data-source label exposed to the UI:
|
||||
@@ -72,6 +110,10 @@ class SensingService {
|
||||
|
||||
/** Stop the service entirely. */
|
||||
stop() {
|
||||
// Invalidate any attempt still inside its await window, so a socket opened
|
||||
// after this call does not resurrect the service.
|
||||
this._connectEpoch++;
|
||||
this._connectInFlight = false;
|
||||
this._clearTimers();
|
||||
if (this._ws) {
|
||||
this._ws.close(1000, 'client stop');
|
||||
@@ -114,7 +156,10 @@ class SensingService {
|
||||
* Current data source label.
|
||||
* "live" — frames are arriving from the real ESP32 over WebSocket
|
||||
* "reconnecting" — WebSocket disconnected; actively retrying, no frames emitted
|
||||
* "simulated" — max reconnect attempts exhausted; emitting synthetic frames
|
||||
* "unreachable" — retries exhausted, client simulation off; NO frames emitted
|
||||
* and whatever is on screen is the last live reading, stale
|
||||
* "simulated" — client simulation explicitly enabled and running; every
|
||||
* frame on screen is invented
|
||||
*/
|
||||
get dataSource() {
|
||||
return this._dataSource;
|
||||
@@ -129,6 +174,22 @@ class SensingService {
|
||||
// reconnects would fail on the second attempt.
|
||||
async _connect() {
|
||||
if (this._ws && this._ws.readyState <= WebSocket.OPEN) return;
|
||||
// The readyState guard above cannot stand alone: this function awaits
|
||||
// ticket minting before it assigns `this._ws`, so two callers entering
|
||||
// during that window BOTH pass the guard and BOTH open a socket. The
|
||||
// second assignment orphans the first — a socket nobody references, still
|
||||
// open on the server, whose `onclose` fires later and calls
|
||||
// `_scheduleReconnect()` even though a healthy socket is live. That walks
|
||||
// `_reconnectAttempt` upward with no outage and eventually trips the
|
||||
// simulation fallback. Observed on the server as connect/disconnect churn
|
||||
// on every page load.
|
||||
if (this._connectInFlight) return;
|
||||
this._connectInFlight = true;
|
||||
|
||||
// Identifies this attempt for the lifetime of its socket. `stop()` and any
|
||||
// superseding attempt bump it, so a late event from an abandoned socket is
|
||||
// recognised and ignored rather than driving reconnect state.
|
||||
const epoch = ++this._connectEpoch;
|
||||
|
||||
this._setState('connecting');
|
||||
|
||||
@@ -140,15 +201,34 @@ class SensingService {
|
||||
// predating ADR-272, connecting without a ticket is correct.
|
||||
}
|
||||
|
||||
if (epoch !== this._connectEpoch) {
|
||||
// Superseded or stopped while minting. The ticket is single-use and now
|
||||
// spent; the current attempt mints its own.
|
||||
this._connectInFlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let ws;
|
||||
try {
|
||||
this._ws = new WebSocket(url);
|
||||
ws = new WebSocket(url);
|
||||
} catch (err) {
|
||||
console.warn('[Sensing] WebSocket constructor failed:', err.message);
|
||||
this._connectInFlight = false;
|
||||
this._fallbackToSimulation();
|
||||
return;
|
||||
}
|
||||
|
||||
this._ws.onopen = () => {
|
||||
this._ws = ws;
|
||||
this._connectInFlight = false;
|
||||
|
||||
/** True while `ws` is still the socket this service is driving. */
|
||||
const isCurrent = () => epoch === this._connectEpoch && this._ws === ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!isCurrent()) {
|
||||
ws.close(1000, 'superseded');
|
||||
return;
|
||||
}
|
||||
console.info('[Sensing] Connected to', SENSING_WS_URL);
|
||||
this._reconnectAttempt = 0;
|
||||
this._stopSimulation();
|
||||
@@ -158,7 +238,8 @@ class SensingService {
|
||||
this._detectServerSource();
|
||||
};
|
||||
|
||||
this._ws.onmessage = (evt) => {
|
||||
ws.onmessage = (evt) => {
|
||||
if (!isCurrent()) return;
|
||||
try {
|
||||
const data = JSON.parse(evt.data);
|
||||
this._handleData(data);
|
||||
@@ -167,11 +248,17 @@ class SensingService {
|
||||
}
|
||||
};
|
||||
|
||||
this._ws.onerror = () => {
|
||||
ws.onerror = () => {
|
||||
// onerror is always followed by onclose, so we handle reconnect there
|
||||
};
|
||||
|
||||
this._ws.onclose = (evt) => {
|
||||
ws.onclose = (evt) => {
|
||||
if (!isCurrent()) {
|
||||
// An abandoned socket closing is not an outage. Reconnect state
|
||||
// belongs to whichever attempt is current.
|
||||
console.debug('[Sensing] Ignoring close from superseded socket');
|
||||
return;
|
||||
}
|
||||
console.info('[Sensing] Connection closed (code=%d)', evt.code);
|
||||
this._ws = null;
|
||||
if (evt.code !== 1000) {
|
||||
@@ -184,18 +271,31 @@ class SensingService {
|
||||
}
|
||||
|
||||
_scheduleReconnect() {
|
||||
// A retry is already pending; stacking timers would multiply the attempt
|
||||
// count for a single outage.
|
||||
if (this._reconnectTimer) return;
|
||||
|
||||
if (this._reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
|
||||
console.warn('[Sensing] Max reconnect attempts (%d) reached, switching to simulation', MAX_RECONNECT_ATTEMPTS);
|
||||
this._fallbackToSimulation();
|
||||
return;
|
||||
if (clientSimulationAllowed()) {
|
||||
console.warn('[Sensing] Max reconnect attempts (%d) reached, switching to simulation', MAX_RECONNECT_ATTEMPTS);
|
||||
this._fallbackToSimulation();
|
||||
return;
|
||||
}
|
||||
// With simulation off there is nothing to give up *for*: keep retrying at
|
||||
// the ceiling delay so the UI recovers by itself when the server returns,
|
||||
// rather than requiring a hard refresh.
|
||||
this._setDataSource('unreachable');
|
||||
}
|
||||
|
||||
const delay = RECONNECT_DELAYS[Math.min(this._reconnectAttempt, RECONNECT_DELAYS.length - 1)];
|
||||
this._reconnectAttempt++;
|
||||
// Hold at the cap rather than counting into the thousands over a long
|
||||
// outage; the delay is already saturated and the number is only read by a
|
||||
// human in the console.
|
||||
this._reconnectAttempt = Math.min(this._reconnectAttempt + 1, MAX_RECONNECT_ATTEMPTS);
|
||||
console.info('[Sensing] Reconnecting in %dms (attempt %d/%d)', delay, this._reconnectAttempt, MAX_RECONNECT_ATTEMPTS);
|
||||
|
||||
this._setState('reconnecting');
|
||||
this._setDataSource('reconnecting');
|
||||
if (this._dataSource !== 'unreachable') this._setDataSource('reconnecting');
|
||||
|
||||
this._reconnectTimer = setTimeout(() => {
|
||||
this._reconnectTimer = null;
|
||||
@@ -205,13 +305,23 @@ class SensingService {
|
||||
// Only start simulation after several failed attempts so a brief hiccup
|
||||
// does not immediately switch the UI to "SIMULATED DATA".
|
||||
if (this._reconnectAttempt >= SIM_FALLBACK_AFTER_ATTEMPTS && this._state !== 'simulated') {
|
||||
this._fallbackToSimulation();
|
||||
if (clientSimulationAllowed()) {
|
||||
this._fallbackToSimulation();
|
||||
} else {
|
||||
// Say "the server is gone" instead of inventing a room.
|
||||
this._setDataSource('unreachable');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Simulation fallback -----------------------------------------------
|
||||
|
||||
_fallbackToSimulation() {
|
||||
if (!clientSimulationAllowed()) {
|
||||
this._setDataSource('unreachable');
|
||||
this._scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
this._setState('simulated');
|
||||
this._setDataSource('simulated');
|
||||
if (this._simTimer) return; // already running
|
||||
|
||||
@@ -1804,7 +1804,11 @@ canvas {
|
||||
flex-direction: column;
|
||||
gap: var(--space-12);
|
||||
overflow-y: auto;
|
||||
/* Without this, `overflow-y: auto` alone still resolves the x axis to auto,
|
||||
so anything marginally too wide scrolls sideways instead of wrapping. */
|
||||
overflow-x: hidden;
|
||||
max-height: 600px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sensing-card {
|
||||
@@ -1812,6 +1816,10 @@ canvas {
|
||||
border: 1px solid var(--color-card-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-12);
|
||||
/* Flex items default to min-width:auto, which refuses to shrink below their
|
||||
content — one wide child then widens the whole 320px column and puts a
|
||||
horizontal scrollbar on the panel. Cards must shrink to the column. */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sensing-card-title {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// Global data-source banner.
|
||||
//
|
||||
// The per-tab banner in SensingTab was correct and still invisible where it
|
||||
// mattered: a reader on Dashboard or Live Demo never saw it, so a screen full
|
||||
// of client-invented sensing frames was indistinguishable from measurement.
|
||||
// This bar is fixed to the viewport and present on every tab, so the provenance
|
||||
// of what is on screen is never more than a glance away.
|
||||
//
|
||||
// It shows nothing at all while data is live. An always-on badge becomes
|
||||
// wallpaper; appearing only when the reading is not trustworthy keeps it
|
||||
// meaningful.
|
||||
|
||||
import { sensingService } from '../services/sensing.service.js';
|
||||
|
||||
const STYLE_ID = 'ruview-data-source-banner-style';
|
||||
const BAR_ID = 'ruview-data-source-banner';
|
||||
|
||||
const CONFIG = {
|
||||
// 'live' is intentionally absent: no banner is shown for trustworthy data.
|
||||
'server-simulated': {
|
||||
text: 'SYNTHETIC DATA — the server is generating this, no hardware is being read',
|
||||
bg: '#7c3a00',
|
||||
fg: '#ffd9a0',
|
||||
},
|
||||
reconnecting: {
|
||||
text: 'RECONNECTING — the display is frozen at the last live reading',
|
||||
bg: '#33302a',
|
||||
fg: '#e8dcc0',
|
||||
},
|
||||
unreachable: {
|
||||
text: 'SERVER UNREACHABLE — nothing on this screen is current',
|
||||
bg: '#5c1010',
|
||||
fg: '#ffc9c9',
|
||||
},
|
||||
simulated: {
|
||||
text: 'SIMULATED — every value on this screen is invented by your browser, not measured',
|
||||
bg: '#6b0f6b',
|
||||
fg: '#ffc9ff',
|
||||
},
|
||||
};
|
||||
|
||||
const CSS = `
|
||||
#${BAR_ID} {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100000;
|
||||
display: none;
|
||||
padding: 7px 14px;
|
||||
font: 600 13px/1.35 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.45);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
#${BAR_ID}.is-visible { display: block; }
|
||||
/* Simulated data is the dangerous case — it looks real. Make the bar move so
|
||||
it cannot be mistaken for a static decoration. */
|
||||
#${BAR_ID}.is-simulated { animation: ruview-dsb-pulse 1.6s ease-in-out infinite; }
|
||||
@keyframes ruview-dsb-pulse { 50% { filter: brightness(1.45); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#${BAR_ID}.is-simulated { animation: none; }
|
||||
}
|
||||
body.ruview-has-data-source-banner { padding-top: 30px; }
|
||||
`;
|
||||
|
||||
export class DataSourceBanner {
|
||||
constructor() {
|
||||
this._bar = null;
|
||||
this._unsubscribe = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
if (!document.getElementById(STYLE_ID)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = CSS;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
this._bar = document.getElementById(BAR_ID);
|
||||
if (!this._bar) {
|
||||
this._bar = document.createElement('div');
|
||||
this._bar.id = BAR_ID;
|
||||
this._bar.setAttribute('role', 'status');
|
||||
// Announce changes, but don't interrupt: the reader is mid-task and the
|
||||
// message describes context, not an error to act on right now.
|
||||
this._bar.setAttribute('aria-live', 'polite');
|
||||
document.body.appendChild(this._bar);
|
||||
}
|
||||
|
||||
// dataSource changes are published on the state-listener channel.
|
||||
this._unsubscribe = sensingService.onStateChange(() => this.render());
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._bar) return;
|
||||
const cfg = CONFIG[sensingService.dataSource];
|
||||
|
||||
if (!cfg) {
|
||||
this._bar.classList.remove('is-visible', 'is-simulated');
|
||||
document.body.classList.remove('ruview-has-data-source-banner');
|
||||
return;
|
||||
}
|
||||
|
||||
this._bar.textContent = cfg.text;
|
||||
this._bar.style.background = cfg.bg;
|
||||
this._bar.style.color = cfg.fg;
|
||||
this._bar.classList.add('is-visible');
|
||||
this._bar.classList.toggle('is-simulated', sensingService.dataSource === 'simulated');
|
||||
document.body.classList.add('ruview-has-data-source-banner');
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this._unsubscribe) {
|
||||
this._unsubscribe();
|
||||
this._unsubscribe = null;
|
||||
}
|
||||
if (this._bar && this._bar.parentNode) {
|
||||
this._bar.parentNode.removeChild(this._bar);
|
||||
}
|
||||
this._bar = null;
|
||||
document.body.classList.remove('ruview-has-data-source-banner');
|
||||
}
|
||||
}
|
||||
|
||||
export const dataSourceBanner = new DataSourceBanner();
|
||||
Generated
+15
-15
@@ -1553,7 +1553,7 @@ version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -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.59.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.59.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]]
|
||||
@@ -8051,7 +8051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.10.5",
|
||||
"itertools 0.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
@@ -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.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9104,7 +9104,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9162,7 +9162,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11116,7 +11116,7 @@ dependencies = [
|
||||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -13624,7 +13624,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -123,6 +123,8 @@ members = [
|
||||
# ADR-324 — clean-room Kooima off-axis (head-coupled perspective) projection.
|
||||
# Dependency-free native core; wasm-bindgen surface only on wasm32.
|
||||
"crates/ruview-offaxis",
|
||||
# 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`.
|
||||
@@ -135,6 +137,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]
|
||||
@@ -220,6 +228,7 @@ walkdir = "2.4"
|
||||
|
||||
# Hashing (for proof)
|
||||
sha2 = "0.10"
|
||||
ed25519-dalek = "2.1"
|
||||
|
||||
# CSV logging
|
||||
csv = "1.3"
|
||||
@@ -278,6 +287,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
|
||||
|
||||
Submodule
+1
Submodule v2/crates/ruforecast added at a802067f04.
+577
@@ -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"
|
||||
@@ -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" }
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"learning_rate": 0.0009293, "weight_decay": 0.01, "gradient_clip_norm": 0.1, "batch_size": 1, "epochs": 1}
|
||||
@@ -0,0 +1 @@
|
||||
{"learning_rate": 0.001, "weight_decay": 0.0001, "gradient_clip_norm": 1.0, "batch_size": 8, "epochs": 60}
|
||||
@@ -0,0 +1 @@
|
||||
{"learning_rate": 0.00002977567315122687, "weight_decay": 4.0937479376727585e-11, "gradient_clip_norm": 4.745827605560193, "batch_size": 26, "epochs": 356}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -491,6 +491,57 @@ fn classification_from_room(room: &RoomInference) -> ClassificationInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimum time a candidate room-level classification must be observed
|
||||
/// *consistently* before the debounced room state actually flips to it.
|
||||
///
|
||||
/// `fuse_room` recomputes its plurality vote fresh every cycle with no
|
||||
/// memory of its own — unlike each node's own classification, which is
|
||||
/// individually debounced (`smooth_and_classify_node`). Confirmed live
|
||||
/// (2026-08-28) as a real source of "flapping": with per-node confidences
|
||||
/// sitting near the classification boundary (44%/83%/53% observed), whichever
|
||||
/// 2 of 3 nodes happen to agree can flip cycle-to-cycle even while each
|
||||
/// node's own reading is individually stable. This adds the missing
|
||||
/// room-level hysteresis, same idea as the per-node debounce, one layer up.
|
||||
const ROOM_DEBOUNCE_DURATION_SECS: f64 = 1.5;
|
||||
|
||||
/// Debounced counterpart to [`classification_from_room`] — apply this at
|
||||
/// live call sites instead, so the room's `presence`/`motion_level` only
|
||||
/// flips after `raw`'s new candidate class has been observed continuously
|
||||
/// for [`ROOM_DEBOUNCE_DURATION_SECS`], not on the very first cycle it wins
|
||||
/// `fuse_room`'s plurality vote. `confidence` is passed through un-debounced
|
||||
/// (it's a continuous value, not a discrete state, so there's nothing to
|
||||
/// flap) — only `motion_level`/`presence` are held back.
|
||||
fn debounce_room_classification(state: &mut AppStateInner, raw: &RoomInference) -> ClassificationInfo {
|
||||
let candidate = if raw.classification == "unavailable" {
|
||||
"absent"
|
||||
} else {
|
||||
raw.classification.as_str()
|
||||
};
|
||||
let now = std::time::Instant::now();
|
||||
|
||||
if candidate == state.room_debounced_level {
|
||||
// Already stable in this state — reset any in-flight candidate.
|
||||
state.room_debounce_candidate = candidate.to_string();
|
||||
state.room_debounce_since = None;
|
||||
} else if candidate == state.room_debounce_candidate {
|
||||
let since = *state.room_debounce_since.get_or_insert(now);
|
||||
if now.duration_since(since).as_secs_f64() >= ROOM_DEBOUNCE_DURATION_SECS {
|
||||
state.room_debounced_level = candidate.to_string();
|
||||
state.room_debounce_since = None;
|
||||
}
|
||||
} else {
|
||||
// New candidate — restart the hold timer.
|
||||
state.room_debounce_candidate = candidate.to_string();
|
||||
state.room_debounce_since = Some(now);
|
||||
}
|
||||
|
||||
ClassificationInfo {
|
||||
motion_level: state.room_debounced_level.clone(),
|
||||
presence: state.room_debounced_level != "absent",
|
||||
confidence: raw.confidence,
|
||||
}
|
||||
}
|
||||
|
||||
/// ADR-297 — the window a node may be silent before it stops contributing to
|
||||
/// the fused room aggregate (its entities go stale/unavailable rather than
|
||||
/// holding a frozen online value). Mirrors the 10 s active-node filter used to
|
||||
@@ -612,6 +663,98 @@ mod issue_1554_room_classification_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod debounce_room_classification_tests {
|
||||
//! `fuse_room`'s plurality vote has no memory of its own — confirmed live
|
||||
//! (2026-08-28) as a real "flapping" source when per-node confidences sit
|
||||
//! near the classification boundary: whichever 2 of 3 nodes happen to
|
||||
//! agree on a given cycle can flip the room result even while each
|
||||
//! node's own reading is individually stable. `debounce_room_classification`
|
||||
//! adds the missing room-level hysteresis (same idea as each node's own
|
||||
//! debounce, one layer up).
|
||||
use super::{debounce_room_classification, AppStateInner, RoomInference};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn room(classification: &str, confidence: f64) -> RoomInference {
|
||||
RoomInference {
|
||||
classification: classification.to_string(),
|
||||
confidence,
|
||||
contributing_nodes: 3,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_cycle_candidate_does_not_flip_immediately() {
|
||||
let mut state = AppStateInner::minimal();
|
||||
let c = debounce_room_classification(&mut state, &room("present_still", 0.6));
|
||||
assert_eq!(c.motion_level, "absent", "one cycle must not be enough to flip");
|
||||
assert!(!c.presence);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_but_recent_candidate_still_does_not_flip() {
|
||||
let mut state = AppStateInner::minimal();
|
||||
debounce_room_classification(&mut state, &room("present_still", 0.6));
|
||||
// Second cycle, essentially no time elapsed — still under the hold duration.
|
||||
let c = debounce_room_classification(&mut state, &room("present_still", 0.6));
|
||||
assert_eq!(c.motion_level, "absent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_sustained_past_hold_duration_flips() {
|
||||
let mut state = AppStateInner::minimal();
|
||||
// Simulate "present_still" having already been the candidate for 2s
|
||||
// (> ROOM_DEBOUNCE_DURATION_SECS = 1.5s) without needing to sleep in
|
||||
// the test.
|
||||
state.room_debounce_candidate = "present_still".to_string();
|
||||
state.room_debounce_since = Some(Instant::now() - Duration::from_secs(2));
|
||||
|
||||
let c = debounce_room_classification(&mut state, &room("present_still", 0.7));
|
||||
assert_eq!(c.motion_level, "present_still");
|
||||
assert!(c.presence);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flapping_candidate_resets_the_hold_timer() {
|
||||
let mut state = AppStateInner::minimal();
|
||||
// "present_still" has been pending 1s (not yet enough to flip).
|
||||
state.room_debounce_candidate = "present_still".to_string();
|
||||
state.room_debounce_since = Some(Instant::now() - Duration::from_secs(1));
|
||||
|
||||
// A different candidate arrives — this is exactly the flapping
|
||||
// pattern (2-of-3 node agreement flipping cycle to cycle). The hold
|
||||
// timer must restart for the new candidate, not carry over.
|
||||
let c = debounce_room_classification(&mut state, &room("active", 0.5));
|
||||
assert_eq!(c.motion_level, "absent", "still debounced to the old stable state");
|
||||
assert_eq!(state.room_debounce_candidate, "active");
|
||||
assert!(
|
||||
state.room_debounce_since.unwrap().elapsed() < Duration::from_millis(100),
|
||||
"hold timer must have restarted for the new candidate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_stable_state_reports_immediately_without_waiting() {
|
||||
let mut state = AppStateInner::minimal();
|
||||
state.room_debounced_level = "present_moving".to_string();
|
||||
let c = debounce_room_classification(&mut state, &room("present_moving", 0.9));
|
||||
assert_eq!(c.motion_level, "present_moving", "no need to re-debounce an already-stable state");
|
||||
assert!(c.presence);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_room_debounces_toward_absent_like_any_other_candidate() {
|
||||
let mut state = AppStateInner::minimal();
|
||||
state.room_debounced_level = "present_still".to_string();
|
||||
state.room_debounce_candidate = "absent".to_string();
|
||||
state.room_debounce_since = Some(Instant::now() - Duration::from_secs(2));
|
||||
|
||||
let c = debounce_room_classification(&mut state, &RoomInference::unavailable());
|
||||
assert_eq!(c.motion_level, "absent");
|
||||
assert!(!c.presence);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct SignalField {
|
||||
grid_size: [usize; 3],
|
||||
@@ -1410,6 +1553,15 @@ struct AppStateInner {
|
||||
/// Per-node sensing state for multi-node deployments.
|
||||
/// Keyed by `node_id` from the ESP32 frame header.
|
||||
node_states: HashMap<u8, NodeState>,
|
||||
/// Debounced room-level classification state — see
|
||||
/// `debounce_room_classification`. `fuse_room` is a fresh, memoryless
|
||||
/// plurality vote every cycle with no debounce of its own (unlike each
|
||||
/// node's own classification, which is debounced individually), so with
|
||||
/// per-node confidences sitting near a boundary the room-level result
|
||||
/// can flip cycle-to-cycle even though each node's own reading is stable.
|
||||
room_debounced_level: String,
|
||||
room_debounce_candidate: String,
|
||||
room_debounce_since: Option<std::time::Instant>,
|
||||
// ── Accuracy sprint: Kalman tracker, multistatic fusion, eigenvalue counting ──
|
||||
/// Global Kalman-based pose tracker for stable person IDs and smoothed keypoints.
|
||||
pose_tracker: PoseTracker,
|
||||
@@ -1417,6 +1569,13 @@ struct AppStateInner {
|
||||
last_tracker_instant: Option<std::time::Instant>,
|
||||
/// Attention-weighted multi-node CSI fusion engine.
|
||||
multistatic_fuser: MultistaticFuser,
|
||||
/// Node positions parsed from --node-positions, in the same order given
|
||||
/// to `multistatic_fuser.set_node_positions`. Real fleets use
|
||||
/// non-sequential logical node IDs, so this is NOT keyed by node_id —
|
||||
/// entry `i` applies to the i-th smallest currently-active node_id, the
|
||||
/// same convention `MultistaticFuser::fuse` uses. See
|
||||
/// `node_positions_by_active_id`.
|
||||
node_positions_config: Vec<[f32; 3]>,
|
||||
/// Governed trust-path bridge (ADR-135..146): runs the same live frames
|
||||
/// through the privacy/provenance/witness control plane. Does not alter
|
||||
/// person-count behavior; its trust state (witness, effective class,
|
||||
@@ -1675,9 +1834,13 @@ impl AppStateInner {
|
||||
training_progress_tx: broadcast::channel::<String>(256).0,
|
||||
adaptive_model: None,
|
||||
node_states: HashMap::new(),
|
||||
room_debounced_level: "absent".to_string(),
|
||||
room_debounce_candidate: "absent".to_string(),
|
||||
room_debounce_since: None,
|
||||
pose_tracker: PoseTracker::new(),
|
||||
last_tracker_instant: None,
|
||||
multistatic_fuser: MultistaticFuser::new(),
|
||||
node_positions_config: Vec::new(),
|
||||
engine_bridge: engine_bridge::EngineBridge::new(
|
||||
wifi_densepose_bfld::PrivacyMode::PrivateHome,
|
||||
1,
|
||||
@@ -2639,14 +2802,44 @@ fn raw_classify(score: f64) -> String {
|
||||
}
|
||||
|
||||
/// Debounce frames required before state transition (at ~10 FPS = ~0.4s).
|
||||
/// Used only by [`smooth_and_classify`] (simulated / WiFi-scan sources,
|
||||
/// which run on a fixed tick cadence close to this design point).
|
||||
const DEBOUNCE_FRAMES: u32 = 4;
|
||||
/// EMA alpha for motion smoothing (~1s time constant at 10 FPS).
|
||||
/// Used only by [`smooth_and_classify`]; see above.
|
||||
const MOTION_EMA_ALPHA: f64 = 0.15;
|
||||
/// EMA alpha for slow-adapting baseline (~30s time constant at 10 FPS).
|
||||
/// Used only by [`smooth_and_classify`]; see above.
|
||||
const BASELINE_EMA_ALPHA: f64 = 0.003;
|
||||
/// Number of warm-up frames before baseline subtraction kicks in.
|
||||
/// Used only by [`smooth_and_classify`]; see above.
|
||||
const BASELINE_WARMUP: u64 = 50;
|
||||
|
||||
/// Debounce duration required before [`smooth_and_classify_node`] accepts a
|
||||
/// state transition. Equivalent to the original `DEBOUNCE_FRAMES = 4` at the
|
||||
/// assumed 10 FPS design point.
|
||||
const NODE_DEBOUNCE_DURATION_SECS: f64 = 0.4;
|
||||
/// Time constant for [`smooth_and_classify_node`]'s motion-score EMA,
|
||||
/// derived from `MOTION_EMA_ALPHA` at the assumed 10 FPS design point
|
||||
/// (τ = -T / ln(1-α)) so behavior is unchanged for a node running at
|
||||
/// exactly 10 FPS.
|
||||
///
|
||||
/// Frame-count/fixed-alpha smoothing silently scales with actual CSI
|
||||
/// arrival rate. ESP32-C6 boards observed in the field run CSI at
|
||||
/// ~48-50 FPS, not the assumed 10 FPS — ~5x faster — which made the
|
||||
/// debounce trigger in ~0.08s instead of ~0.4s and compressed the "1s"
|
||||
/// motion EMA to ~0.2s of real smoothing, letting raw per-frame noise
|
||||
/// flip the reported label almost directly (the reported UI "seizure").
|
||||
/// Deriving a per-frame alpha from the node's actual measured
|
||||
/// `csi_fps_ema` makes the smoothing strength invariant to arrival rate.
|
||||
const NODE_MOTION_TIME_CONSTANT_SECS: f64 = 0.6154;
|
||||
/// Time constant for [`smooth_and_classify_node`]'s baseline EMA, same
|
||||
/// derivation as `NODE_MOTION_TIME_CONSTANT_SECS` from `BASELINE_EMA_ALPHA`.
|
||||
const NODE_BASELINE_TIME_CONSTANT_SECS: f64 = 33.28;
|
||||
/// Baseline warm-up duration before baseline subtraction kicks in for
|
||||
/// [`smooth_and_classify_node`] (originally 50 frames at 10 FPS = 5s).
|
||||
const NODE_BASELINE_WARMUP_SECS: f64 = 5.0;
|
||||
|
||||
/// Apply EMA smoothing, adaptive baseline subtraction, and hysteresis debounce
|
||||
/// to the raw classification. Mutates the smoothing state in `AppStateInner`.
|
||||
fn smooth_and_classify(state: &mut AppStateInner, raw: &mut ClassificationInfo, raw_motion: f64) {
|
||||
@@ -2700,18 +2893,27 @@ fn smooth_and_classify(state: &mut AppStateInner, raw: &mut ClassificationInfo,
|
||||
/// Per-node variant of `smooth_and_classify` that operates on a `NodeState`
|
||||
/// instead of `AppStateInner` (issue #249).
|
||||
fn smooth_and_classify_node(ns: &mut NodeState, raw: &mut ClassificationInfo, raw_motion: f64) {
|
||||
// Derive this frame's effective time step from the node's actual
|
||||
// measured CSI rate (already EMA-tracked, burst-filtered elsewhere —
|
||||
// see `observe_csi_frame_arrival`) rather than assuming 10 FPS.
|
||||
let fps = ns.csi_fps_ema.max(1.0);
|
||||
let dt = 1.0 / fps;
|
||||
let motion_alpha = 1.0 - (-dt / NODE_MOTION_TIME_CONSTANT_SECS).exp();
|
||||
let baseline_alpha = 1.0 - (-dt / NODE_BASELINE_TIME_CONSTANT_SECS).exp();
|
||||
let debounce_frames_needed = ((NODE_DEBOUNCE_DURATION_SECS * fps).round() as u32).max(1);
|
||||
let warmup_frames_needed = ((NODE_BASELINE_WARMUP_SECS * fps).round() as u64).max(1);
|
||||
|
||||
ns.baseline_frames += 1;
|
||||
if ns.baseline_frames < BASELINE_WARMUP {
|
||||
if ns.baseline_frames < warmup_frames_needed {
|
||||
ns.baseline_motion = ns.baseline_motion * 0.9 + raw_motion * 0.1;
|
||||
} else if raw_motion < ns.smoothed_motion + 0.05 {
|
||||
ns.baseline_motion =
|
||||
ns.baseline_motion * (1.0 - BASELINE_EMA_ALPHA) + raw_motion * BASELINE_EMA_ALPHA;
|
||||
ns.baseline_motion * (1.0 - baseline_alpha) + raw_motion * baseline_alpha;
|
||||
}
|
||||
|
||||
let adjusted = (raw_motion - ns.baseline_motion * 0.7).max(0.0);
|
||||
|
||||
ns.smoothed_motion =
|
||||
ns.smoothed_motion * (1.0 - MOTION_EMA_ALPHA) + adjusted * MOTION_EMA_ALPHA;
|
||||
ns.smoothed_motion = ns.smoothed_motion * (1.0 - motion_alpha) + adjusted * motion_alpha;
|
||||
let sm = ns.smoothed_motion;
|
||||
|
||||
let candidate = raw_classify(sm);
|
||||
@@ -2721,7 +2923,7 @@ fn smooth_and_classify_node(ns: &mut NodeState, raw: &mut ClassificationInfo, ra
|
||||
ns.debounce_candidate = candidate;
|
||||
} else if candidate == ns.debounce_candidate {
|
||||
ns.debounce_counter += 1;
|
||||
if ns.debounce_counter >= DEBOUNCE_FRAMES {
|
||||
if ns.debounce_counter >= debounce_frames_needed {
|
||||
ns.current_motion_level = candidate;
|
||||
ns.debounce_counter = 0;
|
||||
}
|
||||
@@ -5822,13 +6024,31 @@ async fn calibration_stop(State(state): State<SharedState>) -> Json<serde_json::
|
||||
// caller knows to keep the room empty and poll /calibration/status.
|
||||
let have = fm.calibration_frame_count();
|
||||
let need = fm.min_calibration_frames() as u64;
|
||||
if have < need {
|
||||
return Json(serde_json::json!({
|
||||
let elapsed_s = fm.calibration_elapsed_s();
|
||||
let need_s = fm.min_calibration_duration_s();
|
||||
if have < need || (need_s > 0.0 && elapsed_s < need_s) {
|
||||
let mut resp = serde_json::json!({
|
||||
"success": false,
|
||||
"error": "Not enough calibration frames yet — keep the room empty and poll /calibration/status until frame_count reaches the target.",
|
||||
"error": "Calibration not complete yet — keep the room empty and poll /calibration/status until both frame_count and elapsed_s reach their targets.",
|
||||
"frame_count": have,
|
||||
"frames_needed": need,
|
||||
}));
|
||||
"elapsed_s": elapsed_s,
|
||||
"duration_needed_s": need_s,
|
||||
"frames_per_second": fm.calibration_frames_per_second(),
|
||||
});
|
||||
// #1756: a fast fleet satisfies the frame gate in ~1/N of the
|
||||
// intended wall-clock window. Make that explicit instead of
|
||||
// silently collecting, so the operator sees why the session
|
||||
// keeps running.
|
||||
if have >= need && elapsed_s < need_s {
|
||||
tracing::warn!(
|
||||
"Calibration frame target reached in {elapsed_s:.1}s — substantially less than the intended {need_s:.0}s window; continuing to collect for slow environmental variation (#1756)"
|
||||
);
|
||||
resp["warning"] = serde_json::json!(format!(
|
||||
"frame target reached in {elapsed_s:.1}s — substantially less than the intended {need_s:.0}s window; continuing to collect for slow environmental variation"
|
||||
));
|
||||
}
|
||||
return Json(resp);
|
||||
}
|
||||
let ts = chrono::Utc::now().timestamp_micros() as u64;
|
||||
match fm.finalize_calibration(ts, 0) {
|
||||
@@ -5841,6 +6061,7 @@ async fn calibration_stop(State(state): State<SharedState>) -> Json<serde_json::
|
||||
"baseline_eigenvalue_count": baseline,
|
||||
"variance_explained": variance_explained,
|
||||
"frame_count": fm.calibration_frame_count(),
|
||||
"elapsed_s": elapsed_s,
|
||||
}))
|
||||
}
|
||||
// ADR-080 #2: finalize error chain stays server-side only.
|
||||
@@ -5857,10 +6078,17 @@ async fn calibration_stop(State(state): State<SharedState>) -> Json<serde_json::
|
||||
async fn calibration_status(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
let s = state.read().await;
|
||||
match s.field_model.as_ref() {
|
||||
// #1756: expose elapsed wall-clock time and the effective aggregate
|
||||
// sample rate alongside the frame count, so a client can see the
|
||||
// shape of the capture instead of frames alone.
|
||||
Some(fm) => Json(serde_json::json!({
|
||||
"active": true,
|
||||
"status": format!("{:?}", fm.status()),
|
||||
"frame_count": fm.calibration_frame_count(),
|
||||
"min_frames": fm.min_calibration_frames(),
|
||||
"elapsed_s": fm.calibration_elapsed_s(),
|
||||
"frames_per_second": fm.calibration_frames_per_second(),
|
||||
"min_duration_s": fm.min_calibration_duration_s(),
|
||||
})),
|
||||
None => Json(serde_json::json!({
|
||||
"active": false,
|
||||
@@ -6267,6 +6495,99 @@ async fn info_page() -> Html<String> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolve `--node-positions` entries to the currently active node IDs.
|
||||
///
|
||||
/// `MultistaticFuser::fuse` assigns `node_positions[i]` to the i-th node in
|
||||
/// its `node_frames` argument, which callers build sorted ascending by
|
||||
/// node_id (see `node_frames_from_states_with_guard`). Real fleets use
|
||||
/// non-sequential logical node IDs (e.g. 11, 12, 13), so a naive
|
||||
/// `node_id -> node_positions_config[node_id]` lookup silently misses for
|
||||
/// every real deployment. This mirrors the same ascending-rank convention so
|
||||
/// the live `NodeInfo.position` field agrees with what fusion actually used.
|
||||
fn node_positions_by_active_id(
|
||||
node_positions_config: &[[f32; 3]],
|
||||
node_states: &HashMap<u8, NodeState>,
|
||||
now: std::time::Instant,
|
||||
) -> HashMap<u8, [f64; 3]> {
|
||||
let mut active_ids: Vec<u8> = node_states
|
||||
.iter()
|
||||
.filter(|(_, n)| {
|
||||
n.last_frame_time
|
||||
.is_some_and(|t| now.duration_since(t).as_secs() < 10)
|
||||
})
|
||||
.map(|(&id, _)| id)
|
||||
.collect();
|
||||
active_ids.sort_unstable();
|
||||
active_ids
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(rank, id)| {
|
||||
node_positions_config
|
||||
.get(rank)
|
||||
.map(|p| (id, [p[0] as f64, p[1] as f64, p[2] as f64]))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod node_positions_by_active_id_tests {
|
||||
use super::*;
|
||||
|
||||
fn active_node(now: std::time::Instant) -> NodeState {
|
||||
let mut ns = NodeState::new();
|
||||
ns.last_frame_time = Some(now);
|
||||
ns
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_sequential_node_ids_get_positions_by_ascending_rank() {
|
||||
// Real fleets use logical IDs like 11, 12, 13 — not 0, 1, 2. The
|
||||
// configured position list must map by ascending node_id rank, not
|
||||
// by treating node_id as a direct index into the list.
|
||||
let now = std::time::Instant::now();
|
||||
let mut node_states = HashMap::new();
|
||||
node_states.insert(13, active_node(now));
|
||||
node_states.insert(11, active_node(now));
|
||||
node_states.insert(12, active_node(now));
|
||||
|
||||
let configured = [[1.0, 0.0, 0.0], [2.0, 0.0, 0.0], [3.0, 0.0, 0.0]];
|
||||
let resolved = node_positions_by_active_id(&configured, &node_states, now);
|
||||
|
||||
assert_eq!(resolved.get(&11), Some(&[1.0, 0.0, 0.0]));
|
||||
assert_eq!(resolved.get(&12), Some(&[2.0, 0.0, 0.0]));
|
||||
assert_eq!(resolved.get(&13), Some(&[3.0, 0.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_nodes_are_excluded_from_rank_assignment() {
|
||||
let now = std::time::Instant::now();
|
||||
let mut node_states = HashMap::new();
|
||||
node_states.insert(11, active_node(now));
|
||||
let mut stale = NodeState::new();
|
||||
stale.last_frame_time =
|
||||
Some(now - std::time::Duration::from_secs(30));
|
||||
node_states.insert(12, stale);
|
||||
node_states.insert(13, active_node(now));
|
||||
|
||||
let configured = [[1.0, 0.0, 0.0], [3.0, 0.0, 0.0]];
|
||||
let resolved = node_positions_by_active_id(&configured, &node_states, now);
|
||||
|
||||
assert_eq!(resolved.get(&11), Some(&[1.0, 0.0, 0.0]));
|
||||
assert_eq!(resolved.get(&12), None, "stale node must not consume a rank");
|
||||
assert_eq!(resolved.get(&13), Some(&[3.0, 0.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_config_entries_fall_through_to_the_caller_default() {
|
||||
let now = std::time::Instant::now();
|
||||
let mut node_states = HashMap::new();
|
||||
node_states.insert(11, active_node(now));
|
||||
|
||||
let resolved = node_positions_by_active_id(&[], &node_states, now);
|
||||
assert_eq!(resolved.get(&11), None);
|
||||
}
|
||||
}
|
||||
|
||||
// ── UDP receiver task ────────────────────────────────────────────────────────
|
||||
|
||||
async fn udp_receiver_task(
|
||||
@@ -6516,6 +6837,8 @@ async fn udp_receiver_task(
|
||||
}
|
||||
|
||||
// Build nodes array with all active nodes.
|
||||
let resolved_positions =
|
||||
node_positions_by_active_id(&s.node_positions_config, &s.node_states, now);
|
||||
let active_nodes: Vec<NodeInfo> = s
|
||||
.node_states
|
||||
.iter()
|
||||
@@ -6526,7 +6849,10 @@ async fn udp_receiver_task(
|
||||
.map(|(&id, n)| NodeInfo {
|
||||
node_id: id,
|
||||
rssi_dbm: n.rssi_history.back().copied().unwrap_or(0.0),
|
||||
position: [2.0, 0.0, 1.5],
|
||||
position: resolved_positions
|
||||
.get(&id)
|
||||
.copied()
|
||||
.unwrap_or([2.0, 0.0, 1.5]),
|
||||
amplitude: vec![],
|
||||
subcarrier_count: 0,
|
||||
// Vitals-only path; still expose the sync snapshot
|
||||
@@ -6568,7 +6894,7 @@ async fn udp_receiver_task(
|
||||
// node's own reading no longer overwrites the room's. The
|
||||
// old ad-hoc "boost confidence by node count" is replaced by
|
||||
// `room_inference`'s freshness-weighted multi-node confidence.
|
||||
let classification = classification_from_room(&room_inference);
|
||||
let classification = debounce_room_classification(&mut s, &room_inference);
|
||||
|
||||
let signal_field = generate_signal_field(
|
||||
fused_features.mean_rssi,
|
||||
@@ -7000,6 +7326,8 @@ async fn udp_receiver_task(
|
||||
// privacy gate applies at Restricted (drop amplitude/phase
|
||||
// proxies).
|
||||
let suppress_raw = s.engine_bridge.suppress_raw_outputs();
|
||||
let resolved_positions =
|
||||
node_positions_by_active_id(&s.node_positions_config, &s.node_states, now);
|
||||
let active_nodes: Vec<NodeInfo> = s
|
||||
.node_states
|
||||
.iter()
|
||||
@@ -7010,7 +7338,10 @@ async fn udp_receiver_task(
|
||||
.map(|(&id, n)| NodeInfo {
|
||||
node_id: id,
|
||||
rssi_dbm: n.rssi_history.back().copied().unwrap_or(0.0),
|
||||
position: [2.0, 0.0, 1.5],
|
||||
position: resolved_positions
|
||||
.get(&id)
|
||||
.copied()
|
||||
.unwrap_or([2.0, 0.0, 1.5]),
|
||||
amplitude: if suppress_raw {
|
||||
vec![]
|
||||
} else {
|
||||
@@ -7037,6 +7368,7 @@ async fn udp_receiver_task(
|
||||
active_nodes.iter().filter_map(|ni| ni.node_inference.as_ref()),
|
||||
NODE_STALE_AFTER_MS,
|
||||
);
|
||||
let room_classification = debounce_room_classification(&mut s, &room_inference);
|
||||
|
||||
let mut update = SensingUpdate {
|
||||
msg_type: "sensing_update".to_string(),
|
||||
@@ -7050,7 +7382,7 @@ async fn udp_receiver_task(
|
||||
// `classification` (this node's own smoothed reading)
|
||||
// still drives `motion_score`/`total_persons` above,
|
||||
// which are legitimately this-packet-local.
|
||||
classification: classification_from_room(&room_inference),
|
||||
classification: room_classification,
|
||||
signal_field: generate_signal_field(
|
||||
fused_features.mean_rssi,
|
||||
motion_score,
|
||||
@@ -8581,7 +8913,7 @@ async fn main() {
|
||||
// threaded into `engine_bridge` so both fusion paths honor the same
|
||||
// WDP_TDM_SLOTS/WDP_GUARD_INTERVAL_US-derived guard (#1049/#1057).
|
||||
let mut engine_bridge_multistatic_cfg: Option<MultistaticConfig> = None;
|
||||
|
||||
let mut node_positions_config: Vec<[f32; 3]> = Vec::new();
|
||||
let state: SharedState = Arc::new(RwLock::new(AppStateInner {
|
||||
latest_update: None,
|
||||
rssi_history: VecDeque::new(),
|
||||
@@ -8647,6 +8979,9 @@ async fn main() {
|
||||
);
|
||||
}),
|
||||
node_states: HashMap::new(),
|
||||
room_debounced_level: "absent".to_string(),
|
||||
room_debounce_candidate: "absent".to_string(),
|
||||
room_debounce_since: None,
|
||||
// Accuracy sprint
|
||||
pose_tracker: PoseTracker::new(),
|
||||
last_tracker_instant: None,
|
||||
@@ -8672,6 +9007,7 @@ async fn main() {
|
||||
"Configured {} node positions for multistatic fusion",
|
||||
positions.len()
|
||||
);
|
||||
node_positions_config = positions.clone();
|
||||
fuser.set_node_positions(positions);
|
||||
}
|
||||
}
|
||||
@@ -8681,6 +9017,7 @@ async fn main() {
|
||||
});
|
||||
fuser
|
||||
},
|
||||
node_positions_config,
|
||||
engine_bridge: engine_bridge::EngineBridge::new(
|
||||
wifi_densepose_bfld::PrivacyMode::PrivateHome,
|
||||
1,
|
||||
|
||||
@@ -287,6 +287,7 @@ mod eig {
|
||||
n_links,
|
||||
n_modes: 3,
|
||||
min_calibration_frames: 20,
|
||||
min_calibration_duration_s: 0.0,
|
||||
baseline_expiry_s: 86_400.0,
|
||||
};
|
||||
let mut model = FieldModel::new(config).unwrap();
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
//! (the residual).
|
||||
//!
|
||||
//! # Algorithm
|
||||
//! 1. Collect CSI during empty-room calibration (>=10 min at 20 Hz)
|
||||
//! 1. Collect CSI during empty-room calibration (>=10 min wall-clock; the
|
||||
//! frame target is this duration at the assumed 20 Hz single-node rate)
|
||||
//! 2. Compute per-link baseline mean (Welford online accumulator)
|
||||
//! 3. Decompose covariance via SVD to extract environmental modes
|
||||
//! 4. At runtime: observation - baseline, project out top-K modes, keep residual
|
||||
@@ -23,6 +24,24 @@ use ndarray_linalg::Eigh;
|
||||
#[cfg(feature = "eigenvalue")]
|
||||
use ndarray_linalg::UPLO;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Calibration window constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Intended wall-clock duration of the empty-room calibration window (s).
|
||||
///
|
||||
/// The Welford statistics exist to absorb slow environmental variation —
|
||||
/// HVAC cycles, thermal drift — so the calibration gate is expressed in
|
||||
/// wall-clock time. The historical frame target of 12,000 is this duration at
|
||||
/// the assumed single-node rate. Every accepted node packet advances the same
|
||||
/// field model, so N nodes reach any frame target N times faster; frames alone
|
||||
/// therefore cannot gate calibration (#1756).
|
||||
pub const CALIBRATION_DURATION_S: f64 = 600.0;
|
||||
|
||||
/// Assumed per-node CSI rate (Hz) used to relate the frame target to
|
||||
/// [`CALIBRATION_DURATION_S`] ("10 min at 20 Hz").
|
||||
pub const ASSUMED_CALIBRATION_RATE_HZ: f64 = 20.0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -34,6 +53,12 @@ pub enum FieldModelError {
|
||||
#[error("Insufficient calibration frames: need {needed}, got {got}")]
|
||||
InsufficientCalibration { needed: usize, got: usize },
|
||||
|
||||
/// Calibration window shorter than the intended wall-clock duration.
|
||||
/// A frame count alone cannot gate calibration: N nodes streaming in
|
||||
/// aggregate reach any frame target in 1/N of the intended window (#1756).
|
||||
#[error("Calibration window too short: need {needed_s:.1}s of wall-clock time, got {got_s:.1}s")]
|
||||
InsufficientCalibrationDuration { needed_s: f64, got_s: f64 },
|
||||
|
||||
/// Dimensionality mismatch between observation and baseline.
|
||||
#[error("Dimension mismatch: baseline has {expected} subcarriers, observation has {got}")]
|
||||
DimensionMismatch { expected: usize, got: usize },
|
||||
@@ -244,8 +269,18 @@ pub struct FieldModelConfig {
|
||||
pub n_subcarriers: usize,
|
||||
/// Number of environmental modes to retain (K). Max 5.
|
||||
pub n_modes: usize,
|
||||
/// Minimum calibration frames before baseline is valid (10 min at 20 Hz = 12000).
|
||||
/// Minimum calibration frames before baseline is valid. Derived from the
|
||||
/// intended wall-clock window at the assumed single-node rate
|
||||
/// (`CALIBRATION_DURATION_S * ASSUMED_CALIBRATION_RATE_HZ` = 12,000).
|
||||
/// A frame count alone is not sufficient: N nodes streaming in aggregate
|
||||
/// reach it in 1/N of the window, so `min_calibration_duration_s` also
|
||||
/// gates finalization (#1756).
|
||||
pub min_calibration_frames: usize,
|
||||
/// Minimum wall-clock duration of the calibration window in seconds.
|
||||
/// Slow environmental variation (HVAC cycles, thermal drift) can only be
|
||||
/// observed over time, regardless of how many frames a fast fleet
|
||||
/// delivers (#1756). 0 disables the duration gate (tests only).
|
||||
pub min_calibration_duration_s: f64,
|
||||
/// Baseline expiry in seconds (default 86400 = 24 hours).
|
||||
pub baseline_expiry_s: f64,
|
||||
}
|
||||
@@ -256,7 +291,9 @@ impl Default for FieldModelConfig {
|
||||
n_links: 6,
|
||||
n_subcarriers: 56,
|
||||
n_modes: 3,
|
||||
min_calibration_frames: 12_000,
|
||||
min_calibration_frames: (CALIBRATION_DURATION_S * ASSUMED_CALIBRATION_RATE_HZ)
|
||||
as usize,
|
||||
min_calibration_duration_s: CALIBRATION_DURATION_S,
|
||||
baseline_expiry_s: 86_400.0,
|
||||
}
|
||||
}
|
||||
@@ -345,6 +382,9 @@ pub struct FieldModel {
|
||||
covariance_sum: Option<Array2<f64>>,
|
||||
/// Number of frames accumulated into covariance_sum.
|
||||
covariance_count: u64,
|
||||
/// Monotonic timestamp of the first accepted calibration frame of the
|
||||
/// current session; `None` until collection starts (#1756).
|
||||
calibration_started: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
/// Diagonal variance fallback for when full covariance SVD is unavailable.
|
||||
@@ -429,6 +469,7 @@ impl FieldModel {
|
||||
last_calibration_us: 0,
|
||||
covariance_sum: None,
|
||||
covariance_count: 0,
|
||||
calibration_started: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -454,6 +495,32 @@ impl FieldModel {
|
||||
self.config.min_calibration_frames
|
||||
}
|
||||
|
||||
/// Minimum wall-clock duration (s) of the calibration window required
|
||||
/// before `finalize_calibration` will succeed (#1756).
|
||||
pub fn min_calibration_duration_s(&self) -> f64 {
|
||||
self.config.min_calibration_duration_s
|
||||
}
|
||||
|
||||
/// Wall-clock seconds elapsed since the first accepted calibration frame
|
||||
/// of the current session, or 0.0 if collection has not started (#1756).
|
||||
pub fn calibration_elapsed_s(&self) -> f64 {
|
||||
self.calibration_started
|
||||
.map(|t| t.elapsed().as_secs_f64())
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Effective aggregate frames per second since collection started, or 0.0
|
||||
/// if collection has not started (#1756). With N nodes streaming, this is
|
||||
/// roughly N times the single-node rate.
|
||||
pub fn calibration_frames_per_second(&self) -> f64 {
|
||||
let elapsed = self.calibration_elapsed_s();
|
||||
if elapsed > 0.0 {
|
||||
self.calibration_frame_count() as f64 / elapsed
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a calibration frame (one CSI observation per link during empty room).
|
||||
///
|
||||
/// `observations` is `[n_links][n_subcarriers]` amplitude data.
|
||||
@@ -467,6 +534,11 @@ impl FieldModel {
|
||||
for (link_stat, obs) in self.link_stats.iter_mut().zip(observations.iter()) {
|
||||
link_stat.update(obs)?;
|
||||
}
|
||||
// Start the wall-clock calibration window on the first accepted frame
|
||||
// (#1756): finalize gates on elapsed duration as well as frame count.
|
||||
if self.calibration_started.is_none() {
|
||||
self.calibration_started = Some(std::time::Instant::now());
|
||||
}
|
||||
if self.status == CalibrationStatus::Uncalibrated {
|
||||
self.status = CalibrationStatus::Collecting;
|
||||
}
|
||||
@@ -501,7 +573,8 @@ impl FieldModel {
|
||||
|
||||
/// Finalize calibration: compute SVD to extract environmental modes.
|
||||
///
|
||||
/// Requires at least `min_calibration_frames` observations.
|
||||
/// Requires at least `min_calibration_frames` observations collected over
|
||||
/// at least `min_calibration_duration_s` of wall-clock time (#1756).
|
||||
/// `timestamp_us` is the current timestamp in microseconds.
|
||||
/// `geometry_hash` identifies the mesh geometry at calibration time.
|
||||
pub fn finalize_calibration(
|
||||
@@ -516,6 +589,19 @@ impl FieldModel {
|
||||
got: count as usize,
|
||||
});
|
||||
}
|
||||
// #1756: the frame gate encodes "duration at the single-node rate",
|
||||
// but every accepted node packet advances this model, so a fleet of N
|
||||
// nodes satisfies it in ~1/N of the intended window. Gate on the
|
||||
// wall-clock duration as well so the baseline covers the slow
|
||||
// environmental variation the Welford statistics are meant to absorb.
|
||||
let elapsed_s = self.calibration_elapsed_s();
|
||||
let need_s = self.config.min_calibration_duration_s;
|
||||
if need_s > 0.0 && elapsed_s < need_s {
|
||||
return Err(FieldModelError::InsufficientCalibrationDuration {
|
||||
needed_s: need_s,
|
||||
got_s: elapsed_s,
|
||||
});
|
||||
}
|
||||
|
||||
let n_sc = self.config.n_subcarriers;
|
||||
let n_modes = self.config.n_modes.min(n_sc);
|
||||
@@ -895,6 +981,7 @@ impl FieldModel {
|
||||
self.status = CalibrationStatus::Uncalibrated;
|
||||
self.covariance_sum = None;
|
||||
self.covariance_count = 0;
|
||||
self.calibration_started = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -912,6 +999,8 @@ mod tests {
|
||||
n_subcarriers: n_sc,
|
||||
n_modes: 3,
|
||||
min_calibration_frames: min_frames,
|
||||
// Tests feed frames instantly; disable the wall-clock gate (#1756).
|
||||
min_calibration_duration_s: 0.0,
|
||||
baseline_expiry_s: 86_400.0,
|
||||
}
|
||||
}
|
||||
@@ -1114,6 +1203,7 @@ mod tests {
|
||||
n_subcarriers: 8,
|
||||
n_modes: 2,
|
||||
min_calibration_frames: 5,
|
||||
min_calibration_duration_s: 0.0,
|
||||
baseline_expiry_s: 86_400.0,
|
||||
};
|
||||
let mut model = FieldModel::new(config).unwrap();
|
||||
@@ -1307,6 +1397,7 @@ mod tests {
|
||||
n_subcarriers: 8,
|
||||
n_modes: 3,
|
||||
min_calibration_frames: 20,
|
||||
min_calibration_duration_s: 0.0,
|
||||
baseline_expiry_s: 86_400.0,
|
||||
};
|
||||
let mut model = FieldModel::new(config).unwrap();
|
||||
@@ -1369,6 +1460,7 @@ mod tests {
|
||||
n_subcarriers: 8,
|
||||
n_modes: 3,
|
||||
min_calibration_frames: 20,
|
||||
min_calibration_duration_s: 0.0,
|
||||
baseline_expiry_s: 86_400.0,
|
||||
};
|
||||
let mut model = FieldModel::new(config).unwrap();
|
||||
@@ -1417,6 +1509,7 @@ mod tests {
|
||||
n_subcarriers: 8,
|
||||
n_modes: 3,
|
||||
min_calibration_frames: 20,
|
||||
min_calibration_duration_s: 0.0,
|
||||
baseline_expiry_s: 86_400.0,
|
||||
};
|
||||
let mut model = FieldModel::new(config).unwrap();
|
||||
@@ -1473,4 +1566,71 @@ mod tests {
|
||||
"Environmental projection should be non-zero for drifting subcarrier"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Wall-clock calibration gate (#1756)
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_default_frame_target_derives_from_duration() {
|
||||
let cfg = FieldModelConfig::default();
|
||||
assert_eq!(cfg.min_calibration_duration_s, CALIBRATION_DURATION_S);
|
||||
assert_eq!(
|
||||
cfg.min_calibration_frames,
|
||||
(CALIBRATION_DURATION_S * ASSUMED_CALIBRATION_RATE_HZ) as usize,
|
||||
"frame target must be the intended window at the assumed rate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_finalize_requires_wall_clock_window() {
|
||||
// Enough frames, but fed instantly: the fleet-fast scenario from #1756.
|
||||
let mut config = make_config(1, 4, 5);
|
||||
config.min_calibration_duration_s = 600.0;
|
||||
let mut model = FieldModel::new(config).unwrap();
|
||||
for _ in 0..10 {
|
||||
model
|
||||
.feed_calibration(&make_observations(1, 4, 1.0))
|
||||
.unwrap();
|
||||
}
|
||||
assert!(model.calibration_frame_count() >= 5);
|
||||
match model.finalize_calibration(1_000_000, 0) {
|
||||
Err(FieldModelError::InsufficientCalibrationDuration { needed_s, got_s }) => {
|
||||
assert!((needed_s - 600.0).abs() < 1e-9);
|
||||
assert!(got_s < 1.0, "test feeds frames instantly, got {got_s}s");
|
||||
}
|
||||
other => panic!("expected InsufficientCalibrationDuration, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duration_gate_disabled_with_zero() {
|
||||
// min_calibration_duration_s = 0 keeps the legacy frame-only gate.
|
||||
let mut model = FieldModel::new(make_config(1, 4, 5)).unwrap();
|
||||
for _ in 0..5 {
|
||||
model
|
||||
.feed_calibration(&make_observations(1, 4, 1.0))
|
||||
.unwrap();
|
||||
}
|
||||
assert!(model.finalize_calibration(1_000_000, 0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calibration_clock_accessors() {
|
||||
let mut model = FieldModel::new(make_config(1, 4, 5)).unwrap();
|
||||
// No frames yet: the session clock has not started.
|
||||
assert_eq!(model.calibration_elapsed_s(), 0.0);
|
||||
assert_eq!(model.calibration_frames_per_second(), 0.0);
|
||||
|
||||
model
|
||||
.feed_calibration(&make_observations(1, 4, 1.0))
|
||||
.unwrap();
|
||||
assert!(model.calibration_elapsed_s() >= 0.0);
|
||||
assert!(model.calibration_frames_per_second() >= 0.0);
|
||||
|
||||
// Reset clears the session clock.
|
||||
model.reset_calibration();
|
||||
assert_eq!(model.calibration_elapsed_s(), 0.0);
|
||||
assert_eq!(model.calibration_frames_per_second(), 0.0);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user