Compare commits
7 Commits
476f0bb246
...
e4c3521eb3
| Author | SHA1 | Date |
|---|---|---|
|
|
e4c3521eb3 | |
|
|
69e61e3437 | |
|
|
d9e87e13b4 | |
|
|
be48143f77 | |
|
|
c453268002 | |
|
|
6ee21a0941 | |
|
|
0cfd255730 |
|
|
@ -108,16 +108,18 @@ jobs:
|
|||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
# Swatinem/rust-cache replaces a naive `actions/cache` of the whole
|
||||
# `v2/target`. That manual cache of a 38-crate target dir (multi-GB) was an
|
||||
# intermittent failure source — several CI runs this cycle died at the
|
||||
# cache/setup step (after toolchain install, before "Run Rust tests"),
|
||||
# needing a rerun. rust-cache is purpose-built for Rust: it caches the
|
||||
# registry + git + a pruned target, evicts stale deps, and restores far more
|
||||
# reliably (and faster) on large workspaces. `workspaces: v2` points it at
|
||||
# the v2/ cargo workspace (keys on v2/Cargo.lock, caches v2/target).
|
||||
- name: Cache cargo (Swatinem/rust-cache)
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
v2/target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('v2/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
workspaces: v2
|
||||
|
||||
- name: Run Rust tests
|
||||
working-directory: v2
|
||||
|
|
|
|||
|
|
@ -46,7 +46,10 @@ jobs:
|
|||
|
||||
- name: Run Bandit security scan
|
||||
run: |
|
||||
bandit -r src/ -f sarif -o bandit-results.sarif
|
||||
# The Python codebase lives under archive/v1/src (it moved there when
|
||||
# the runtime was rewritten in Rust). Scanning `src/` matched nothing,
|
||||
# so this SAST step was a silent no-op.
|
||||
bandit -r archive/v1/src/ -f sarif -o bandit-results.sarif
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Bandit results to GitHub Security
|
||||
|
|
@ -57,22 +60,20 @@ jobs:
|
|||
sarif_file: bandit-results.sarif
|
||||
category: bandit
|
||||
|
||||
- name: Run Semgrep security scan
|
||||
continue-on-error: true
|
||||
uses: returntocorp/semgrep-action@v1
|
||||
with:
|
||||
config: >-
|
||||
p/security-audit
|
||||
p/secrets
|
||||
p/python
|
||||
p/docker
|
||||
p/kubernetes
|
||||
env:
|
||||
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
|
||||
|
||||
- name: Generate Semgrep SARIF
|
||||
# Removed the deprecated `returntocorp/semgrep-action@v1` step: it was
|
||||
# redundant (the pip `semgrep --sarif` below is what feeds GitHub Security;
|
||||
# the action only pushed to the Semgrep cloud app via SEMGREP_APP_TOKEN) and
|
||||
# it pulled `returntocorp/semgrep-agent:v1` from Docker Hub on every run,
|
||||
# which intermittently timed out and turned this check red. The pip semgrep
|
||||
# (installed above) needs no Docker pull. The action's `p/docker` +
|
||||
# `p/kubernetes` rulesets are folded into the command below so coverage is
|
||||
# preserved.
|
||||
- name: Run Semgrep + generate SARIF
|
||||
run: |
|
||||
semgrep --config=p/security-audit --config=p/secrets --config=p/python --sarif --output=semgrep.sarif src/
|
||||
semgrep \
|
||||
--config=p/security-audit --config=p/secrets --config=p/python \
|
||||
--config=p/docker --config=p/kubernetes \
|
||||
--sarif --output=semgrep.sarif archive/v1/src/
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Semgrep results to GitHub Security
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- **MQTT multi-node deployments now create one Home-Assistant device per node — closes #898.** After the #872 MQTT wiring landed, the JSON→`VitalsSnapshot` bridge hard-coded a single `node_id` (the MQTT client id) and the publisher used a single `OwnedDiscoveryBuilder`, so every physical node collapsed into one device (`identifiers:["wifi_densepose_wifi-densepose-1"]`), contradicting the "one device per node" docs. The bridge now emits one snapshot per node in the sensing update's `nodes[]` (each with its own `node_id` + RSSI, falling back to a single aggregate snapshot for wifi/simulate sources), and the publisher derives a per-node builder (`OwnedDiscoveryBuilder::for_node`) that publishes discovery + availability lazily on first sight of each `node_id` and routes state to per-node topics — yielding N distinct HA devices with per-node availability/LWT. Unit-tested (distinct nodes → distinct `wifi_densepose_<node>` identifiers); 71 MQTT tests pass.
|
||||
- **Person count no longer pinned to 1 — addresses #803.** The aggregate occupancy reported by the sensing server was derived from `smoothed_person_score`, an EMA-smoothed *activity* score (amplitude variance / motion / spectral energy). That score saturates near a single occupant — one moving person maxes it out — so it cannot discriminate occupancy *count* and stayed clamped at 1 across S3/C6 and the Python/Docker/Rust servers. Meanwhile the count-aware per-node estimates the ESP32 paths already compute (firmware `n_persons`, and the DynamicMinCut `corr_persons`) were stashed in `NodeState::prev_person_count` and then **discarded** by the aggregator (same dead-wiring class as #872). The aggregator now takes `max(activity_count, node_max)` via a unit-tested `aggregate_person_count` helper, so a node positively estimating 2–3 occupants is surfaced instead of overwritten. The fix can only ever *raise* the count when a node reports more people, so the single-occupant case is provably never inflated (regression-guarded by test). **Second half:** the pure-CSI per-node path itself clamped its own estimate — the DynamicMinCut occupancy (`estimate_persons_from_correlation`, 0–3) was mapped to a score via `corr_persons / 3.0`, putting 2 people at 0.667, *just under* the 0.70 up-threshold of `score_to_person_count`, so the per-node count never climbed past 1 (so `node_max` was also stuck at 1 for CSI-only nodes). Replaced it with a threshold-aligned `corr_persons_to_score` mapping (1→0.40, 2→0.74, 3→0.96) whose steady state round-trips back to the same count through the EMA + hysteresis, while still gating transient noise. A convergence test replays the exact EMA loop to prove min-cut=2 now reports 2 (and documents that the old `/3.0` mapping reported 1). Full multi-person accuracy still depends on the underlying estimator quality; this removes the two server-side clamps that masked it. 586 sensing-server tests pass.
|
||||
- **MQTT publisher now actually runs (`--mqtt`) — closes #872.** The `--mqtt*` flags were defined only in `cli::Args` (dead code, referenced nowhere) while the binary parses a *separate* `main::Args` with no mqtt fields, and `main.rs` never started the `mqtt::` publisher — so MQTT/Home-Assistant integration was completely unwired (`--mqtt` errored as an unexpected argument, and even with the Docker image's `--features mqtt` build the publisher never ran). Earlier attempts chased a Docker *rebuild*; the real cause was disconnected *code*. Extracted the flags into a shared `cli::MqttArgs` (`#[command(flatten)]` into both structs), spawn the publisher on `--mqtt`, and bridge the JSON sensing broadcast into the typed `VitalsSnapshot` stream with a defensive `serde_json::Value` mapping. Verified end-to-end against `mosquitto`: 20 HA auto-discovery entities + live state (presence/person-count/…). 577 (default) / 580 (`--features mqtt`) tests pass.
|
||||
- **Mass Casualty triage never reports a survivor with a heartbeat as Deceased (safety) — PR #926.** Both triage paths in `wifi-densepose-mat` — `TriageCalculator::calculate` (`combine_assessments(Absent, None) ⇒ Deceased`) and the detection path `EnsembleClassifier::determine_triage` (`!has_breathing && !has_movement ⇒ Deceased`) — ignored the `heartbeat` field. A survivor with a detectable **pulse** but no sensed breathing/movement (respiratory arrest — the most time-critical *savable* state, Immediate/Red) was therefore reported **Deceased (Black)** and deprioritized for rescue. The domain path was in fact only reachable *because* a heartbeat made `has_vitals()` true, so every "Deceased" was a live person. Both paths now escalate to **Immediate** when a heartbeat is present; total absence of breathing, movement *and* heartbeat is unchanged (domain → `Unknown`, ensemble → `Deceased`). 2 safety regression tests; full MAT suite (177) green.
|
||||
- **Per-node Home-Assistant devices now report each node's *own* presence/motion — PR #918.** After the one-device-per-node fan-out landed, the MQTT bridge still applied the *room-level aggregate* `classification` to every node, so in a multi-node deployment a node watching an empty corner inherited another node's "present" (and `motion_level: "absent"` was mis-mapped to full motion). Each node in the broadcast `nodes[]` already carries its own `classification`; the bridge now reads it per node (extracted into a testable `vitals_snapshots_from_sensing_json`), keeping vitals + person count room-level. 4 unit tests.
|
||||
- **`--model` gives an actionable diagnostic instead of a cryptic magic error — PR #919 (refs #894).** Passing a HuggingFace `ruvnet/wifi-densepose-pretrained` file (`model.safetensors` / `model-q4.bin` / `model.rvf.jsonl`) to `--model` produced `invalid magic at offset 0: … got 0x77455735`, then a silent fall back to heuristics. The load-failure path now detects the format (safetensors / quantized blob / JSONL manifest) and explains that those files are a different format **and** encoder architecture than the RVF binary container the progressive loader expects, pointing to #894. Pure `diagnose_model_load_error` + 4 tests.
|
||||
- **`--export-rvf` no longer silently produces a placeholder model — PR #920.** The `--export-rvf` handler ran *before* `--train`/`--pretrain` and unconditionally wrote placeholder sine-wave weights, so the documented `--train … --export-rvf <path>` workflow short-circuited to a fake model and never trained (while printing "exported successfully"). It now emits the placeholder **container-format demo** only standalone (with a clear warning), and falls through to real training when `--train`/`--pretrain` is set; docs point to `--save-rvf` for the real model. 3 guard tests.
|
||||
|
||||
### Added
|
||||
- **WiFi-CSI pose: efficiency frontier + per-room calibration service** (ADR-150 §3.2–3.6). Two beyond-SOTA results on the MM-Fi benchmark, plus the deployment mechanism that resolves real-world generalization:
|
||||
|
|
@ -33,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Security
|
||||
- **ESP32 OTA upload now fails closed when no PSK is provisioned** (#596 audit finding — critical, **breaking change for unprovisioned nodes**). `ota_check_auth()` previously returned `true` when `s_ota_psk[0] == '\0'`, so a freshly-flashed node would accept attacker-controlled firmware over plain HTTP on port 8032 from any host on the WiFi. No Secure Boot V2, no signed-image verification — a single LAN call could brick or backdoor a node. The fix rejects every OTA upload until a PSK is written to NVS (the OTA HTTP server still starts so operators can run `provision.py --ota-psk <hex>` over USB-CDC without reflashing). **Operators affected**: any deployment that relied on the unauthenticated OTA endpoint working out of the box now needs to provision a PSK before subsequent OTA pushes will succeed. Boot-time `ESP_LOGW` makes the new posture visible.
|
||||
- **Bearer-token auth accepts the scheme case-insensitively (RFC 6750) — PR #929.** `require_bearer` parsed the `Authorization` header with a case-sensitive `strip_prefix("Bearer ")`, so a *correct* `RUVIEW_API_TOKEN` sent as `Authorization: bearer <token>` (or `BEARER`, or with extra whitespace) was rejected with a confusing 401 — needless friction when enabling auth. The scheme is now matched with `eq_ignore_ascii_case` (per RFC 6750 §2.1 / RFC 7235 §2.1); the token compare is unchanged — still exact and constant-time (`ct_eq`) — so a wrong token or a non-Bearer scheme (`Basic …`) still returns 401. Audited the surrounding code while here: `ct_eq` correctly rejects length mismatch (no prefix-auth bypass) and the middleware fails closed. New `accepts_case_insensitive_bearer_scheme` test.
|
||||
- **Path-traversal vulnerabilities patched in five sensing-server endpoints** (closes #615 — critical). New `wifi_densepose_sensing_server::path_safety::safe_id()` enforces `[A-Za-z0-9._-]` only (no leading `.`, max 64 chars) before any user-controlled identifier reaches a `format!()` building a filesystem path. Applied at:
|
||||
- `POST /api/v1/recording/start` (`recording.rs` — `session_name`)
|
||||
- `GET /api/v1/recording/download/:id` (`recording.rs` — `id`)
|
||||
|
|
|
|||
|
|
@ -1048,7 +1048,7 @@ The Rust sensing server binary accepts the following flags:
|
|||
| `--dataset` | (none) | Path to dataset directory (MM-Fi or Wi-Pose) |
|
||||
| `--dataset-type` | `mmfi` | Dataset format: `mmfi` or `wipose` |
|
||||
| `--epochs` | `100` | Training epochs |
|
||||
| `--export-rvf` | (none) | Export RVF model container and exit |
|
||||
| `--export-rvf` | (none) | Export a **placeholder** RVF container-format demo and exit — **not a trained model**. For a real model use `--train` (+ `--save-rvf`) or download a pretrained encoder. |
|
||||
| `--save-rvf` | (none) | Save model state to RVF on shutdown |
|
||||
| `--model` | (none) | Load a trained `.rvf` model for inference |
|
||||
| `--load-rvf` | (none) | Load model config from RVF container |
|
||||
|
|
@ -1359,7 +1359,7 @@ docker run --rm \
|
|||
-v $(pwd)/output:/output \
|
||||
--entrypoint /app/sensing-server \
|
||||
ruvnet/wifi-densepose:latest \
|
||||
--train --dataset /data --epochs 100 --export-rvf /output/model.rvf
|
||||
--train --dataset /data --epochs 100 --save-rvf /output/model.rvf
|
||||
```
|
||||
|
||||
The pipeline runs 10 phases:
|
||||
|
|
|
|||
|
|
@ -172,6 +172,14 @@ impl EnsembleClassifier {
|
|||
let has_movement = reading.movement.movement_type != MovementType::None;
|
||||
|
||||
if !has_breathing && !has_movement {
|
||||
// SAFETY: a detectable heartbeat means the survivor is ALIVE. No
|
||||
// sensed breathing/movement *with* a pulse is respiratory arrest —
|
||||
// the most time-critical savable state (Immediate), never Deceased.
|
||||
// Only the total absence of breathing, movement AND heartbeat is
|
||||
// reported Deceased.
|
||||
if reading.heartbeat.is_some() {
|
||||
return TriageStatus::Immediate;
|
||||
}
|
||||
return TriageStatus::Deceased;
|
||||
}
|
||||
|
||||
|
|
@ -295,6 +303,27 @@ mod tests {
|
|||
assert_eq!(result.recommended_triage, TriageStatus::Deceased);
|
||||
}
|
||||
|
||||
/// SAFETY regression: heartbeat present but no sensed breathing/movement is
|
||||
/// respiratory arrest — Immediate, never Deceased. Only the *total* absence
|
||||
/// of breathing, movement AND heartbeat (the test above) is Deceased.
|
||||
#[test]
|
||||
fn test_heartbeat_with_no_breathing_or_movement_is_immediate() {
|
||||
// breathing: None, heartbeat: Some(72 bpm), movement: None
|
||||
let reading = make_reading(None, Some(72.0), MovementType::None);
|
||||
|
||||
let classifier = EnsembleClassifier::new(EnsembleConfig {
|
||||
min_ensemble_confidence: 0.0,
|
||||
..EnsembleConfig::default()
|
||||
});
|
||||
|
||||
let result = classifier.classify(&reading);
|
||||
assert_eq!(
|
||||
result.recommended_triage,
|
||||
TriageStatus::Immediate,
|
||||
"a survivor with a pulse must never be triaged Deceased"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensemble_confidence_weighting() {
|
||||
let classifier = EnsembleClassifier::new(EnsembleConfig {
|
||||
|
|
|
|||
|
|
@ -104,7 +104,20 @@ impl TriageCalculator {
|
|||
let movement_status = Self::assess_movement(vitals);
|
||||
|
||||
// Step 4: Combine assessments
|
||||
Self::combine_assessments(breathing_status, movement_status)
|
||||
let status = Self::combine_assessments(breathing_status, movement_status);
|
||||
|
||||
// Step 5: SAFETY OVERRIDE — a detectable heartbeat means the survivor is
|
||||
// ALIVE. `combine_assessments` only sees breathing + movement, so a
|
||||
// person with a pulse but no *sensed* breathing/movement (respiratory
|
||||
// arrest, or breathing too shallow for CSI to pick up) would otherwise
|
||||
// be reported Deceased and deprioritized for rescue. No breathing + a
|
||||
// pulse is the most time-critical *savable* state, so escalate to
|
||||
// Immediate rather than ever calling a survivor with a heartbeat dead.
|
||||
if status == TriageStatus::Deceased && vitals.heartbeat.is_some() {
|
||||
return TriageStatus::Immediate;
|
||||
}
|
||||
|
||||
status
|
||||
}
|
||||
|
||||
/// Assess breathing status
|
||||
|
|
@ -217,7 +230,9 @@ enum MovementAssessment {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::{BreathingPattern, ConfidenceScore, MovementProfile};
|
||||
use crate::domain::{
|
||||
BreathingPattern, ConfidenceScore, HeartbeatSignature, MovementProfile, SignalStrength,
|
||||
};
|
||||
use chrono::Utc;
|
||||
|
||||
fn create_vitals(
|
||||
|
|
@ -233,6 +248,29 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// SAFETY regression: a survivor with a detectable heartbeat but no sensed
|
||||
/// breathing or movement is in respiratory arrest — Immediate (Red), and
|
||||
/// must NEVER be reported Deceased. (Before the fix, `combine_assessments`
|
||||
/// ignored heartbeat and returned Deceased; that path was in fact only
|
||||
/// reachable *because* a heartbeat made `has_vitals()` true.)
|
||||
#[test]
|
||||
fn heartbeat_with_no_breathing_or_movement_is_immediate_not_deceased() {
|
||||
let vitals = VitalSignsReading {
|
||||
breathing: None,
|
||||
heartbeat: Some(HeartbeatSignature {
|
||||
rate_bpm: 72.0,
|
||||
variability: 0.1,
|
||||
strength: SignalStrength::Moderate,
|
||||
}),
|
||||
movement: MovementProfile::default(),
|
||||
timestamp: Utc::now(),
|
||||
confidence: ConfidenceScore::new(0.8),
|
||||
};
|
||||
let status = TriageCalculator::calculate(&vitals);
|
||||
assert_eq!(status, TriageStatus::Immediate, "pulse present ⇒ alive");
|
||||
assert_ne!(status, TriageStatus::Deceased);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_vitals_is_unknown() {
|
||||
let vitals = create_vitals(None, MovementProfile::default());
|
||||
|
|
|
|||
|
|
@ -100,7 +100,17 @@ pub async fn require_bearer(
|
|||
.headers()
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "));
|
||||
// RFC 6750 §2.1 / RFC 7235 §2.1: the auth-scheme ("Bearer") is
|
||||
// case-insensitive. Match it as such (and tolerate extra leading
|
||||
// whitespace before the token) so a correct token isn't rejected
|
||||
// just because a client sent `bearer`/`BEARER`. The token compare
|
||||
// below stays exact + constant-time.
|
||||
.and_then(|s| {
|
||||
let (scheme, token) = s.split_once(' ')?;
|
||||
scheme
|
||||
.eq_ignore_ascii_case("Bearer")
|
||||
.then(|| token.trim_start())
|
||||
});
|
||||
let ok = supplied
|
||||
.map(|s| ct_eq(s.as_bytes(), expected.as_bytes()))
|
||||
.unwrap_or(false);
|
||||
|
|
@ -185,6 +195,31 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepts_case_insensitive_bearer_scheme() {
|
||||
// RFC 6750 §2.1 / RFC 7235 §2.1: the auth-scheme is case-insensitive.
|
||||
// A correct token must authenticate regardless of scheme casing or
|
||||
// extra whitespace; a wrong token must still be rejected.
|
||||
async fn req_status(auth_value: &str) -> StatusCode {
|
||||
let r = wrap(AuthState::from_token("s3cr3t"));
|
||||
let mut req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/v1/info")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.headers_mut()
|
||||
.insert(AUTHORIZATION, auth_value.parse().unwrap());
|
||||
r.oneshot(req).await.unwrap().status()
|
||||
}
|
||||
assert_eq!(req_status("Bearer s3cr3t").await, StatusCode::OK);
|
||||
assert_eq!(req_status("bearer s3cr3t").await, StatusCode::OK);
|
||||
assert_eq!(req_status("BEARER s3cr3t").await, StatusCode::OK);
|
||||
assert_eq!(req_status("Bearer s3cr3t").await, StatusCode::OK); // extra space
|
||||
// Scheme leniency must NOT weaken the token check.
|
||||
assert_eq!(req_status("bearer nope").await, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(req_status("Basic s3cr3t").await, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enabled_blocks_api_with_wrong_bearer() {
|
||||
let r = wrap(AuthState::from_token("s3cr3t"));
|
||||
|
|
|
|||
|
|
@ -5619,6 +5619,16 @@ fn diagnose_model_load_error(path: &std::path::Path, data: &[u8], err: &str) ->
|
|||
)
|
||||
}
|
||||
|
||||
/// Whether `--export-rvf` should emit the placeholder container-format demo.
|
||||
///
|
||||
/// It must only do so **standalone**. Combined with `--train`/`--pretrain` the
|
||||
/// real model is produced by the training pipeline, so short-circuiting here
|
||||
/// would silently skip training and write placeholder weights — the #894 bug
|
||||
/// where the documented `--train … --export-rvf` workflow produced a fake model.
|
||||
fn export_emits_placeholder_demo(export_set: bool, train: bool, pretrain: bool) -> bool {
|
||||
export_set && !train && !pretrain
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// If `--ui-path` points nowhere (wrong cwd), try common repo layouts relative to cwd.
|
||||
|
|
@ -5662,9 +5672,24 @@ async fn main() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Handle --export-rvf mode: build an RVF container package and exit
|
||||
if let Some(ref rvf_path) = args.export_rvf {
|
||||
eprintln!("Exporting RVF container package...");
|
||||
// Handle --export-rvf: writes a CONTAINER-FORMAT DEMO with placeholder
|
||||
// weights — it is NOT a trained model. Only short-circuit when standalone:
|
||||
// combined with --train/--pretrain the real model is exported by the
|
||||
// training pipeline, and short-circuiting here would silently skip training
|
||||
// and write placeholder weights (#894 — the documented `--train …
|
||||
// --export-rvf` workflow produced a placeholder and never trained).
|
||||
if export_emits_placeholder_demo(args.export_rvf.is_some(), args.train, args.pretrain) {
|
||||
let rvf_path = args
|
||||
.export_rvf
|
||||
.as_ref()
|
||||
.expect("export_emits_placeholder_demo implies export_rvf is set");
|
||||
eprintln!(
|
||||
"WARNING: --export-rvf writes a CONTAINER-FORMAT DEMO with placeholder \
|
||||
weights — it is NOT a trained model. Train one with \
|
||||
`--train --dataset <DIR>` (which exports a calibrated .rvf to the \
|
||||
models/ directory), or download a pretrained encoder. See issue #894."
|
||||
);
|
||||
eprintln!("Exporting RVF container package (placeholder weights)...");
|
||||
use rvf_pipeline::RvfModelBuilder;
|
||||
|
||||
let mut builder = RvfModelBuilder::new("wifi-densepose", "1.0.0");
|
||||
|
|
@ -5713,6 +5738,13 @@ async fn main() {
|
|||
}
|
||||
}
|
||||
return;
|
||||
} else if args.export_rvf.is_some() {
|
||||
// --export-rvf alongside --train/--pretrain: don't emit a placeholder.
|
||||
// Fall through so training runs; it exports the real calibrated model.
|
||||
eprintln!(
|
||||
"Note: --export-rvf is ignored in training mode — the trained model \
|
||||
is exported by the training pipeline to the models/ directory."
|
||||
);
|
||||
}
|
||||
|
||||
// Handle --pretrain mode: self-supervised contrastive pretraining (ADR-024)
|
||||
|
|
@ -7310,3 +7342,29 @@ mod model_load_diagnostic_tests {
|
|||
assert!(msg.contains("wifi-densepose-train"), "{msg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod export_rvf_mode_tests {
|
||||
use super::export_emits_placeholder_demo;
|
||||
|
||||
#[test]
|
||||
fn standalone_export_emits_placeholder() {
|
||||
// --export-rvf alone → the container-format demo (placeholder weights).
|
||||
assert!(export_emits_placeholder_demo(true, false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_with_train_does_not_short_circuit() {
|
||||
// #894: `--train --export-rvf` must NOT emit a placeholder + skip
|
||||
// training — it must fall through to the real training pipeline.
|
||||
assert!(!export_emits_placeholder_demo(true, true, false));
|
||||
assert!(!export_emits_placeholder_demo(true, false, true));
|
||||
assert!(!export_emits_placeholder_demo(true, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_export_flag_never_emits() {
|
||||
assert!(!export_emits_placeholder_demo(false, false, false));
|
||||
assert!(!export_emits_placeholder_demo(false, true, false));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue