From 352fb5f089e74bf47b60c6221594b9c22defe251 Mon Sep 17 00:00:00 2001 From: main Date: Thu, 19 Mar 2026 17:41:40 -0400 Subject: Prepare fidget spinner for public sharing --- crates/fidget-spinner-core/Cargo.toml | 6 +- crates/fidget-spinner-core/src/error.rs | 6 ++ crates/fidget-spinner-core/src/lib.rs | 11 +- crates/fidget-spinner-core/src/model.rs | 178 +++++++++++++++++++++++++++++++- 4 files changed, 192 insertions(+), 9 deletions(-) (limited to 'crates/fidget-spinner-core') diff --git a/crates/fidget-spinner-core/Cargo.toml b/crates/fidget-spinner-core/Cargo.toml index b472b91..c147ee2 100644 --- a/crates/fidget-spinner-core/Cargo.toml +++ b/crates/fidget-spinner-core/Cargo.toml @@ -1,9 +1,13 @@ [package] name = "fidget-spinner-core" -description = "Core domain model for a local-first experimental DAG" +categories.workspace = true +description = "Core domain model for the Fidget Spinner experimental DAG" edition.workspace = true +keywords.workspace = true license.workspace = true publish = false +readme.workspace = true +repository.workspace = true rust-version.workspace = true version.workspace = true diff --git a/crates/fidget-spinner-core/src/error.rs b/crates/fidget-spinner-core/src/error.rs index 8e976c7..eb05ba7 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("tag names must not be blank")] + EmptyTagName, + #[error( + "invalid tag name `{0}`; expected lowercase ascii alphanumerics separated by `-`, `_`, or `/`" + )] + InvalidTagName(String), #[error("command recipes must contain at least one argv element")] EmptyCommand, } diff --git a/crates/fidget-spinner-core/src/lib.rs b/crates/fidget-spinner-core/src/lib.rs index f368268..b5e2b23 100644 --- a/crates/fidget-spinner-core/src/lib.rs +++ b/crates/fidget-spinner-core/src/lib.rs @@ -18,9 +18,10 @@ pub use crate::model::{ AdmissionState, AnnotationVisibility, ArtifactKind, ArtifactRef, CheckpointDisposition, CheckpointRecord, CheckpointSnapshotRef, CodeSnapshotRef, CommandRecipe, CompletedExperiment, DagEdge, DagNode, DiagnosticSeverity, EdgeKind, EvaluationProtocol, ExecutionBackend, - ExperimentResult, FieldPresence, FieldRole, FrontierContract, FrontierNote, FrontierProjection, - FrontierRecord, FrontierStatus, FrontierVerdict, GitCommitHash, InferencePolicy, JsonObject, - MetricObservation, MetricSpec, MetricUnit, NodeAnnotation, NodeClass, NodeDiagnostics, - NodePayload, NodeTrack, NonEmptyText, OptimizationObjective, PayloadSchemaRef, - ProjectFieldSpec, ProjectSchema, RunRecord, RunStatus, ValidationDiagnostic, + ExperimentResult, FieldPresence, FieldRole, FieldValueType, FrontierContract, FrontierNote, + FrontierProjection, FrontierRecord, FrontierStatus, FrontierVerdict, GitCommitHash, + InferencePolicy, JsonObject, MetricObservation, MetricSpec, MetricUnit, NodeAnnotation, + NodeClass, NodeDiagnostics, NodePayload, NodeTrack, NonEmptyText, OptimizationObjective, + PayloadSchemaRef, ProjectFieldSpec, ProjectSchema, RunRecord, RunStatus, TagName, TagRecord, + ValidationDiagnostic, }; diff --git a/crates/fidget-spinner-core/src/model.rs b/crates/fidget-spinner-core/src/model.rs index f0d1818..2de3705 100644 --- a/crates/fidget-spinner-core/src/model.rs +++ b/crates/fidget-spinner-core/src/model.rs @@ -5,6 +5,7 @@ use camino::Utf8PathBuf; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; use crate::{ AgentSessionId, AnnotationId, ArtifactId, CheckpointId, CoreError, ExperimentId, FrontierId, @@ -57,6 +58,60 @@ impl Display for GitCommitHash { } } +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct TagName(String); + +impl TagName { + pub fn new(value: impl Into) -> Result { + let normalized = value.into().trim().to_ascii_lowercase(); + if normalized.is_empty() { + return Err(CoreError::EmptyTagName); + } + let mut previous_was_separator = true; + for character in normalized.chars() { + if character.is_ascii_lowercase() || character.is_ascii_digit() { + previous_was_separator = false; + continue; + } + if matches!(character, '-' | '_' | '/') && !previous_was_separator { + previous_was_separator = true; + continue; + } + return Err(CoreError::InvalidTagName(normalized)); + } + if previous_was_separator { + return Err(CoreError::InvalidTagName(normalized)); + } + Ok(Self(normalized)) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom for TagName { + type Error = CoreError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(value: TagName) -> Self { + value.0 + } +} + +impl Display for TagName { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + pub type JsonObject = Map; #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] @@ -143,6 +198,44 @@ pub enum InferencePolicy { ModelMayInfer, } +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FieldValueType { + String, + Numeric, + Boolean, + Timestamp, +} + +impl FieldValueType { + #[must_use] + pub const fn is_plottable(self) -> bool { + matches!(self, Self::Numeric | Self::Timestamp) + } + + #[must_use] + pub fn accepts(self, value: &Value) -> bool { + match self { + Self::String => value.is_string(), + Self::Numeric => value.is_number(), + Self::Boolean => value.is_boolean(), + Self::Timestamp => value + .as_str() + .is_some_and(|raw| OffsetDateTime::parse(raw, &Rfc3339).is_ok()), + } + } + + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::String => "string", + Self::Numeric => "numeric", + Self::Boolean => "boolean", + Self::Timestamp => "timestamp", + } + } +} + #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] pub enum FrontierStatus { Exploring, @@ -264,6 +357,13 @@ impl NodeAnnotation { } } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct TagRecord { + pub name: TagName, + pub description: NonEmptyText, + pub created_at: OffsetDateTime, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct ValidationDiagnostic { pub severity: DiagnosticSeverity, @@ -296,6 +396,8 @@ pub struct ProjectFieldSpec { pub severity: DiagnosticSeverity, pub role: FieldRole, pub inference_policy: InferencePolicy, + #[serde(default)] + pub value_type: Option, } impl ProjectFieldSpec { @@ -303,6 +405,11 @@ impl ProjectFieldSpec { pub fn applies_to(&self, class: NodeClass) -> bool { self.node_classes.is_empty() || self.node_classes.contains(&class) } + + #[must_use] + pub fn is_plottable(&self) -> bool { + self.value_type.is_some_and(FieldValueType::is_plottable) + } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -330,6 +437,13 @@ impl ProjectSchema { } } + #[must_use] + pub fn field_spec(&self, class: NodeClass, name: &str) -> Option<&ProjectFieldSpec> { + self.fields + .iter() + .find(|field| field.applies_to(class) && field.name.as_str() == name) + } + #[must_use] pub fn validate_node(&self, class: NodeClass, payload: &NodePayload) -> NodeDiagnostics { let items = self @@ -337,8 +451,24 @@ impl ProjectSchema { .iter() .filter(|field| field.applies_to(class)) .filter_map(|field| { - let is_missing = payload.field(field.name.as_str()).is_none(); + let value = payload.field(field.name.as_str()); + let is_missing = value.is_none(); if !is_missing || field.presence == FieldPresence::Optional { + if let (Some(value), Some(value_type)) = (value, field.value_type) + && !value_type.accepts(value) + { + return Some(ValidationDiagnostic { + severity: field.severity, + code: format!("type.{}", field.name.as_str()), + message: validation_message(format!( + "project payload field `{}` expected {}, found {}", + field.name.as_str(), + value_type.as_str(), + json_value_kind(value) + )), + field_name: Some(field.name.as_str().to_owned()), + }); + } return None; } Some(ValidationDiagnostic { @@ -366,6 +496,17 @@ fn validation_message(value: String) -> NonEmptyText { } } +fn json_value_kind(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "numeric", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct DagNode { pub id: NodeId, @@ -375,6 +516,7 @@ pub struct DagNode { pub archived: bool, pub title: NonEmptyText, pub summary: Option, + pub tags: BTreeSet, pub payload: NodePayload, pub annotations: Vec, pub diagnostics: NodeDiagnostics, @@ -402,6 +544,7 @@ impl DagNode { archived: false, title, summary, + tags: BTreeSet::new(), payload, annotations: Vec::new(), diagnostics, @@ -628,8 +771,9 @@ mod tests { use serde_json::json; use super::{ - CommandRecipe, DagNode, DiagnosticSeverity, FieldPresence, FieldRole, InferencePolicy, - JsonObject, NodeClass, NodePayload, NonEmptyText, ProjectFieldSpec, ProjectSchema, + CommandRecipe, DagNode, DiagnosticSeverity, FieldPresence, FieldRole, FieldValueType, + InferencePolicy, JsonObject, NodeClass, NodePayload, NonEmptyText, ProjectFieldSpec, + ProjectSchema, }; use crate::CoreError; @@ -661,6 +805,7 @@ mod tests { severity: DiagnosticSeverity::Warning, role: FieldRole::ProjectionGate, inference_policy: InferencePolicy::ManualOnly, + value_type: None, }], }; let payload = NodePayload::with_schema(schema.schema_ref(), JsonObject::new()); @@ -672,6 +817,33 @@ mod tests { Ok(()) } + #[test] + fn schema_validation_warns_on_type_mismatch() -> Result<(), CoreError> { + let schema = ProjectSchema { + namespace: NonEmptyText::new("local.libgrid")?, + version: 1, + fields: vec![ProjectFieldSpec { + name: NonEmptyText::new("improvement")?, + node_classes: BTreeSet::from([NodeClass::Analysis]), + presence: FieldPresence::Recommended, + severity: DiagnosticSeverity::Warning, + role: FieldRole::RenderOnly, + inference_policy: InferencePolicy::ManualOnly, + value_type: Some(FieldValueType::Numeric), + }], + }; + let payload = NodePayload::with_schema( + schema.schema_ref(), + JsonObject::from_iter([("improvement".to_owned(), json!("not a number"))]), + ); + let diagnostics = schema.validate_node(NodeClass::Analysis, &payload); + + assert_eq!(diagnostics.admission, super::AdmissionState::Admitted); + assert_eq!(diagnostics.items.len(), 1); + assert_eq!(diagnostics.items[0].code, "type.improvement"); + Ok(()) + } + #[test] fn research_nodes_default_to_off_path() -> Result<(), CoreError> { let payload = NodePayload { -- cgit v1.2.3