swarm repositories / source
aboutsummaryrefslogtreecommitdiff
path: root/crates/fidget-spinner-core
diff options
context:
space:
mode:
authormain <main@swarm.moe>2026-03-20 23:31:29 -0400
committermain <main@swarm.moe>2026-03-20 23:31:29 -0400
commit3a523c3c8ac1bf9094dbe65a6f53b71085438c0c (patch)
tree5055c0e64f3024059221d341f3b81434cae34586 /crates/fidget-spinner-core
parenteb0f0f73b7da9d76ff6833757fd265725d3e4b14 (diff)
downloadfidget_spinner-3a523c3c8ac1bf9094dbe65a6f53b71085438c0c.zip
Open the metric unit vocabulary
Diffstat (limited to 'crates/fidget-spinner-core')
-rw-r--r--crates/fidget-spinner-core/src/error.rs6
-rw-r--r--crates/fidget-spinner-core/src/lib.rs6
-rw-r--r--crates/fidget-spinner-core/src/model.rs164
3 files changed, 163 insertions, 13 deletions
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<Self> {
+ 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<String>) -> Result<Self, CoreError> {
+ 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> {
+ KnownMetricUnit::parse_alias(&self.0)
+ }
+
+ #[must_use]
+ pub fn scalar() -> Self {
+ Self(KnownMetricUnit::Scalar.as_str().to_owned())
+ }
+}
+
+impl TryFrom<String> for MetricUnit {
+ type Error = CoreError;
+
+ fn try_from(value: String) -> Result<Self, Self::Error> {
+ Self::new(value)
+ }
+}
+
+impl From<MetricUnit> 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<String, CoreError> {
+ 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,