fix(aura-registry): Registry::load tolerates legacy pre-0047 bare-float params

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
This commit is contained in:
2026-06-17 16:28:15 +02:00
parent 20511d5d5c
commit 58bff32e6a
2 changed files with 86 additions and 2 deletions
+77
View File
@@ -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<D>(deserializer: D) -> Result<Self, D::Error>
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<RunReportRead> 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,
}
}
}
+9 -2
View File
@@ -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::<compat::RunReportRead>(raw)
.map_err(|source| RegistryError::Parse { line: i + 1, source })?
.into();
reports.push(report);
}
Ok(reports)