From 3a523c3c8ac1bf9094dbe65a6f53b71085438c0c Mon Sep 17 00:00:00 2001 From: main Date: Fri, 20 Mar 2026 23:31:29 -0400 Subject: Open the metric unit vocabulary --- crates/fidget-spinner-core/src/error.rs | 6 ++ crates/fidget-spinner-core/src/lib.rs | 6 +- crates/fidget-spinner-core/src/model.rs | 164 ++++++++++++++++++++++++++++++-- 3 files changed, 163 insertions(+), 13 deletions(-) (limited to 'crates/fidget-spinner-core/src') diff --git a/crates/fidget-spinner-core/src/error.rs b/crates/fidget-spinner-core/src/error.rs index a095f57..67db8bd 100644 --- a/crates/fidget-spinner-core/src/error.rs +++ b/crates/fidget-spinner-core/src/error.rs @@ -4,6 +4,12 @@ use thiserror::Error; pub enum CoreError { #[error("text values must not be blank")] EmptyText, + #[error("metric units must not be blank")] + EmptyMetricUnit, + #[error( + "invalid metric unit `{0}`; expected a built-in unit like `microseconds` or a lowercase ascii token" + )] + InvalidMetricUnit(String), #[error("tag names must not be blank")] EmptyTagName, #[error( diff --git a/crates/fidget-spinner-core/src/lib.rs b/crates/fidget-spinner-core/src/lib.rs index 903e740..7089aa6 100644 --- a/crates/fidget-spinner-core/src/lib.rs +++ b/crates/fidget-spinner-core/src/lib.rs @@ -15,7 +15,7 @@ pub use crate::model::{ ArtifactKind, ArtifactRecord, AttachmentTargetKind, AttachmentTargetRef, CommandRecipe, ExecutionBackend, ExperimentAnalysis, ExperimentOutcome, ExperimentRecord, ExperimentStatus, FieldValueType, FrontierBrief, FrontierRecord, FrontierRoadmapItem, FrontierStatus, - FrontierVerdict, HypothesisRecord, MetricDefinition, MetricUnit, MetricValue, MetricVisibility, - NonEmptyText, OptimizationObjective, RunDimensionDefinition, RunDimensionValue, Slug, TagName, - TagRecord, VertexKind, VertexRef, + FrontierVerdict, HypothesisRecord, KnownMetricUnit, MetricDefinition, MetricUnit, MetricValue, + MetricVisibility, NonEmptyText, OptimizationObjective, RunDimensionDefinition, + RunDimensionValue, Slug, TagName, TagRecord, VertexKind, VertexRef, }; diff --git a/crates/fidget-spinner-core/src/model.rs b/crates/fidget-spinner-core/src/model.rs index cedd882..5f4bdeb 100644 --- a/crates/fidget-spinner-core/src/model.rs +++ b/crates/fidget-spinner-core/src/model.rs @@ -165,29 +165,129 @@ impl FrontierStatus { } } -#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum MetricUnit { - Seconds, - Bytes, +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum KnownMetricUnit { + Scalar, Count, Ratio, - Custom, + Percent, + Bytes, + Nanoseconds, + Microseconds, + Milliseconds, + Seconds, } -impl MetricUnit { +impl KnownMetricUnit { #[must_use] pub const fn as_str(self) -> &'static str { match self { - Self::Seconds => "seconds", - Self::Bytes => "bytes", + Self::Scalar => "scalar", Self::Count => "count", Self::Ratio => "ratio", - Self::Custom => "custom", + Self::Percent => "percent", + Self::Bytes => "bytes", + Self::Nanoseconds => "nanoseconds", + Self::Microseconds => "microseconds", + Self::Milliseconds => "milliseconds", + Self::Seconds => "seconds", + } + } + + fn parse_alias(raw: &str) -> Option { + match raw { + "1" | "scalar" | "unitless" | "dimensionless" => Some(Self::Scalar), + "count" | "counts" => Some(Self::Count), + "ratio" | "fraction" => Some(Self::Ratio), + "%" | "percent" | "percentage" | "pct" => Some(Self::Percent), + "bytes" | "byte" | "b" | "by" => Some(Self::Bytes), + "nanoseconds" | "nanosecond" | "ns" => Some(Self::Nanoseconds), + "microseconds" | "microsecond" | "us" | "µs" | "micros" => Some(Self::Microseconds), + "milliseconds" | "millisecond" | "ms" | "millis" => Some(Self::Milliseconds), + "seconds" | "second" | "s" | "sec" | "secs" => Some(Self::Seconds), + _ => None, } } } +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct MetricUnit(String); + +impl MetricUnit { + pub fn new(value: impl Into) -> Result { + let raw = value.into(); + let normalized = normalize_metric_unit(&raw)?; + Ok(Self(normalized)) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + #[must_use] + pub fn known_kind(&self) -> Option { + KnownMetricUnit::parse_alias(&self.0) + } + + #[must_use] + pub fn scalar() -> Self { + Self(KnownMetricUnit::Scalar.as_str().to_owned()) + } +} + +impl TryFrom for MetricUnit { + type Error = CoreError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(value: MetricUnit) -> Self { + value.0 + } +} + +impl Display for MetricUnit { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +fn normalize_metric_unit(raw: &str) -> Result { + let normalized = raw.trim().to_ascii_lowercase(); + if normalized.is_empty() { + return Err(CoreError::EmptyMetricUnit); + } + if let Some(unit) = KnownMetricUnit::parse_alias(&normalized) { + return Ok(unit.as_str().to_owned()); + } + if normalized == "custom" { + return Err(CoreError::InvalidMetricUnit(normalized)); + } + let mut previous_was_separator = true; + let mut has_alphanumeric = false; + for character in normalized.chars() { + if character.is_ascii_lowercase() || character.is_ascii_digit() { + previous_was_separator = false; + has_alphanumeric = true; + continue; + } + if matches!(character, '-' | '_' | '/' | '.') && !previous_was_separator { + previous_was_separator = true; + continue; + } + return Err(CoreError::InvalidMetricUnit(normalized)); + } + if !has_alphanumeric || previous_was_separator { + return Err(CoreError::InvalidMetricUnit(normalized)); + } + Ok(normalized) +} + #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] #[serde(rename_all = "snake_case")] pub enum OptimizationObjective { @@ -358,6 +458,50 @@ impl RunDimensionDefinition { } } +#[cfg(test)] +mod tests { + use super::{KnownMetricUnit, MetricUnit}; + + #[test] + fn metric_unit_normalizes_known_aliases() { + let microseconds = MetricUnit::new("micros"); + assert!(microseconds.is_ok()); + let microseconds = match microseconds { + Ok(value) => value, + Err(_) => return, + }; + assert_eq!(microseconds.as_str(), "microseconds"); + assert_eq!( + microseconds.known_kind(), + Some(KnownMetricUnit::Microseconds) + ); + + let percent = MetricUnit::new("%"); + assert!(percent.is_ok()); + let percent = match percent { + Ok(value) => value, + Err(_) => return, + }; + assert_eq!(percent.as_str(), "percent"); + assert_eq!(percent.known_kind(), Some(KnownMetricUnit::Percent)); + } + + #[test] + fn metric_unit_accepts_real_custom_tokens_and_rejects_placeholder_custom() { + let objective = MetricUnit::new("objective"); + assert!(objective.is_ok()); + let objective = match objective { + Ok(value) => value, + Err(_) => return, + }; + assert_eq!(objective.as_str(), "objective"); + assert_eq!(objective.known_kind(), None); + + let placeholder = MetricUnit::new("custom"); + assert!(placeholder.is_err()); + } +} + #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct MetricValue { pub key: NonEmptyText, -- cgit v1.2.3