Files
RuView/scripts/run-ruforecast-benchmarks.sh
T
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

289 lines
10 KiB
Bash
Executable File

#!/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'