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)