diff options
| author | main <main@swarm.moe> | 2026-03-19 10:15:18 -0400 |
|---|---|---|
| committer | main <main@swarm.moe> | 2026-03-19 10:15:18 -0400 |
| commit | 7b9bd8b42883f82b090718175b8316296ef18236 (patch) | |
| tree | 16f2c70b0f630c7757d72a20bd90d17c2e3a8414 /crates/fidget-spinner-core | |
| download | fidget_spinner-7b9bd8b42883f82b090718175b8316296ef18236.zip | |
Initial Fidget Spinner MVP
Diffstat (limited to 'crates/fidget-spinner-core')
| -rw-r--r-- | crates/fidget-spinner-core/Cargo.toml | 19 | ||||
| -rw-r--r-- | crates/fidget-spinner-core/src/error.rs | 9 | ||||
| -rw-r--r-- | crates/fidget-spinner-core/src/id.rs | 46 | ||||
| -rw-r--r-- | crates/fidget-spinner-core/src/lib.rs | 26 | ||||
| -rw-r--r-- | crates/fidget-spinner-core/src/model.rs | 693 |
5 files changed, 793 insertions, 0 deletions
diff --git a/crates/fidget-spinner-core/Cargo.toml b/crates/fidget-spinner-core/Cargo.toml new file mode 100644 index 0000000..b472b91 --- /dev/null +++ b/crates/fidget-spinner-core/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "fidget-spinner-core" +description = "Core domain model for a local-first experimental DAG" +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true +version.workspace = true + +[dependencies] +camino.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +time.workspace = true +uuid.workspace = true + +[lints] +workspace = true diff --git a/crates/fidget-spinner-core/src/error.rs b/crates/fidget-spinner-core/src/error.rs new file mode 100644 index 0000000..8e976c7 --- /dev/null +++ b/crates/fidget-spinner-core/src/error.rs @@ -0,0 +1,9 @@ +use thiserror::Error; + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum CoreError { + #[error("text values must not be blank")] + EmptyText, + #[error("command recipes must contain at least one argv element")] + EmptyCommand, +} diff --git a/crates/fidget-spinner-core/src/id.rs b/crates/fidget-spinner-core/src/id.rs new file mode 100644 index 0000000..ea2cd5a --- /dev/null +++ b/crates/fidget-spinner-core/src/id.rs @@ -0,0 +1,46 @@ +use std::fmt::{self, Display, Formatter}; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +macro_rules! define_id { + ($name:ident) => { + #[derive( + Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, + )] + #[serde(transparent)] + pub struct $name(Uuid); + + impl $name { + #[must_use] + pub fn fresh() -> Self { + Self(Uuid::now_v7()) + } + + #[must_use] + pub fn from_uuid(uuid: Uuid) -> Self { + Self(uuid) + } + + #[must_use] + pub fn as_uuid(self) -> Uuid { + self.0 + } + } + + impl Display for $name { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(&self.0, formatter) + } + } + }; +} + +define_id!(AgentSessionId); +define_id!(AnnotationId); +define_id!(ArtifactId); +define_id!(CheckpointId); +define_id!(ExperimentId); +define_id!(FrontierId); +define_id!(NodeId); +define_id!(RunId); diff --git a/crates/fidget-spinner-core/src/lib.rs b/crates/fidget-spinner-core/src/lib.rs new file mode 100644 index 0000000..f368268 --- /dev/null +++ b/crates/fidget-spinner-core/src/lib.rs @@ -0,0 +1,26 @@ +//! Core domain types for the Fidget Spinner frontier machine. +//! +//! The product direction is intentionally local-first and agent-first: the DAG +//! is the canonical truth, while frontier state is a derived operational +//! projection over that graph. The global spine is intentionally narrow so +//! projects can carry richer payloads and annotations without fossilizing the +//! whole system into one universal schema. + +mod error; +mod id; +mod model; + +pub use crate::error::CoreError; +pub use crate::id::{ + AgentSessionId, AnnotationId, ArtifactId, CheckpointId, ExperimentId, FrontierId, NodeId, RunId, +}; +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, +}; diff --git a/crates/fidget-spinner-core/src/model.rs b/crates/fidget-spinner-core/src/model.rs new file mode 100644 index 0000000..f0d1818 --- /dev/null +++ b/crates/fidget-spinner-core/src/model.rs @@ -0,0 +1,693 @@ +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<String>) -> Result<Self, CoreError> { + 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<String>) -> Result<Self, CoreError> { + 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<String, Value>; + +#[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<PayloadSchemaRef>, + 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<NonEmptyText>, + 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<String>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct NodeDiagnostics { + pub admission: AdmissionState, + pub items: Vec<ValidationDiagnostic>, +} + +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<NodeClass>, + 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<ProjectFieldSpec>, +} + +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<FrontierId>, + pub archived: bool, + pub title: NonEmptyText, + pub summary: Option<NonEmptyText>, + pub payload: NodePayload, + pub annotations: Vec<NodeAnnotation>, + pub diagnostics: NodeDiagnostics, + pub agent_session_id: Option<AgentSessionId>, + pub created_at: OffsetDateTime, + pub updated_at: OffsetDateTime, +} + +impl DagNode { + #[must_use] + pub fn new( + class: NodeClass, + frontier_id: Option<FrontierId>, + title: NonEmptyText, + summary: Option<NonEmptyText>, + 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<NonEmptyText>, + pub produced_by_run: Option<RunId>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CodeSnapshotRef { + pub repo_root: Utf8PathBuf, + pub worktree_root: Utf8PathBuf, + pub worktree_name: Option<NonEmptyText>, + pub head_commit: Option<GitCommitHash>, + pub dirty_paths: BTreeSet<Utf8PathBuf>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CheckpointSnapshotRef { + pub repo_root: Utf8PathBuf, + pub worktree_root: Utf8PathBuf, + pub worktree_name: Option<NonEmptyText>, + pub commit_hash: GitCommitHash, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CommandRecipe { + pub working_directory: Utf8PathBuf, + pub argv: Vec<NonEmptyText>, + pub env: BTreeMap<String, String>, +} + +impl CommandRecipe { + pub fn new( + working_directory: Utf8PathBuf, + argv: Vec<NonEmptyText>, + env: BTreeMap<String, String>, + ) -> Result<Self, CoreError> { + 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<NonEmptyText>, + pub primary_metric: MetricSpec, + pub supporting_metrics: BTreeSet<MetricSpec>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct FrontierContract { + pub objective: NonEmptyText, + pub evaluation: EvaluationProtocol, + pub promotion_criteria: Vec<NonEmptyText>, +} + +#[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<FrontierId>, + pub status: RunStatus, + pub backend: ExecutionBackend, + pub code_snapshot: Option<CodeSnapshotRef>, + pub benchmark_suite: Option<NonEmptyText>, + pub command: CommandRecipe, + pub started_at: Option<OffsetDateTime>, + pub finished_at: Option<OffsetDateTime>, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct ExperimentResult { + pub benchmark_suite: NonEmptyText, + pub primary_metric: MetricObservation, + pub supporting_metrics: Vec<MetricObservation>, + pub benchmark_bundle: Option<ArtifactId>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct FrontierNote { + pub summary: NonEmptyText, + pub next_hypotheses: Vec<NonEmptyText>, +} + +#[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<NodeId>, + 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<CheckpointId>, + pub candidate_checkpoint_ids: BTreeSet<CheckpointId>, + 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(()) + } +} |