From 58bff32e6a47709484319dff2e69acd0572f5a49 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 17 Jun 2026 16:28:15 +0200 Subject: [PATCH] fix(aura-registry): Registry::load tolerates legacy pre-0047 bare-float params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cycle-0047 typed-Scalar params migration (86746e3) changed each RunManifest param value on the C18 flat runs store from a bare JSON number (2.0) to an externally-tagged Scalar ({"F64":2.0}), but did not migrate the persisted runs.jsonl. Registry::load could no longer deserialize any pre-0047 line, so `aura runs list` / `runs rank` errored out on a researcher's existing run history (bug F5, surfaced by the "The World, part II" milestone fieldtest). Fix: a localized read-side back-compat mirror (src/compat.rs). load() now reads each line via RunReportRead, whose ScalarRead accepts BOTH the tagged form AND a bare JSON number (coerced to Scalar::f64), then converts into the canonical RunReport. The forward write path (append / RunReport::to_json) is untouched — it still emits the tagged form. This is a one-directional read widening, not a format change. The secondary clause of #83 (parse error exits 0) was triaged as not-a-bug: runs list/rank already exit 2 via load_runs_or_exit (main.rs:742); the "(exit 0)" in the fieldtest transcript was a pipe measurement artifact ($? read head's exit code, not the binary's). RED test (committed 20511d5) now green; full workspace suite + clippy green. closes #83 --- crates/aura-registry/src/compat.rs | 77 ++++++++++++++++++++++++++++++ crates/aura-registry/src/lib.rs | 11 ++++- 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 crates/aura-registry/src/compat.rs diff --git a/crates/aura-registry/src/compat.rs b/crates/aura-registry/src/compat.rs new file mode 100644 index 0000000..374991c --- /dev/null +++ b/crates/aura-registry/src/compat.rs @@ -0,0 +1,77 @@ +//! Back-compatible read shapes for the C18 flat runs store. +//! +//! [`Registry::load`](crate::Registry::load) is the durable read-path and must +//! stay tolerant of the param-value wire shape across the cycle-0047 typed-`Scalar` +//! migration (`86746e3`). The current format tags each param value as an +//! externally-tagged [`Scalar`] (`{"F64":2.0}` / `{"I64":10}`); a `runs.jsonl` +//! line written *before* that migration carries the value as a **bare JSON +//! number** (`2.0`). These mirror types deserialize either shape and convert into +//! the canonical [`RunReport`] — the read side only. The forward write path +//! (`Registry::append`, `RunReport::to_json`) is untouched and still emits the +//! tagged form, so this is a one-directional widening, not a format change. + +use aura_engine::{RunManifest, RunMetrics, RunReport, Scalar, Timestamp}; +use serde::de::{self, Deserializer}; +use serde::Deserialize; + +/// A `RunReport` whose param values read back from either the tagged or the +/// legacy bare-float shape. Field-for-field structural mirror of `RunReport`; +/// only [`ScalarRead`] differs from the engine type. +#[derive(Deserialize)] +pub(crate) struct RunReportRead { + manifest: RunManifestRead, + metrics: RunMetrics, +} + +#[derive(Deserialize)] +struct RunManifestRead { + commit: String, + params: Vec<(String, ScalarRead)>, + window: (Timestamp, Timestamp), + seed: u64, + broker: String, +} + +/// A param value that accepts BOTH the current tagged [`Scalar`] form +/// (`{"F64":2.0}` / `{"I64":10}`) and the legacy pre-0047 bare JSON number +/// (`2.0`, coerced to `Scalar::F64` per the documented `f64` coercion). +struct ScalarRead(Scalar); + +impl<'de> Deserialize<'de> for ScalarRead { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + // serde_json's untagged `Value` lets us peek the shape without committing + // the parse: an object/string deserializes as the tagged `Scalar`; a bare + // number is the legacy form, coerced to `Scalar::f64`. + let value = serde_json::Value::deserialize(deserializer)?; + match value { + serde_json::Value::Number(n) => { + let f = n.as_f64().ok_or_else(|| { + de::Error::custom(format!("legacy bare-float param value out of f64 range: {n}")) + })?; + Ok(ScalarRead(Scalar::f64(f))) + } + other => Scalar::deserialize(other) + .map(ScalarRead) + .map_err(de::Error::custom), + } + } +} + +impl From for RunReport { + fn from(r: RunReportRead) -> Self { + let RunManifestRead { commit, params, window, seed, broker } = r.manifest; + RunReport { + manifest: RunManifest { + commit, + params: params.into_iter().map(|(name, v)| (name, v.0)).collect(), + window, + seed, + broker, + }, + metrics: r.metrics, + } + } +} diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index d847202..a455fdc 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -19,6 +19,8 @@ use std::path::{Path, PathBuf}; use aura_engine::{RunReport, SweepFamily, SweepPoint}; +mod compat; + mod lineage; pub use lineage::{ group_families, mc_member_reports, sweep_member_reports, walkforward_member_reports, Family, @@ -64,8 +66,13 @@ impl Registry { if raw.trim().is_empty() { continue; } - let report = - serde_json::from_str(raw).map_err(|source| RegistryError::Parse { line: i + 1, source })?; + // Read via the back-compat mirror: tolerant of both the current + // tagged-`Scalar` param shape (`{"F64":2.0}`) and the legacy pre-0047 + // bare-float shape (`2.0`). The forward write path (`append`, + // `RunReport::to_json`) is unchanged — it still emits the tagged form. + let report: RunReport = serde_json::from_str::(raw) + .map_err(|source| RegistryError::Parse { line: i + 1, source })? + .into(); reports.push(report); } Ok(reports)