use std::collections::BTreeMap; use std::fmt::{self, Display, Formatter}; use camino::Utf8PathBuf; use serde::{Deserialize, Serialize}; use serde_json::Value; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use uuid::Uuid; use crate::{ArtifactId, CoreError, ExperimentId, FrontierId, HypothesisId}; #[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] #[serde(transparent)] pub struct NonEmptyText(String); impl NonEmptyText { pub fn new(value: impl Into) -> Result { let value = value.into(); if value.trim().is_empty() { return Err(CoreError::EmptyText); } Ok(Self(value)) } #[must_use] pub fn as_str(&self) -> &str { &self.0 } } impl Display for NonEmptyText { fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { formatter.write_str(&self.0) } } #[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) } } #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] pub struct Slug(String); impl Slug { pub fn new(value: impl Into) -> Result { let normalized = value.into().trim().to_ascii_lowercase(); if normalized.is_empty() { return Err(CoreError::EmptySlug); } if Uuid::parse_str(&normalized).is_ok() { return Err(CoreError::UuidLikeSlug(normalized)); } 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::InvalidSlug(normalized)); } if previous_was_separator { return Err(CoreError::InvalidSlug(normalized)); } Ok(Self(normalized)) } #[must_use] pub fn as_str(&self) -> &str { &self.0 } } impl TryFrom for Slug { type Error = CoreError; fn try_from(value: String) -> Result { Self::new(value) } } impl From for String { fn from(value: Slug) -> Self { value.0 } } impl Display for Slug { fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { formatter.write_str(&self.0) } } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum FrontierStatus { Exploring, Paused, Archived, } impl FrontierStatus { #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Exploring => "exploring", Self::Paused => "paused", Self::Archived => "archived", } } } #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] #[serde(rename_all = "snake_case")] pub enum MetricUnit { Seconds, Bytes, Count, Ratio, Custom, } impl MetricUnit { #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Seconds => "seconds", Self::Bytes => "bytes", Self::Count => "count", Self::Ratio => "ratio", Self::Custom => "custom", } } } #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] #[serde(rename_all = "snake_case")] pub enum OptimizationObjective { Minimize, Maximize, Target, } impl OptimizationObjective { #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Minimize => "minimize", Self::Maximize => "maximize", Self::Target => "target", } } } #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] #[serde(rename_all = "snake_case")] pub enum MetricVisibility { Canonical, Minor, Hidden, Archived, } impl MetricVisibility { #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Canonical => "canonical", Self::Minor => "minor", Self::Hidden => "hidden", Self::Archived => "archived", } } #[must_use] pub const fn is_default_visible(self) -> bool { matches!(self, Self::Canonical | Self::Minor) } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct MetricDefinition { pub key: NonEmptyText, pub unit: MetricUnit, pub objective: OptimizationObjective, pub visibility: MetricVisibility, pub description: Option, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } impl MetricDefinition { #[must_use] pub fn new( key: NonEmptyText, unit: MetricUnit, objective: OptimizationObjective, visibility: MetricVisibility, description: Option, ) -> Self { let now = OffsetDateTime::now_utc(); Self { key, unit, objective, visibility, description, created_at: now, updated_at: now, } } } #[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 as_str(self) -> &'static str { match self { Self::String => "string", Self::Numeric => "numeric", Self::Boolean => "boolean", Self::Timestamp => "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()), } } } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "snake_case", tag = "type", content = "value")] pub enum RunDimensionValue { String(NonEmptyText), Numeric(f64), Boolean(bool), Timestamp(NonEmptyText), } impl RunDimensionValue { #[must_use] pub const fn value_type(&self) -> FieldValueType { match self { Self::String(_) => FieldValueType::String, Self::Numeric(_) => FieldValueType::Numeric, Self::Boolean(_) => FieldValueType::Boolean, Self::Timestamp(_) => FieldValueType::Timestamp, } } #[must_use] pub fn as_json(&self) -> Value { match self { Self::String(value) | Self::Timestamp(value) => Value::String(value.to_string()), Self::Numeric(value) => { serde_json::Number::from_f64(*value).map_or(Value::Null, Value::Number) } Self::Boolean(value) => Value::Bool(*value), } } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct RunDimensionDefinition { pub key: NonEmptyText, pub value_type: FieldValueType, pub description: Option, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } impl RunDimensionDefinition { #[must_use] pub fn new( key: NonEmptyText, value_type: FieldValueType, description: Option, ) -> Self { let now = OffsetDateTime::now_utc(); Self { key, value_type, description, created_at: now, updated_at: now, } } } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct MetricValue { pub key: NonEmptyText, pub value: f64, } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum ExecutionBackend { Manual, LocalProcess, WorktreeProcess, SshProcess, } impl ExecutionBackend { #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Manual => "manual", Self::LocalProcess => "local_process", Self::WorktreeProcess => "worktree_process", Self::SshProcess => "ssh_process", } } } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum FrontierVerdict { Accepted, Kept, Parked, Rejected, } impl FrontierVerdict { #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Accepted => "accepted", Self::Kept => "kept", Self::Parked => "parked", Self::Rejected => "rejected", } } } #[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 CommandRecipe { #[serde(default)] pub working_directory: Option, pub argv: Vec, #[serde(default)] pub env: BTreeMap, } impl CommandRecipe { pub fn new( working_directory: Option, argv: Vec, env: BTreeMap, ) -> Result { if argv.is_empty() { return Err(CoreError::EmptyCommand); } Ok(Self { working_directory, argv, env, }) } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct FrontierRoadmapItem { pub rank: u32, pub hypothesis_id: HypothesisId, pub summary: Option, } #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] pub struct FrontierBrief { pub situation: Option, pub roadmap: Vec, pub unknowns: Vec, pub revision: u64, pub updated_at: Option, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct FrontierRecord { pub id: FrontierId, pub slug: Slug, pub label: NonEmptyText, pub objective: NonEmptyText, pub status: FrontierStatus, pub brief: FrontierBrief, pub revision: u64, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct HypothesisRecord { pub id: HypothesisId, pub slug: Slug, pub frontier_id: FrontierId, pub archived: bool, pub title: NonEmptyText, pub summary: NonEmptyText, pub body: NonEmptyText, pub tags: Vec, pub revision: u64, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum ExperimentStatus { Open, Closed, } impl ExperimentStatus { #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Open => "open", Self::Closed => "closed", } } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct ExperimentAnalysis { pub summary: NonEmptyText, pub body: NonEmptyText, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct ExperimentOutcome { pub backend: ExecutionBackend, pub command: CommandRecipe, pub dimensions: BTreeMap, pub primary_metric: MetricValue, pub supporting_metrics: Vec, pub verdict: FrontierVerdict, pub rationale: NonEmptyText, pub analysis: Option, pub closed_at: OffsetDateTime, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct ExperimentRecord { pub id: ExperimentId, pub slug: Slug, pub frontier_id: FrontierId, pub hypothesis_id: HypothesisId, pub archived: bool, pub title: NonEmptyText, pub summary: Option, pub tags: Vec, pub status: ExperimentStatus, pub outcome: Option, pub revision: u64, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum ArtifactKind { Document, Link, Log, Table, Plot, Dump, Binary, Other, } impl ArtifactKind { #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Document => "document", Self::Link => "link", Self::Log => "log", Self::Table => "table", Self::Plot => "plot", Self::Dump => "dump", Self::Binary => "binary", Self::Other => "other", } } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct ArtifactRecord { pub id: ArtifactId, pub slug: Slug, pub kind: ArtifactKind, pub label: NonEmptyText, pub summary: Option, pub locator: NonEmptyText, pub media_type: Option, pub revision: u64, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] #[serde(rename_all = "snake_case")] pub enum VertexKind { Hypothesis, Experiment, } impl VertexKind { #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Hypothesis => "hypothesis", Self::Experiment => "experiment", } } } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(tag = "kind", content = "id", rename_all = "snake_case")] pub enum VertexRef { Hypothesis(HypothesisId), Experiment(ExperimentId), } impl VertexRef { #[must_use] pub const fn kind(self) -> VertexKind { match self { Self::Hypothesis(_) => VertexKind::Hypothesis, Self::Experiment(_) => VertexKind::Experiment, } } #[must_use] pub fn opaque_id(self) -> String { match self { Self::Hypothesis(id) => id.to_string(), Self::Experiment(id) => id.to_string(), } } } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum AttachmentTargetKind { Frontier, Hypothesis, Experiment, } impl AttachmentTargetKind { #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Frontier => "frontier", Self::Hypothesis => "hypothesis", Self::Experiment => "experiment", } } } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(tag = "kind", content = "id", rename_all = "snake_case")] pub enum AttachmentTargetRef { Frontier(FrontierId), Hypothesis(HypothesisId), Experiment(ExperimentId), } impl AttachmentTargetRef { #[must_use] pub const fn kind(self) -> AttachmentTargetKind { match self { Self::Frontier(_) => AttachmentTargetKind::Frontier, Self::Hypothesis(_) => AttachmentTargetKind::Hypothesis, Self::Experiment(_) => AttachmentTargetKind::Experiment, } } #[must_use] pub fn opaque_id(self) -> String { match self { Self::Frontier(id) => id.to_string(), Self::Hypothesis(id) => id.to_string(), Self::Experiment(id) => id.to_string(), } } }