Compare commits

...
29 Commits
Author SHA1 Message Date
rUv 8d1a67d31b Merge pull request #1793 from clonea1/contrib/server-classification-stability
fix(sensing-server): scale smoothing to the measured CSI rate, and debounce the room
2026-09-04 12:19:54 -04:00
rUv c19b846394 Merge pull request #1791 from clonea1/contrib/server-node-positions
fix(sensing-server): wire --node-positions into live NodeInfo output
2026-09-04 12:18:22 -04:00
rUv cf6e955196 Merge pull request #1795 from clonea1/contrib/wifi-retry-watchdog
fix(firmware): retry WiFi forever, and add an uplink watchdog (stacked on #1797)
2026-09-04 12:17:41 -04:00
rUv dddc214a25 Merge pull request #1797 from clonea1/contrib/thermal
feat(firmware): thermal monitoring, radio throttling and node health
2026-09-04 12:17:06 -04:00
rUv 5da64c6fc5 Merge pull request #1800 from TsinbeiLabs/fix/calibration-wall-clock-gate
fix(calibration): gate finalize on wall-clock duration, expose elapsed_s and frames/s
2026-09-04 12:16:45 -04:00
rUv 1c088e67c2 Merge pull request #1794 from clonea1/contrib/ui-no-fabricated-data
fix(ui): never present invented data as measured
2026-09-04 12:16:41 -04:00
rUv 1231088427 Merge pull request #1796 from clonea1/contrib/disable-unused-154-radio
perf(firmware): stop the C6 sharing its radio with a stack that cannot receive
2026-09-04 12:16:36 -04:00
rUv 4d857a908f Merge pull request #1792 from clonea1/contrib/subcarrier-grids-256
fix(firmware): stop truncating HE20 CSI to half its subcarriers on C6/C5
2026-09-04 12:16:32 -04:00
ruv 9b595b8333 fix(sensing-server): key --node-positions by active-node rank, not node_id
The prior fix stored the parsed --node-positions list keyed by list index
under the assumption that node_id == index, then looked it up by the
node's real node_id. Real fleets use non-sequential logical node IDs
(11, 12, 13, ...), so that lookup silently misses for every realistic
deployment and falls through to the same [2.0, 0.0, 1.5] placeholder the
original bug report was about.

MultistaticFuser::fuse assigns node_positions[i] to the i-th frame in its
node_frames argument, which callers build sorted ascending by node_id.
Match that same convention in the NodeInfo JSON builder: rank currently-
active node_ids ascending and index into the configured position list by
rank, so live position output agrees with what fusion actually used.

Adds node_positions_by_active_id() (shared by both call sites) plus
regression tests for non-sequential IDs, stale-node exclusion from rank
assignment, and the missing-config fallback.
2026-09-04 11:48:11 -04:00
Joe 944b7bea77 fix(firmware): retry WiFi forever; add an uplink watchdog
A node that lost WiFi could give up permanently. The disconnect handler
counted retries in s_retry_num, which is only ever cleared on a successful
IP_EVENT_STA_GOT_IP, so it accumulated across the node's entire uptime.
After ten cumulative disconnects it set WIFI_FAIL_BIT and never called
esp_wifi_connect() again. No backoff reset, no periodic retry, no reboot.
Only a power cycle cleared it.

MEASURED 2026-08-30: the AP serving the fleet took a firmware update and
rebooted at ~03:38. All three wall-mounted nodes stopped delivering within
three minutes of each other (nodes 0 and 1 at 03:37:03, to the second) and
none recovered for 5.5-7.5 hours, until each was physically unplugged. CSI
capture ran the whole time -- node 0's sync sequence advanced 16600 ->
46340 at a steady ~30/s while it was invisible to the server -- because
capture is driver-level and needs no IP. The boards looked alive on a
bench and dead to the aggregator.

Retry is now unbounded with 500 ms -> 30 s exponential backoff, scheduled
on an esp_timer one-shot so esp_wifi_connect() is never reached through a
delay inside the event handler. 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; nothing stops the node retrying.

The uplink watchdog covers the failures we have not diagnosed: no
successful send for CONFIG_UPLINK_WATCHDOG_TIMEOUT_S restarts the node.
The signal is a successful sendto(), which on UDP means the stack accepted
the datagram, NOT that the server received it -- so stopping the
aggregator cannot reboot the fleet. It arms on the first successful send
rather than at boot, so a node powered up while the AP is down does not
reboot-loop; the unbounded reconnect owns that case.

The timeout is 1200 s, sized from a 112-minute soak rather than a guess:
30 observed silences across two nodes, median 225 s, one 510 s outlier
that looked like two cycles running together. An earlier 120 s would have
rebooted healthy nodes every few minutes, and even 600 s left only 90 s of
margin over an event already seen once. Do not lower it below ~900 s while
the ~225 s stalls are unexplained; a needless reboot re-runs ESP-NOW
leader election and discards mesh time-sync state.

Also guards thermal.c's apply_tx_power behind CONFIG_THERMAL_THROTTLE so a
monitor-only build (the default, and what is on the wall) stops warning
about an unused function.

Kconfig gains CSI_GATE_MESH_ALIGNED here alongside the watchdog options;
its implementation lands in a later commit and the option is inert until
then.

Co-Authored-By: claude-flow <ruv@ruv.net>
(cherry picked from commit bb6d954de68fd4d542e191fd5e82c753f67a9874)
2026-09-04 11:31:58 -04:00
Joeandclaude-flow 7f9df37380 fix(firmware): damp every thermal step down, not just the one into OK
Follow-up to the review on #1797. The CRITICAL -> THROTTLED edge was
unconditional: any reading below critical_c dropped the state immediately, so a
node sitting on the critical boundary flapped between TXP_MIN and TXP_STEP2
indefinitely. Every oscillation moves that node's RSSI at both ends, which a
reader cannot distinguish from an obstruction -- exactly the failure the
hysteresis on the other edges was added to prevent.

Now every falling edge requires cooling a full hysteresis band below the
threshold it crossed on the way up; rising edges stay immediate, because heat is
a reason to act now. Verified by dithering across the boundary: six readings
either side of 80 C previously produced five state changes, now zero.

The decision moved into thermal.h as a pure function, following the convention
edge_processing.h already uses for edge_evidence_person_count -- a host test
then exercises the exact function the device build runs rather than a copy free
to drift.

BEHAVIOUR CHANGE worth calling out, beyond what the review asked for. The old
`recover` point was a single global threshold (throttle_c - hyst) that every
state fell back through, so leaving WARN required cooling to 64 C -- a bound
derived from the throttle threshold, not WARN's own. Hysteresis is now relative
to each state's own entry point, so WARN releases at 57 C instead. Leaving
THROTTLED is unchanged at 64 C. Kconfig help updated, since it described the old
throttle-relative semantics.

One consequence the test pins explicitly: with the default constants the
critical exit point (80 - 8 = 72) coincides exactly with the throttle threshold,
so a node cool enough to leave CRITICAL is already below THROTTLED's entry and
lands in WARN. THROTTLED is unreachable descending from CRITICAL at hysteresis
8. That falls out of the chosen constants rather than the logic, and the test
covers hysteresis 4 as well to show the intermediate step reappears when the
bands do not coincide. My first version of the test asserted THROTTLED there and
was simply wrong -- running it caught that.

Verified in espressif/idf:v5.4: against the unfixed transition the test fails
five checks including the dither, passes with the fix, and the C6 firmware
builds. Still no hardware evidence for this PR -- a real boot log is coming
before merge, as promised earlier in the thread.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-04 11:29:27 -04:00
Hsukqi Lee f574450516 fix(calibration): gate finalize on wall-clock duration, expose elapsed_s and frames/s
Signed-off-by: Hsukqi Lee <team@tsinbei.com>
2026-09-04 20:15:38 +08:00
Joeandclaude-flow 3621a211bd fix(sensing-server): debounce the room-level classification too
`fuse_room` recomputes its plurality vote fresh every cycle with no memory of
its own, unlike each node's classification, which is debounced individually.

With per-node confidences sitting near the classification boundary -- 44%, 83%
and 53% observed simultaneously across three nodes -- whichever two of three
happened to agree on a given cycle could flip the room-level result while every
node's own reading was individually stable. The room read
absent / present_still / absent / present_still in rapid succession.

That cascaded into everything gating on presence: the persons array, the
rendered figure appearing and disappearing, the live position dot. Each of
those looked like its own bug, and none of them was.

Adds the missing room-level hysteresis. A new candidate classification must be
observed continuously for ROOM_DEBOUNCE_DURATION_SECS before it is adopted; an
interrupted run restarts it. The threshold is a DURATION rather than a cycle
count, for the same reason as the per-node fix in the previous commit: a count
means a different real interval at every rate.

Debouncing at the room level does not make the per-node debounce redundant.
They answer different questions -- "is this node's reading stable" and "do the
nodes agree stably" -- and the observed failure was agreement flapping while
every individual reading held steady.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-03 17:16:02 -04:00
Joe 5eb8ddde12 fix(sensing-server): scale per-node smoothing to actual CSI rate
DEBOUNCE_FRAMES, MOTION_EMA_ALPHA, and BASELINE_EMA_ALPHA were tuned
assuming CSI frames arrive at ~10 FPS (per their own doc comments).
smooth_and_classify_node() runs once per raw incoming UDP frame, and
ESP32-C6 boards measured in the field run at ~48-50 FPS - about 5x
faster. That silently compressed a ~0.4s debounce window to ~0.08s
and a ~1s motion EMA to ~0.2s of real smoothing, letting per-frame
CSI noise flip the reported motion_level almost directly instead of
being smoothed - the reported UI "seizure" of rapid label flicker.

Rather than hardcode a different fixed rate (which would just move
the same bug to whatever chip/capture-mode/config next diverges from
it), derive each frame's effective alpha and frame-count thresholds
from the node's own measured csi_fps_ema, which is already tracked
and burst-filtered elsewhere (observe_csi_frame_arrival). Time
constants are back-derived from the original alphas at the assumed
10 FPS point, so behavior is unchanged for a node at exactly 10 FPS
and now scales correctly to whatever rate a node actually runs at.

smooth_and_classify() (simulated/WiFi-scan sources, already tick-
gated at a known fixed cadence) is unaffected - not exhibiting this
bug, left as-is.

Co-Authored-By: claude-flow <ruv@ruv.net>
(cherry picked from commit d29d9fb02078b066be8557af24023c22e99fa3d1)
2026-09-03 17:12:18 -04:00
Joeandclaude-flow fbecae4e02 fix(ui): never present invented data as measured
The dashboard flip-flopped between "absent" and "present_still" after
navigating away and back. The cause was not sensing. On a dropped socket the
client fell back to a built-in simulation and rendered synthetic values that
were indistinguishable from live ones, so the UI was showing made-up readings
and saying nothing about it.

That is the most damaging failure this UI can have. A sensing system that
invents data silently is worse than one that shows nothing, because every
downstream judgement -- including which nodes to move and whether a change
helped -- is then made against fiction.

Changes:

  - Client-side simulation is default-OFF and must be opted into explicitly.
  - An unreachable server retries forever instead of substituting data.
  - A banner states plainly whenever what is displayed is not live. The
    labels say what they mean: "NO DATA - SERVER UNREACHABLE" for an
    unreachable server, and "INVENTED DATA - NOT MEASURED" for simulation,
    replacing "OFFLINE - CLIENT SIMULATION", which read like a status rather
    than a warning.

Also fixes a reconnect race in the same path: two in-flight connects could each
install handlers, so a stale socket kept overwriting fresher state. That race is
what made the flip-flop intermittent and hard to attribute.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-03 11:42:16 -04:00
rUv de099c1f2d feat: train real fal.ai synthetic visual teacher LoRA (ADR-353)
feat: train real fal.ai synthetic visual teacher LoRA (ADR-353)
2026-09-03 10:35:12 -04:00
rUv 01460b98b0 research: real zero-shot pretrained-checkpoint eval beats naive baselines (RuForecast)
research: real zero-shot pretrained-checkpoint eval beats naive baselines (RuForecast)
2026-09-03 10:35:09 -04:00
rUv 26f444138e chore(forecast): bump ruforecast submodule to governance/spend enforcement fix
chore(forecast): bump ruforecast submodule to governance/spend enforcement fix
2026-09-03 10:34:57 -04:00
Joeandclaude-flow 00737441c4 perf(firmware): stop the C6 sharing its radio with a stack that cannot receive
Every C6 node spends coexistence time-slicing on 802.15.4 -- a single 2.4 GHz
transceiver is shared between WiFi, BLE and 15.4 -- against a receive path that
provably delivers nothing. That time competes directly with CSI capture, which
is the node's entire purpose, and it matters most on a large fleet where the
50 Hz receive gate is already the binding constraint.

The evidence that it cannot receive is upstream's own (ADR-110 D1,
ruvnet/RuView#762): five configurations tested -- channel 15 and 26, OpenThread
on and off, promiscuous on and off, PAN-matched and broadcast -- and every one
gave rx=0 while TX stayed 100% reliable on IDF v5.4. ESP-NOW shipped as the
workaround and is what this firmware actually syncs on. The stack was
nonetheless still initialised at boot and beaconing every 100 ms.

Safe to remove: 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. c6_timesync_init is gated on the same symbol and
is no longer called.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-03 08:53:09 -04:00
Joe 25d7be3c25 fix(sensing-server): wire --node-positions into live NodeInfo output
Node position parsing/storage for multistatic fusion worked correctly,
but the per-node JSON builder (both the vitals-only and governed/
privacy-gated ESP32 live-data paths) never read the parsed positions -
it always emitted the hardcoded fallback [2.0, 0.0, 1.5] regardless of
--node-positions. Store the parsed positions in AppStateInner, keyed
by 0-based index (= node_id), and read from it at both call sites.

Matches upstream issues #228, #249, and the "Known Issues" #301 symptom
class ("same output regardless of position").

Co-Authored-By: claude-flow <ruv@ruv.net>
(cherry picked from commit d34d76c54e7ba4a466cf9dc27060684f49756b6b)
2026-09-03 08:00:30 -04:00
Joeandclaude-flow 29b3968731 fix(firmware): size the subcarrier buffers for HE20, not just pre-HE
EDGE_MAX_SUBCARRIERS is a flat 128, which silently truncates CSI on any part
that carries HE20. An ESP32-C6 or C5 reports 256 subcarriers per frame; every
buffer sized by this constant holds half of them, and edge_process_frame()
returns early on n_subcarriers > EDGE_MAX_SUBCARRIERS, so on those parts the
edge pipeline stops entirely -- no log past init and no vitals packet ever
reaching the sink.

Gated on CONFIG_SOC_WIFI_HE_SUPPORT, the same switch csi_collector.c already
uses to pick its rx_ctrl layout, so pre-HE parts keep 128 and do not pay ~3.5 KB
of .bss they can never use.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-02 23:53:02 -04:00
Joe ed006abd58 refactor(firmware): separate acting on temperature from measuring it
Reading the sensor is inert. Reducing transmit power moves every RSSI the node
has, at both ends of every link -- introducing that during a three-to-nine node
bring-up would give any surprise two candidate causes and no way to separate
them.

So CONFIG_THERMAL_THROTTLE is now its own option, default off, while
monitoring stays on. The first flash therefore reports die temperature and
peak without ever changing behaviour, which is exactly what is needed to
answer whether these boards run hot in enclosures at all.

If none of them ever does -- the likely outcome at ~0.4 W average in a vented
box -- throttling never needs enabling and the thresholds cost nothing.

Co-Authored-By: claude-flow <ruv@ruv.net>
(cherry picked from commit 187eb17ba9549a96aa63ce0fda37888c8c65650c)
2026-09-02 22:44:24 -04:00
Joe 5419bff2a8 feat(firmware): thermal monitoring, radio throttling, reset reason, heap health
The C6 carries a temperature sensor this firmware has never read. Tolerable on
a bench board; not once nine nodes are sealed in printed enclosures on a wall
with the radio transmitting continuously.

What is 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 under its own weight over weeks -- a failure that never announces itself.
Thresholds are set against DIE temperature with that offset in mind: ~72 C on
the die in a small vented box is roughly a 50-55 C enclosure. Setting the trip
at 55 would throttle a perfectly healthy node.

Throttling reduces WiFi transmit power in 3 dB steps, the dominant heat source
and the one that degrades gracefully -- links weaken but keep working, whereas
cutting capture rate would blind the node. Recovery has 8 C of hysteresis
because oscillating around the trip point would put a visible RSSI step on
every link the node has, repeatedly.

`thermal_tx_dbm()` and `thermal_state()` are exported for a specific reason: a
throttled node's RSSI falls at both ends, which is indistinguishable from an
obstruction in the link diagnostics. Anything reading link strength must be
able to tell a hot node from a wall.

Also records two things nothing tracked before:

- **Reset reason at boot.** A node that reboots tells you almost nothing; one
  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 one line.
- **Free and minimum-free heap** in the periodic log. A slow leak and an
  overheat look identical after ten days unless the minimum has been visibly
  walking downward the whole time.

Kconfig-gated (default y on C6), thresholds configurable. Builds clean at
0xfe990, 45% partition free.

Co-Authored-By: claude-flow <ruv@ruv.net>
(cherry picked from commit 898295fba7b3873b37e85a5ffc0ec87fe609aa23)
2026-09-02 22:44:09 -04:00
ruv f1ea275d5d chore(forecast): bump ruforecast submodule to the governance/spend fix
Points v2/crates/ruforecast at ruvnet/RuForecast@a802067f, the tip of
fix/governance-and-spend-enforcement (ruvnet/RuForecast PR #2), which closes
14 code-review findings against the training stack merged as #1766 -- most
critically, ADR-349's governance-verification and spend-approval-record
requirements were specified but never actually enforced by the fal.ai
hosted path; they are now.

Adds ADR-354 recording this as a RuView-level integration decision and
pointing to the submodule's own ADR-350 for the full technical record,
since that repository is now the source of truth for the forecast
subsystem's decision history.

Depends on ruvnet/RuForecast#2 merging with green CI first; see that PR for
status before merging this one.
2026-09-02 08:53:08 -04:00
ruv 5c50fa8588 feat: train real fal.ai synthetic visual teacher LoRA (ADR-353)
Trains a real fal.ai LoRA fine-tune (fal-ai/flux-lora-fast-training) on
frames extracted from the real fal.ai-generated room-visualization MP4s
already captured this session, so display-layer generation has a specific
trained style target instead of prompt engineering against a stock model.

Architecture boundary: this model only ever produces supplementary
synthetic visual/pixel output. It has no path into measured/derived RF,
pose, vitals, identity, or confidence values, which remain local and
authoritative.

Real run: request 01a060b2-c940-7972-abf9-ea48847eb2ae, 1000 steps, 27
training images, ~$2.04 spend, validated with a real inference call
against the trained LoRA (assets/fal-visual-teacher-sample.png).
2026-09-02 08:45:23 -04:00
ruv f19938a7b6 docs(ruforecast): real zero-shot pretrained-checkpoint comparison beats naive baselines
Chronos-Bolt (base/small), Chronos-T5-small, and TimesFM-2.5-200M run
zero-shot (no fine-tuning) on the same BIDMC cross-entity test windows,
baselines, and WQL/MAE metrics as the prior from-scratch RuForecast
BIDMC result. Unlike every from-scratch RuForecast configuration tried
this session, all four pretrained checkpoints beat both last-value and
seasonal-naive baselines on WQL, on both independent patient-partition
judges (12-42% margin), consistent with the massive-pretraining-corpus
hypothesis from the prior literature pass.
2026-09-02 02:15:33 -04:00
ruv db5a898372 docs(readme): move animated hero GIF to top banner position 2026-09-02 01:31:51 -04:00
ruv b11422459e docs(readme): add animated hero GIF
Real-time-style animated visualization illustrating RuView's pose
estimation, breathing, and heart-rate sensing capability, generated
via MiniMax H3 image-to-video from the existing hero graphic.
2026-09-02 01:27:34 -04:00
rUv e5bf0d4892 feat(forecast): add independent Rust multivariate training stack (#1766)
* feat(forecast): add independent Rust training stack

Signed-off-by: Codex <codex@openai.com>

* docs(forecast): record informal HPO exploration note (unaccepted)

Records an exploratory Darwin Mode numeric-genome hyperparameter search
against the tiny_ci synthetic accuracy protocol: WQL improved 0.257 -> 0.161
-> 0.153 over two search rounds, staying ahead of both baselines throughout.
Explicitly scoped as informal/exploratory (not a frozen leakage-free report,
not eligible for the evidence ledger, not validated for large_linux or any
real dataset) per this doc's own evidence-tagging discipline.

* feat(forecast-core): add per-horizon weighted quantile loss

weighted_quantile_loss collapses the whole horizon into one aggregate
number, hiding whether error grows with lead time. Add a sibling
function using the identical per-cell pinball formula and domain
checks, just reduced per horizon step instead of globally. A unit
test reconstructs the aggregate from the per-step numerators/
denominators to prove the two functions agree exactly, not just
approximately.

* feat(forecast-model): add evaluation-only local activation

Adds activate_for_evaluation and build_eval_input, gated behind the
cpu feature. activate_for_evaluation self-signs an unsigned candidate
with a fixed, publicly-known, non-secret Ed25519 key so a CLI operator
can run inference against their own just-trained candidate without a
real release signature -- explicitly never a production trust path,
documented as such in the module doc comment. The schema-digest check
in ArtifactActivationPolicy still applies in full; only signing is
relaxed. build_eval_input constructs a single-window ModelInput for
CPU inference from raw context-major values/mask arrays, matching the
training batch builders exact time-feature/descriptor encoding.

Six unit tests cover: malformed-candidate rejection, digest-mismatch
rejection (proving the schema check has teeth even on this relaxed
path), getting past the policy gate with a matching digest, and
eval-input shape validation.

* feat(forecast-train): add evaluate and prepare-synthetic-dataset CLI

Turns this sessions throwaway HPO scripts into real, tested CLI
surfaces, replacing two example binaries that bypassed the CLI/TOML
training path entirely.

prepare-synthetic-dataset generates a larger, configurable synthetic
training shard (default 24 windows) plus a matching train-local.toml
with the given OptimizerSpec hyperparameters baked in, and a separate
held-out test.jsonl -- the same synthetic-only, local-validation-only
posture as prepare-local-example, just bigger and configurable.

evaluate scores a trained (unsigned) candidate against held-out
windows and the LastValue/SeasonalNaive baselines: overall weighted
quantile loss, a per-horizon breakdown, 80% interval coverage, and
missingness, for the model and both baselines. Explicitly lists what
docs/benchmarks/ruforecast.mds full accuracy protocol additionally
asks for that this does not cover (abstention coverage, selective
risk, site/device slices, interference regime, RuVector-retrieval
ablation) rather than silently omitting them -- each needs
infrastructure a single-entity synthetic fixture does not have.

Together the three real commands (prepare-synthetic-dataset,
train-local, evaluate) reproduce the exact WQL numbers the old
library-bypassing example scripts produced for the same
hyperparameters, confirmed by re-running the harness/ruview HPO
dry-run end to end against the new CLI path.

New integration test trains a real candidate via smoke, evaluates it
against held-out synthetic windows, and asserts the full report shape
and an empty-input rejection.

* feat(forecast-train): add prepare-synthetic-dataset --seed + Autogenous bridge

--seed (default 0, backward compatible) makes prepare-synthetic-dataset's
generator deterministic per offset: same seed -> byte-identical corpus
across runs (needed to train two genomes on the SAME corpus for a fair
comparison); different seeds -> genuinely independent corpora (needed for
honest multi-seed/multi-judge evaluation upstream, both in
harness/ruview/flywheel/ruforecast/gate.mjs's fitness function and in the
new v2/crates/ruforecast-autogenous-bridge crate added here).

ruforecast-autogenous-bridge is a LOCAL-DEV-ONLY (excluded from the v2
workspace, publish = false) bridge from this crate's real evaluate CLI
into ruvnet/autogenous's new regression-candidate promotion path (separate,
unpushed branch feat/regression-candidate-kind): runs N independent
train+evaluate judges on genuinely distinct synthetic corpora, signs
receipts, and gets a real cryptographically-verified PROMOTE/REJECT
decision -- defense in depth on top of, not a replacement for, Darwin's own
promotion gate. Path-depends on a sibling autogenous checkout that does not
exist in CI; build/run directly via `cargo build --manifest-path
crates/ruforecast-autogenous-bridge/Cargo.toml`.

cargo test -p ruview-forecast-train --no-default-features --features
cpu,cli: all green (unaffected existing tests + this backward-compatible
addition).

* docs(forecast): retract round-2 HPO result, record honest multi-seed finding

The earlier "Informal HPO exploration note" claimed a real improvement
(WQL 0.257 -> 0.153) from a 3-round hyperparameter search. That search
evaluated every candidate against one fixed synthetic corpus (seed 0) for
every round -- textbook overfitting. Independent verification against two
fresh corpora (via the new ruforecast-autogenous-bridge crate) showed the
"winner" losing to the baseline on both.

Fixed the root cause in harness/ruview/flywheel/ruforecast/gate.mjs:
candidates are now scored against three independent corpora, worst-case
across them, not one fixed corpus.

Re-ran the search under the fix. It found a new winner that genuinely beat
baseline on all three of its own search seeds -- and that winner ALSO lost
independent verification on fresh seeds. Two independent search rounds,
pre- and post-fix, both produced an illusory "winner." The honest reading:
at this dataset scale (24 synthetic windows) held-out WQL varies enormously
by which corpus is drawn, regardless of hyperparameters -- confirmed
directly by the baseline genome's own primary swinging from 0.83 to a full
regression across the three fixed search seeds with unchanged
hyperparameters. No RuForecast hyperparameter configuration has been shown
to reliably beat the trivial baselines out-of-sample at this scale.

Also fixes a real design inconsistency surfaced by this exploration: the
Autogenous regression-candidate promotion verifier
(envelope::regression::verify_regression_promotion, separate unpushed
ruvnet/autogenous branch feat/regression-candidate-kind) required all
judges to share one corpus_id, which conflicts with this kind's
intentional cross-corpus judge design. Corrected there (commit bfa4c48);
did not change either REJECT verdict, which were already driven by the
real NotBetterThanParent signal on their own.

Append-only: the original round-2 row is kept, read together with this
new amendment section, per this doc's own evidence-ledger discipline.

* refactor(forecast): extract RuForecast to ruvnet/RuForecast submodule

Mirrors the v2/crates/worldgraph pattern: RuForecast becomes its own
independent public repo/workspace (ruforecast-core/model/train), mounted
at v2/crates/ruforecast as a git submodule, with v2/Cargo.toml path-depping
into its sub-crates and excluding the submodule from the v2 workspace.

Full v2 --workspace check passes; the ruforecast CLI builds and runs
correctly from its new location. Real git history for the extracted
crates (7 commits) was preserved via git-filter-repo, not squashed.

* feat(autogenous-bridge): real-data independent verification tool

real_data_verify.rs: signs two real, temporally-independent judge
measurements (different train/test split boundaries on the same real
household vitals corpus, not synthetic seeds) through the real
Autogenous regression-candidate promotion path, and gets a genuine
cryptographically-checked PROMOTE/REJECT verdict.

Result recorded (see docs/benchmarks/ruforecast.md): REJECT. One judge
nominally beat the trivial baseline but by less than the 0.01
non-inferiority margin; the other judge lost outright. Consistent with
every synthetic-data search this session -- no configuration has yet
been shown to reliably beat trivial baselines out-of-sample, now
including a real 6390-sample household corpus.

Bumps the ruforecast submodule to pick up the evaluate real-gap fix.

* docs(forecast): record real-household-data result (REJECT, signed)

6390 real vital-signs samples, two independent temporal splits,
independently verified via the real Autogenous regression-candidate
promotion path. Signed verdict: REJECT -- neither split cleared the
non-inferiority margin. Same conclusion as every synthetic search this
session, now confirmed with real data too: more real data is the
credible next lever, not further search on this scale of fixture.

* chore(forecast): point ruforecast submodule at published main (post gap-tolerance fix)

* ci(forecast): fix ruforecast-ci.yml for the new v2/crates/ruforecast submodule layout

The RuForecast crates were extracted into a standalone submodule (v2/crates/ruforecast, mirroring the existing v2/crates/worldgraph pattern) and are no longer members of the v2 workspace. The CI workflow still referenced the old in-tree package names/paths (ruview-forecast-core/model/train under v2/), which broke every forecast job with "cannot specify features for packages outside of workspace".

Fixed every job to target the submodule: working-directory -> v2/crates/ruforecast, -p ruview-forecast-* -> -p ruforecast-*, all hardcoded Cargo.toml/source paths in the Python assertion and clean-room-scan blocks, the Swatinem/rust-cache workspaces input, the artifact-tree output paths, and the two levels of relative path (../ -> ../../) in the informational-benchmark steps whose working directory moved one level deeper.

Also collapsed the paths: trigger filters internal-crate globs (v2/crates/ruview-forecast-*/**) down to the single v2/crates/ruforecast path, since GitHub Actions path filters only ever see a submodule gitlink change as one entry in the parent tree, never its internal file paths -- the old globs could never have matched anything.

Includes a real cargo fmt fix (submodule commit d9902f9) for formatting drift left over from the earlier real-data gap-tolerance fix, which the contract job's fmt --check step would otherwise have failed on.

All four forecast CI jobs' real commands verified locally before push: cargo test/check for CPU, CUDA/WGPU compile-check, the contract job's Python assertion block, the hosted-boundaries clean-room scan, and cargo fmt --check.

* docs(forecast): record real BIDMC cross-entity holdout result (REJECT)

Two independent real-patient partitions (34/16 and 24/26 splits, 53 total
ICU patients from PhysioNet BIDMC, Open Data Commons Attribution License
v1.0) both show the model losing to trivial baselines by a decisive margin
(+69% and +601% worse WQL). First genuine cross-entity real-data test this
project has run; same conclusion as every prior synthetic and single-household
test. Full Autogenous signed verification was not run for this entry -
flagged explicitly in the note.

* chore(forecast): point ruforecast submodule at published main (example feature-gate fix)

* chore(forecast): point ruforecast submodule at published main (clippy fix)

---------

Signed-off-by: Codex <codex@openai.com>
2026-09-01 23:35:42 -04:00
56 changed files with 8369 additions and 64 deletions

No files matched your search

+627
View File
@@ -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
+6
View File
@@ -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/
+4
View File
@@ -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
+1 -1
View File
@@ -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>
+7
View File
@@ -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)
+162
View File
@@ -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)
+4
View File
@@ -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
+534
View File
@@ -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&nbsp;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&nbsp;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>` |
+511
View File
@@ -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.
+2 -1
View File
@@ -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. */
+25 -1
View File
@@ -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 ----
*
+144 -8
View File
@@ -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.
*/
+201
View File
@@ -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 */
+106
View File
@@ -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
+17 -4
View File
@@ -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())
+124
View File
@@ -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())
+95
View File
@@ -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()
+288
View File
@@ -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'
+21
View File
@@ -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
+2 -1
View File
@@ -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}`;
+4 -2
View File
@@ -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;
+2 -1
View File
@@ -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
View File
@@ -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
+8
View File
@@ -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 {
+131
View File
@@ -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
View File
@@ -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]]
+12
View File
@@ -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 v2/crates/ruforecast added at a802067f04.
+577
View File
@@ -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);
}
}