use std::collections::{BTreeMap, BTreeSet}; use std::fmt::{self, Display, Formatter}; use camino::Utf8PathBuf; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use time::OffsetDateTime; use crate::{ AgentSessionId, AnnotationId, ArtifactId, CheckpointId, CoreError, ExperimentId, FrontierId, NodeId, RunId, }; #[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, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] #[serde(transparent)] pub struct GitCommitHash(NonEmptyText); impl GitCommitHash { pub fn new(value: impl Into) -> Result { NonEmptyText::new(value).map(Self) } #[must_use] pub fn as_str(&self) -> &str { self.0.as_str() } } impl Display for GitCommitHash { fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { Display::fmt(&self.0, formatter) } } pub type JsonObject = Map; #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] pub enum NodeClass { Contract, Change, Run, Analysis, Decision, Research, Enabling, Note, } impl NodeClass { #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Contract => "contract", Self::Change => "change", Self::Run => "run", Self::Analysis => "analysis", Self::Decision => "decision", Self::Research => "research", Self::Enabling => "enabling", Self::Note => "note", } } #[must_use] pub const fn default_track(self) -> NodeTrack { match self { Self::Contract | Self::Change | Self::Run | Self::Analysis | Self::Decision => { NodeTrack::CorePath } Self::Research | Self::Enabling | Self::Note => NodeTrack::OffPath, } } } impl Display for NodeClass { fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { formatter.write_str(self.as_str()) } } #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] pub enum NodeTrack { CorePath, OffPath, } #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] pub enum AnnotationVisibility { HiddenByDefault, Visible, } #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] pub enum DiagnosticSeverity { Error, Warning, Info, } #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] pub enum FieldPresence { Required, Recommended, Optional, } #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] pub enum FieldRole { Index, ProjectionGate, RenderOnly, Opaque, } #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] pub enum InferencePolicy { ManualOnly, ModelMayInfer, } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] pub enum FrontierStatus { Exploring, Paused, Saturated, Archived, } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] pub enum CheckpointDisposition { Champion, FrontierCandidate, Baseline, DeadEnd, Archived, } #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] pub enum MetricUnit { Seconds, Bytes, Count, Ratio, Custom, } #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] pub enum OptimizationObjective { Minimize, Maximize, Target, } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] pub enum RunStatus { Queued, Running, Succeeded, Failed, Cancelled, } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] pub enum ExecutionBackend { LocalProcess, WorktreeProcess, SshProcess, } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] pub enum FrontierVerdict { PromoteToChampion, KeepOnFrontier, RevertToChampion, ArchiveDeadEnd, NeedsMoreEvidence, } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] pub enum AdmissionState { Admitted, Rejected, } #[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] pub struct PayloadSchemaRef { pub namespace: NonEmptyText, pub version: u32, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct NodePayload { pub schema: Option, pub fields: JsonObject, } impl NodePayload { #[must_use] pub fn empty() -> Self { Self { schema: None, fields: JsonObject::new(), } } #[must_use] pub fn with_schema(schema: PayloadSchemaRef, fields: JsonObject) -> Self { Self { schema: Some(schema), fields, } } #[must_use] pub fn field(&self, name: &str) -> Option<&Value> { self.fields.get(name) } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct NodeAnnotation { pub id: AnnotationId, pub visibility: AnnotationVisibility, pub label: Option, pub body: NonEmptyText, pub created_at: OffsetDateTime, } impl NodeAnnotation { #[must_use] pub fn hidden(body: NonEmptyText) -> Self { Self { id: AnnotationId::fresh(), visibility: AnnotationVisibility::HiddenByDefault, label: None, body, created_at: OffsetDateTime::now_utc(), } } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct ValidationDiagnostic { pub severity: DiagnosticSeverity, pub code: String, pub message: NonEmptyText, pub field_name: Option, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct NodeDiagnostics { pub admission: AdmissionState, pub items: Vec, } impl NodeDiagnostics { #[must_use] pub const fn admitted() -> Self { Self { admission: AdmissionState::Admitted, items: Vec::new(), } } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct ProjectFieldSpec { pub name: NonEmptyText, pub node_classes: BTreeSet, pub presence: FieldPresence, pub severity: DiagnosticSeverity, pub role: FieldRole, pub inference_policy: InferencePolicy, } impl ProjectFieldSpec { #[must_use] pub fn applies_to(&self, class: NodeClass) -> bool { self.node_classes.is_empty() || self.node_classes.contains(&class) } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct ProjectSchema { pub namespace: NonEmptyText, pub version: u32, pub fields: Vec, } impl ProjectSchema { #[must_use] pub fn default_with_namespace(namespace: NonEmptyText) -> Self { Self { namespace, version: 1, fields: Vec::new(), } } #[must_use] pub fn schema_ref(&self) -> PayloadSchemaRef { PayloadSchemaRef { namespace: self.namespace.clone(), version: self.version, } } #[must_use] pub fn validate_node(&self, class: NodeClass, payload: &NodePayload) -> NodeDiagnostics { let items = self .fields .iter() .filter(|field| field.applies_to(class)) .filter_map(|field| { let is_missing = payload.field(field.name.as_str()).is_none(); if !is_missing || field.presence == FieldPresence::Optional { return None; } Some(ValidationDiagnostic { severity: field.severity, code: format!("missing.{}", field.name.as_str()), message: validation_message(format!( "missing project payload field `{}`", field.name.as_str() )), field_name: Some(field.name.as_str().to_owned()), }) }) .collect(); NodeDiagnostics { admission: AdmissionState::Admitted, items, } } } fn validation_message(value: String) -> NonEmptyText { match NonEmptyText::new(value) { Ok(message) => message, Err(_) => unreachable!("validation diagnostics are never empty"), } } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct DagNode { pub id: NodeId, pub class: NodeClass, pub track: NodeTrack, pub frontier_id: Option, pub archived: bool, pub title: NonEmptyText, pub summary: Option, pub payload: NodePayload, pub annotations: Vec, pub diagnostics: NodeDiagnostics, pub agent_session_id: Option, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } impl DagNode { #[must_use] pub fn new( class: NodeClass, frontier_id: Option, title: NonEmptyText, summary: Option, payload: NodePayload, diagnostics: NodeDiagnostics, ) -> Self { let now = OffsetDateTime::now_utc(); Self { id: NodeId::fresh(), class, track: class.default_track(), frontier_id, archived: false, title, summary, payload, annotations: Vec::new(), diagnostics, agent_session_id: None, created_at: now, updated_at: now, } } #[must_use] pub fn is_core_path(&self) -> bool { self.track == NodeTrack::CorePath } } #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] pub enum EdgeKind { Lineage, Evidence, Comparison, Supersedes, Annotation, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct DagEdge { pub source_id: NodeId, pub target_id: NodeId, pub kind: EdgeKind, } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] pub enum ArtifactKind { Note, Patch, BenchmarkBundle, MetricSeries, Table, Plot, Log, Binary, Checkpoint, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct ArtifactRef { pub id: ArtifactId, pub kind: ArtifactKind, pub label: NonEmptyText, pub path: Utf8PathBuf, pub media_type: Option, pub produced_by_run: Option, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct CodeSnapshotRef { pub repo_root: Utf8PathBuf, pub worktree_root: Utf8PathBuf, pub worktree_name: Option, pub head_commit: Option, pub dirty_paths: BTreeSet, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct CheckpointSnapshotRef { pub repo_root: Utf8PathBuf, pub worktree_root: Utf8PathBuf, pub worktree_name: Option, pub commit_hash: GitCommitHash, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct CommandRecipe { pub working_directory: Utf8PathBuf, pub argv: Vec, pub env: BTreeMap, } impl CommandRecipe { pub fn new( working_directory: Utf8PathBuf, argv: Vec, env: BTreeMap, ) -> Result { if argv.is_empty() { return Err(CoreError::EmptyCommand); } Ok(Self { working_directory, argv, env, }) } } #[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] pub struct MetricSpec { pub metric_key: NonEmptyText, pub unit: MetricUnit, pub objective: OptimizationObjective, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct EvaluationProtocol { pub benchmark_suites: BTreeSet, pub primary_metric: MetricSpec, pub supporting_metrics: BTreeSet, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct FrontierContract { pub objective: NonEmptyText, pub evaluation: EvaluationProtocol, pub promotion_criteria: Vec, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct MetricObservation { pub metric_key: NonEmptyText, pub unit: MetricUnit, pub objective: OptimizationObjective, pub value: f64, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct FrontierRecord { pub id: FrontierId, pub label: NonEmptyText, pub root_contract_node_id: NodeId, pub status: FrontierStatus, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } impl FrontierRecord { #[must_use] pub fn new(label: NonEmptyText, root_contract_node_id: NodeId) -> Self { Self::with_id(FrontierId::fresh(), label, root_contract_node_id) } #[must_use] pub fn with_id(id: FrontierId, label: NonEmptyText, root_contract_node_id: NodeId) -> Self { let now = OffsetDateTime::now_utc(); Self { id, label, root_contract_node_id, status: FrontierStatus::Exploring, created_at: now, updated_at: now, } } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct CheckpointRecord { pub id: CheckpointId, pub frontier_id: FrontierId, pub node_id: NodeId, pub snapshot: CheckpointSnapshotRef, pub disposition: CheckpointDisposition, pub summary: NonEmptyText, pub created_at: OffsetDateTime, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct RunRecord { pub node_id: NodeId, pub run_id: RunId, pub frontier_id: Option, pub status: RunStatus, pub backend: ExecutionBackend, pub code_snapshot: Option, pub benchmark_suite: Option, pub command: CommandRecipe, pub started_at: Option, pub finished_at: Option, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct ExperimentResult { pub benchmark_suite: NonEmptyText, pub primary_metric: MetricObservation, pub supporting_metrics: Vec, pub benchmark_bundle: Option, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct FrontierNote { pub summary: NonEmptyText, pub next_hypotheses: Vec, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct CompletedExperiment { pub id: ExperimentId, pub frontier_id: FrontierId, pub base_checkpoint_id: CheckpointId, pub candidate_checkpoint_id: CheckpointId, pub change_node_id: NodeId, pub run_node_id: NodeId, pub run_id: RunId, pub analysis_node_id: Option, pub decision_node_id: NodeId, pub result: ExperimentResult, pub note: FrontierNote, pub verdict: FrontierVerdict, pub created_at: OffsetDateTime, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct FrontierProjection { pub frontier: FrontierRecord, pub champion_checkpoint_id: Option, pub candidate_checkpoint_ids: BTreeSet, pub experiment_count: u64, } #[cfg(test)] mod tests { use std::collections::{BTreeMap, BTreeSet}; use camino::Utf8PathBuf; use serde_json::json; use super::{ CommandRecipe, DagNode, DiagnosticSeverity, FieldPresence, FieldRole, InferencePolicy, JsonObject, NodeClass, NodePayload, NonEmptyText, ProjectFieldSpec, ProjectSchema, }; use crate::CoreError; #[test] fn non_empty_text_rejects_blank_input() { let text = NonEmptyText::new(" "); assert_eq!(text, Err(CoreError::EmptyText)); } #[test] fn command_recipe_requires_argv() { let recipe = CommandRecipe::new( Utf8PathBuf::from("/tmp/worktree"), Vec::new(), BTreeMap::new(), ); assert_eq!(recipe, Err(CoreError::EmptyCommand)); } #[test] fn schema_validation_warns_without_rejecting_ingest() -> Result<(), CoreError> { let schema = ProjectSchema { namespace: NonEmptyText::new("local.libgrid")?, version: 1, fields: vec![ProjectFieldSpec { name: NonEmptyText::new("hypothesis")?, node_classes: BTreeSet::from([NodeClass::Change]), presence: FieldPresence::Required, severity: DiagnosticSeverity::Warning, role: FieldRole::ProjectionGate, inference_policy: InferencePolicy::ManualOnly, }], }; let payload = NodePayload::with_schema(schema.schema_ref(), JsonObject::new()); let diagnostics = schema.validate_node(NodeClass::Change, &payload); assert_eq!(diagnostics.admission, super::AdmissionState::Admitted); assert_eq!(diagnostics.items.len(), 1); assert_eq!(diagnostics.items[0].severity, DiagnosticSeverity::Warning); Ok(()) } #[test] fn research_nodes_default_to_off_path() -> Result<(), CoreError> { let payload = NodePayload { schema: None, fields: JsonObject::from_iter([("topic".to_owned(), json!("ideas"))]), }; let node = DagNode::new( NodeClass::Research, None, NonEmptyText::new("feature scouting")?, None, payload, super::NodeDiagnostics::admitted(), ); assert!(!node.is_core_path()); Ok(()) } }