swarm repositories / source
aboutsummaryrefslogtreecommitdiff
path: root/crates/fidget-spinner-cli/src/mcp
diff options
context:
space:
mode:
authormain <main@swarm.moe>2026-03-21 19:19:49 -0400
committermain <main@swarm.moe>2026-03-21 19:19:49 -0400
commite15fd4966e21bd8d31dbf580ede8a309c994816d (patch)
tree691d38d549959a59c02b982dd96cab9542dc3d85 /crates/fidget-spinner-cli/src/mcp
parent3a523c3c8ac1bf9094dbe65a6f53b71085438c0c (diff)
downloadfidget_spinner-main.zip
Sharpen frontier grounding and experiment comparatorsHEADmain
Diffstat (limited to 'crates/fidget-spinner-cli/src/mcp')
-rw-r--r--crates/fidget-spinner-cli/src/mcp/catalog.rs55
-rw-r--r--crates/fidget-spinner-cli/src/mcp/projection.rs67
-rw-r--r--crates/fidget-spinner-cli/src/mcp/service.rs241
3 files changed, 313 insertions, 50 deletions
diff --git a/crates/fidget-spinner-cli/src/mcp/catalog.rs b/crates/fidget-spinner-cli/src/mcp/catalog.rs
index d6c8171..e741e09 100644
--- a/crates/fidget-spinner-cli/src/mcp/catalog.rs
+++ b/crates/fidget-spinner-cli/src/mcp/catalog.rs
@@ -96,8 +96,8 @@ const TOOL_SPECS: &[ToolSpec] = &[
replay: ReplayContract::Convergent,
},
ToolSpec {
- name: "frontier.brief.update",
- description: "Replace or patch the singleton frontier brief.",
+ name: "frontier.update",
+ description: "Patch frontier objective and grounding state.",
dispatch: DispatchTarget::Worker,
replay: ReplayContract::NeverReplay,
},
@@ -168,6 +168,12 @@ const TOOL_SPECS: &[ToolSpec] = &[
replay: ReplayContract::NeverReplay,
},
ToolSpec {
+ name: "experiment.nearest",
+ description: "Find the nearest accepted, kept, rejected, and champion comparators for one slice.",
+ dispatch: DispatchTarget::Worker,
+ replay: ReplayContract::Convergent,
+ },
+ ToolSpec {
name: "experiment.history",
description: "Read the revision history for one experiment.",
dispatch: DispatchTarget::Worker,
@@ -353,7 +359,7 @@ fn tool_input_schema(name: &str) -> Value {
&[("frontier", selector_schema("Frontier UUID or slug."))],
&["frontier"],
),
- "frontier.brief.update" => object_schema(
+ "frontier.update" => object_schema(
&[
("frontier", selector_schema("Frontier UUID or slug.")),
(
@@ -361,6 +367,10 @@ fn tool_input_schema(name: &str) -> Value {
integer_schema("Optimistic concurrency guard."),
),
(
+ "objective",
+ string_schema("Optional replacement frontier objective."),
+ ),
+ (
"situation",
nullable_string_schema("Optional frontier situation text."),
),
@@ -369,6 +379,10 @@ fn tool_input_schema(name: &str) -> Value {
"unknowns",
string_array_schema("Ordered frontier unknowns."),
),
+ (
+ "scoreboard_metric_keys",
+ string_array_schema("Ordered frontier scoreboard metric keys."),
+ ),
],
&["frontier"],
),
@@ -517,6 +531,36 @@ fn tool_input_schema(name: &str) -> Value {
"rationale",
],
),
+ "experiment.nearest" => object_schema(
+ &[
+ (
+ "frontier",
+ selector_schema("Optional frontier UUID or slug."),
+ ),
+ (
+ "hypothesis",
+ selector_schema("Optional hypothesis UUID or slug."),
+ ),
+ (
+ "experiment",
+ selector_schema("Optional experiment UUID or slug used as an anchor."),
+ ),
+ (
+ "metric",
+ string_schema("Optional metric key used to choose the champion."),
+ ),
+ ("dimensions", run_dimensions_schema()),
+ ("tags", string_array_schema("Require all listed tags.")),
+ (
+ "order",
+ enum_string_schema(
+ &["asc", "desc"],
+ "Optional explicit champion ranking direction.",
+ ),
+ ),
+ ],
+ &[],
+ ),
"artifact.record" => object_schema(
&[
(
@@ -631,7 +675,10 @@ fn tool_input_schema(name: &str) -> Value {
),
(
"scope",
- enum_string_schema(&["live", "visible", "all"], "Registry slice to enumerate."),
+ enum_string_schema(
+ &["live", "scoreboard", "visible", "all"],
+ "Registry slice to enumerate.",
+ ),
),
],
&[],
diff --git a/crates/fidget-spinner-cli/src/mcp/projection.rs b/crates/fidget-spinner-cli/src/mcp/projection.rs
index a36e915..c93d3ec 100644
--- a/crates/fidget-spinner-cli/src/mcp/projection.rs
+++ b/crates/fidget-spinner-cli/src/mcp/projection.rs
@@ -6,10 +6,10 @@ use fidget_spinner_core::{
RunDimensionValue, TagRecord,
};
use fidget_spinner_store_sqlite::{
- ArtifactDetail, ArtifactSummary, EntityHistoryEntry, ExperimentDetail, ExperimentSummary,
- FrontierOpenProjection, FrontierSummary, HypothesisCurrentState, HypothesisDetail,
- MetricBestEntry, MetricKeySummary, MetricObservationSummary, ProjectStore, StoreError,
- VertexSummary,
+ ArtifactDetail, ArtifactSummary, EntityHistoryEntry, ExperimentDetail, ExperimentNearestHit,
+ ExperimentNearestResult, ExperimentSummary, FrontierOpenProjection, FrontierSummary,
+ HypothesisCurrentState, HypothesisDetail, MetricBestEntry, MetricKeySummary,
+ MetricObservationSummary, ProjectStore, StoreError, VertexSummary,
};
use libmcp::{
ProjectionError, SelectorProjection, StructuredProjection, SurfaceKind, SurfacePolicy,
@@ -56,6 +56,7 @@ pub(crate) struct FrontierBriefProjection {
pub(crate) situation: Option<String>,
pub(crate) roadmap: Vec<RoadmapItemProjection>,
pub(crate) unknowns: Vec<String>,
+ pub(crate) scoreboard_metric_keys: Vec<String>,
pub(crate) revision: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) updated_at: Option<TimestampText>,
@@ -106,6 +107,7 @@ pub(crate) struct FrontierListOutput {
pub(crate) struct FrontierOpenOutput {
pub(crate) frontier: FrontierOpenFrontierProjection,
pub(crate) active_tags: Vec<String>,
+ pub(crate) scoreboard_metrics: Vec<MetricKeySummaryProjection>,
pub(crate) active_metric_keys: Vec<MetricKeySummaryProjection>,
pub(crate) active_hypotheses: Vec<HypothesisCurrentStateProjection>,
pub(crate) open_experiments: Vec<ExperimentSummaryProjection>,
@@ -519,6 +521,32 @@ pub(crate) struct MetricBestOutput {
}
#[derive(Clone, Serialize)]
+pub(crate) struct ExperimentNearestHitProjection {
+ pub(crate) experiment: ExperimentSummaryProjection,
+ pub(crate) hypothesis: HypothesisSummaryProjection,
+ pub(crate) dimensions: BTreeMap<String, Value>,
+ pub(crate) reasons: Vec<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) metric_value: Option<MetricObservationSummaryProjection>,
+}
+
+#[derive(Clone, Serialize, libmcp::ToolProjection)]
+#[libmcp(kind = "read")]
+pub(crate) struct ExperimentNearestOutput {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) metric: Option<MetricKeySummaryProjection>,
+ pub(crate) target_dimensions: BTreeMap<String, Value>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) accepted: Option<ExperimentNearestHitProjection>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) kept: Option<ExperimentNearestHitProjection>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) rejected: Option<ExperimentNearestHitProjection>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) champion: Option<ExperimentNearestHitProjection>,
+}
+
+#[derive(Clone, Serialize)]
pub(crate) struct TagRecordProjection {
pub(crate) name: String,
pub(crate) description: String,
@@ -649,6 +677,11 @@ pub(crate) fn frontier_open(projection: &FrontierOpenProjection) -> FrontierOpen
.iter()
.map(ToString::to_string)
.collect(),
+ scoreboard_metrics: projection
+ .scoreboard_metric_keys
+ .iter()
+ .map(metric_key_summary)
+ .collect(),
active_metric_keys: projection
.active_metric_keys
.iter()
@@ -863,6 +896,17 @@ pub(crate) fn metric_best(entries: &[MetricBestEntry]) -> MetricBestOutput {
}
}
+pub(crate) fn experiment_nearest(result: &ExperimentNearestResult) -> ExperimentNearestOutput {
+ ExperimentNearestOutput {
+ metric: result.metric.as_ref().map(metric_key_summary),
+ target_dimensions: dimension_map(&result.target_dimensions),
+ accepted: result.accepted.as_ref().map(experiment_nearest_hit),
+ kept: result.kept.as_ref().map(experiment_nearest_hit),
+ rejected: result.rejected.as_ref().map(experiment_nearest_hit),
+ champion: result.champion.as_ref().map(experiment_nearest_hit),
+ }
+}
+
pub(crate) fn tag_record(tag: &TagRecord) -> TagRecordOutput {
TagRecordOutput {
record: tag_record_projection(tag),
@@ -963,6 +1007,11 @@ fn frontier_brief_projection(
situation: brief.situation.as_ref().map(ToString::to_string),
roadmap,
unknowns: brief.unknowns.iter().map(ToString::to_string).collect(),
+ scoreboard_metric_keys: brief
+ .scoreboard_metric_keys
+ .iter()
+ .map(ToString::to_string)
+ .collect(),
revision: brief.revision,
updated_at: brief.updated_at.map(timestamp_value),
}
@@ -1142,6 +1191,16 @@ fn metric_best_entry(entry: &MetricBestEntry) -> MetricBestEntryProjection {
}
}
+fn experiment_nearest_hit(hit: &ExperimentNearestHit) -> ExperimentNearestHitProjection {
+ ExperimentNearestHitProjection {
+ experiment: experiment_summary(&hit.experiment),
+ hypothesis: hypothesis_summary(&hit.hypothesis),
+ dimensions: dimension_map(&hit.dimensions),
+ reasons: hit.reasons.iter().map(ToString::to_string).collect(),
+ metric_value: hit.metric_value.as_ref().map(metric_observation_summary),
+ }
+}
+
fn metric_observation_summary(
metric: &MetricObservationSummary,
) -> MetricObservationSummaryProjection {
diff --git a/crates/fidget-spinner-cli/src/mcp/service.rs b/crates/fidget-spinner-cli/src/mcp/service.rs
index 7c649aa..70f4751 100644
--- a/crates/fidget-spinner-cli/src/mcp/service.rs
+++ b/crates/fidget-spinner-cli/src/mcp/service.rs
@@ -14,11 +14,11 @@ use fidget_spinner_core::{
use fidget_spinner_store_sqlite::{
AttachmentSelector, CloseExperimentRequest, CreateArtifactRequest, CreateFrontierRequest,
CreateHypothesisRequest, DefineMetricRequest, DefineRunDimensionRequest, EntityHistoryEntry,
- ExperimentOutcomePatch, FrontierOpenProjection, FrontierRoadmapItemDraft, FrontierSummary,
- ListArtifactsQuery, ListExperimentsQuery, ListHypothesesQuery, MetricBestEntry,
- MetricBestQuery, MetricKeySummary, MetricKeysQuery, MetricRankOrder, MetricScope,
- OpenExperimentRequest, ProjectStatus, ProjectStore, StoreError, TextPatch,
- UpdateArtifactRequest, UpdateExperimentRequest, UpdateFrontierBriefRequest,
+ ExperimentNearestQuery, ExperimentOutcomePatch, FrontierOpenProjection,
+ FrontierRoadmapItemDraft, FrontierSummary, ListArtifactsQuery, ListExperimentsQuery,
+ ListHypothesesQuery, MetricBestEntry, MetricBestQuery, MetricKeySummary, MetricKeysQuery,
+ MetricRankOrder, MetricScope, OpenExperimentRequest, ProjectStatus, ProjectStore, StoreError,
+ TextPatch, UpdateArtifactRequest, UpdateExperimentRequest, UpdateFrontierRequest,
UpdateHypothesisRequest, VertexSelector, VertexSummary,
};
use serde::Deserialize;
@@ -137,44 +137,58 @@ impl WorkerService {
let args = deserialize::<FrontierSelectorArgs>(arguments)?;
frontier_open_output(&lift!(self.store.frontier_open(&args.frontier)), &operation)?
}
- "frontier.brief.update" => {
- let args = deserialize::<FrontierBriefUpdateArgs>(arguments)?;
+ "frontier.update" => {
+ let args = deserialize::<FrontierUpdateArgs>(arguments)?;
let frontier = lift!(
- self.store
- .update_frontier_brief(UpdateFrontierBriefRequest {
- frontier: args.frontier,
- expected_revision: args.expected_revision,
- situation: nullable_text_patch_from_wire(args.situation, &operation)?,
- roadmap: args
- .roadmap
- .map(|items| {
- items
- .into_iter()
- .map(|item| {
- Ok(FrontierRoadmapItemDraft {
- rank: item.rank,
- hypothesis: item.hypothesis,
- summary: item
- .summary
- .map(NonEmptyText::new)
- .transpose()
- .map_err(store_fault(&operation))?,
- })
+ self.store.update_frontier(UpdateFrontierRequest {
+ frontier: args.frontier,
+ expected_revision: args.expected_revision,
+ objective: args
+ .objective
+ .map(NonEmptyText::new)
+ .transpose()
+ .map_err(store_fault(&operation))?,
+ situation: nullable_text_patch_from_wire(args.situation, &operation)?,
+ roadmap: args
+ .roadmap
+ .map(|items| {
+ items
+ .into_iter()
+ .map(|item| {
+ Ok(FrontierRoadmapItemDraft {
+ rank: item.rank,
+ hypothesis: item.hypothesis,
+ summary: item
+ .summary
+ .map(NonEmptyText::new)
+ .transpose()
+ .map_err(store_fault(&operation))?,
})
- .collect::<Result<Vec<_>, FaultRecord>>()
- })
- .transpose()?,
- unknowns: args
- .unknowns
- .map(|items| {
- items
- .into_iter()
- .map(NonEmptyText::new)
- .collect::<Result<Vec<_>, _>>()
- .map_err(store_fault(&operation))
- })
- .transpose()?,
- })
+ })
+ .collect::<Result<Vec<_>, FaultRecord>>()
+ })
+ .transpose()?,
+ unknowns: args
+ .unknowns
+ .map(|items| {
+ items
+ .into_iter()
+ .map(NonEmptyText::new)
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(store_fault(&operation))
+ })
+ .transpose()?,
+ scoreboard_metric_keys: args
+ .scoreboard_metric_keys
+ .map(|items| {
+ items
+ .into_iter()
+ .map(NonEmptyText::new)
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(store_fault(&operation))
+ })
+ .transpose()?,
+ })
);
frontier_record_output(&self.store, &frontier, &operation)?
}
@@ -366,6 +380,32 @@ impl WorkerService {
);
experiment_record_output(&experiment, &operation)?
}
+ "experiment.nearest" => {
+ let args = deserialize::<ExperimentNearestArgs>(arguments)?;
+ experiment_nearest_output(
+ &lift!(
+ self.store.experiment_nearest(ExperimentNearestQuery {
+ frontier: args.frontier,
+ hypothesis: args.hypothesis,
+ experiment: args.experiment,
+ metric: args
+ .metric
+ .map(NonEmptyText::new)
+ .transpose()
+ .map_err(store_fault(&operation))?,
+ dimensions: dimension_map_from_wire(args.dimensions)?,
+ tags: args
+ .tags
+ .map(tags_to_set)
+ .transpose()
+ .map_err(store_fault(&operation))?
+ .unwrap_or_default(),
+ order: args.order,
+ })
+ ),
+ &operation,
+ )?
+ }
"experiment.history" => {
let args = deserialize::<ExperimentSelectorArgs>(arguments)?;
history_output(
@@ -583,12 +623,14 @@ struct FrontierSelectorArgs {
}
#[derive(Debug, Deserialize)]
-struct FrontierBriefUpdateArgs {
+struct FrontierUpdateArgs {
frontier: String,
expected_revision: Option<u64>,
+ objective: Option<String>,
situation: Option<NullableStringArg>,
roadmap: Option<Vec<FrontierRoadmapItemWire>>,
unknowns: Option<Vec<String>>,
+ scoreboard_metric_keys: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
@@ -686,6 +728,17 @@ struct ExperimentCloseArgs {
}
#[derive(Debug, Deserialize)]
+struct ExperimentNearestArgs {
+ frontier: Option<String>,
+ hypothesis: Option<String>,
+ experiment: Option<String>,
+ metric: Option<String>,
+ dimensions: Option<Map<String, Value>>,
+ tags: Option<Vec<String>>,
+ order: Option<MetricRankOrder>,
+}
+
+#[derive(Debug, Deserialize)]
struct ExperimentOutcomeWire {
backend: ExecutionBackend,
command: CommandRecipe,
@@ -835,6 +888,7 @@ where
| StoreError::UnknownRoadmapHypothesis(_)
| StoreError::ManualExperimentRequiresCommand
| StoreError::MetricOrderRequired { .. }
+ | StoreError::MetricScopeRequiresFrontier { .. }
| StoreError::UnknownDimensionFilter(_)
| StoreError::DuplicateTag(_)
| StoreError::DuplicateMetricDefinition(_)
@@ -1005,6 +1059,14 @@ fn json_value_to_dimension(value: Value) -> Result<RunDimensionValue, FaultRecor
}
}
+fn run_dimension_value_text(value: &RunDimensionValue) -> String {
+ match value {
+ RunDimensionValue::String(value) | RunDimensionValue::Timestamp(value) => value.to_string(),
+ RunDimensionValue::Numeric(value) => value.to_string(),
+ RunDimensionValue::Boolean(value) => value.to_string(),
+ }
+}
+
fn project_status_output(
status: &ProjectStatus,
operation: &str,
@@ -1144,6 +1206,18 @@ fn frontier_record_output(
.join("; ")
));
}
+ if !frontier.brief.scoreboard_metric_keys.is_empty() {
+ lines.push(format!(
+ "scoreboard metrics: {}",
+ frontier
+ .brief
+ .scoreboard_metric_keys
+ .iter()
+ .map(ToString::to_string)
+ .collect::<Vec<_>>()
+ .join(", ")
+ ));
+ }
projected_tool_output(
&projection,
lines.join("\n"),
@@ -1187,6 +1261,17 @@ fn frontier_open_output(
.join(", ")
));
}
+ if !projection.scoreboard_metric_keys.is_empty() {
+ lines.push(format!(
+ "scoreboard metrics: {}",
+ projection
+ .scoreboard_metric_keys
+ .iter()
+ .map(|metric| metric.key.to_string())
+ .collect::<Vec<_>>()
+ .join(", ")
+ ));
+ }
if !projection.active_hypotheses.is_empty() {
lines.push("active hypotheses:".to_owned());
for state in &projection.active_hypotheses {
@@ -1567,6 +1652,71 @@ fn metric_best_output(
)
}
+fn experiment_nearest_output(
+ result: &fidget_spinner_store_sqlite::ExperimentNearestResult,
+ operation: &str,
+) -> Result<ToolOutput, FaultRecord> {
+ let projection = projection::experiment_nearest(result);
+ let mut lines = Vec::new();
+ if !result.target_dimensions.is_empty() {
+ lines.push(format!(
+ "target slice: {}",
+ result
+ .target_dimensions
+ .iter()
+ .map(|(key, value)| format!("{key}={}", run_dimension_value_text(value)))
+ .collect::<Vec<_>>()
+ .join(", ")
+ ));
+ }
+ if let Some(metric) = result.metric.as_ref() {
+ lines.push(format!(
+ "champion metric: {} [{} {}]",
+ metric.key,
+ metric.unit.as_str(),
+ metric.objective.as_str()
+ ));
+ }
+ for (label, hit) in [
+ ("accepted", result.accepted.as_ref()),
+ ("kept", result.kept.as_ref()),
+ ("rejected", result.rejected.as_ref()),
+ ("champion", result.champion.as_ref()),
+ ] {
+ if let Some(hit) = hit {
+ let suffix = hit
+ .metric_value
+ .as_ref()
+ .map_or_else(String::new, |metric| {
+ format!(" | {}={}", metric.key, metric.value)
+ });
+ lines.push(format!(
+ "{}: {} / {}{}",
+ label, hit.experiment.slug, hit.hypothesis.slug, suffix
+ ));
+ lines.push(format!(
+ " why: {}",
+ hit.reasons
+ .iter()
+ .map(ToString::to_string)
+ .collect::<Vec<_>>()
+ .join("; ")
+ ));
+ }
+ }
+ projected_tool_output(
+ &projection,
+ if lines.is_empty() {
+ "no comparator candidates".to_owned()
+ } else {
+ lines.join("\n")
+ },
+ None,
+ FaultStage::Worker,
+ operation,
+ )
+}
+
fn run_dimension_definition_output(
dimension: &fidget_spinner_core::RunDimensionDefinition,
operation: &str,
@@ -1704,6 +1854,7 @@ mod legacy_projection_values {
"situation": frontier.brief.situation,
"roadmap": roadmap,
"unknowns": frontier.brief.unknowns,
+ "scoreboard_metric_keys": frontier.brief.scoreboard_metric_keys,
"revision": frontier.brief.revision,
"updated_at": frontier.brief.updated_at.map(timestamp_value),
},
@@ -1749,11 +1900,17 @@ mod legacy_projection_values {
"situation": projection.frontier.brief.situation,
"roadmap": roadmap,
"unknowns": projection.frontier.brief.unknowns,
+ "scoreboard_metric_keys": projection.frontier.brief.scoreboard_metric_keys,
"revision": projection.frontier.brief.revision,
"updated_at": projection.frontier.brief.updated_at.map(timestamp_value),
},
},
"active_tags": projection.active_tags,
+ "scoreboard_metrics": projection
+ .scoreboard_metric_keys
+ .iter()
+ .map(metric_key_summary_value)
+ .collect::<Vec<_>>(),
"active_metric_keys": projection
.active_metric_keys
.iter()