swarm repositories / source
aboutsummaryrefslogtreecommitdiff
path: root/crates/fidget-spinner-cli/src
diff options
context:
space:
mode:
authormain <main@swarm.moe>2026-03-20 23:19:33 -0400
committermain <main@swarm.moe>2026-03-20 23:19:33 -0400
commiteb0f0f73b7da9d76ff6833757fd265725d3e4b14 (patch)
tree38d64a437cac0518caf2cca5aa4bff5984e64515 /crates/fidget-spinner-cli/src
parentae809af85f6687ae21d7e2f7140aa88354c446cc (diff)
downloadfidget_spinner-eb0f0f73b7da9d76ff6833757fd265725d3e4b14.zip
Polish metric slices and MCP time projections
Diffstat (limited to 'crates/fidget-spinner-cli/src')
-rw-r--r--crates/fidget-spinner-cli/src/mcp/output.rs15
-rw-r--r--crates/fidget-spinner-cli/src/mcp/projection.rs216
-rw-r--r--crates/fidget-spinner-cli/src/mcp/service.rs139
-rw-r--r--crates/fidget-spinner-cli/src/ui.rs476
4 files changed, 656 insertions, 190 deletions
diff --git a/crates/fidget-spinner-cli/src/mcp/output.rs b/crates/fidget-spinner-cli/src/mcp/output.rs
index 2e11e20..494fe23 100644
--- a/crates/fidget-spinner-cli/src/mcp/output.rs
+++ b/crates/fidget-spinner-cli/src/mcp/output.rs
@@ -118,21 +118,6 @@ pub(crate) fn projected_tool_output(
))
}
-pub(crate) fn fallback_tool_output(
- concise: &impl Serialize,
- full: &impl Serialize,
- kind: SurfaceKind,
- stage: FaultStage,
- operation: &str,
-) -> Result<ToolOutput, FaultRecord> {
- let projection = FallbackJsonProjection::new(concise, full, kind)
- .map_err(|error| projection_fault(error, stage, operation))?;
- let concise_text = projection
- .porcelain_projection(DetailLevel::Concise)
- .map_err(|error| projection_fault(error, stage, operation))?;
- projected_tool_output(&projection, concise_text, None, stage, operation)
-}
-
pub(crate) fn fallback_detailed_tool_output(
concise: &impl Serialize,
full: &impl Serialize,
diff --git a/crates/fidget-spinner-cli/src/mcp/projection.rs b/crates/fidget-spinner-cli/src/mcp/projection.rs
index ca89af0..a36e915 100644
--- a/crates/fidget-spinner-cli/src/mcp/projection.rs
+++ b/crates/fidget-spinner-cli/src/mcp/projection.rs
@@ -2,15 +2,18 @@ use std::collections::BTreeMap;
use fidget_spinner_core::{
AttachmentTargetRef, CommandRecipe, ExperimentAnalysis, ExperimentOutcome, FrontierBrief,
- FrontierRecord, MetricValue, NonEmptyText, RunDimensionValue,
+ FrontierRecord, MetricDefinition, MetricValue, NonEmptyText, RunDimensionDefinition,
+ RunDimensionValue, TagRecord,
};
use fidget_spinner_store_sqlite::{
- ArtifactDetail, ArtifactSummary, ExperimentDetail, ExperimentSummary, FrontierOpenProjection,
- FrontierSummary, HypothesisCurrentState, HypothesisDetail, MetricBestEntry, MetricKeySummary,
- MetricObservationSummary, ProjectStore, StoreError, VertexSummary,
+ ArtifactDetail, ArtifactSummary, EntityHistoryEntry, ExperimentDetail, ExperimentSummary,
+ FrontierOpenProjection, FrontierSummary, HypothesisCurrentState, HypothesisDetail,
+ MetricBestEntry, MetricKeySummary, MetricObservationSummary, ProjectStore, StoreError,
+ VertexSummary,
};
use libmcp::{
ProjectionError, SelectorProjection, StructuredProjection, SurfaceKind, SurfacePolicy,
+ TimestampText,
};
use serde::Serialize;
use serde_json::Value;
@@ -44,7 +47,7 @@ pub(crate) struct FrontierSummaryProjection {
pub(crate) status: String,
pub(crate) active_hypothesis_count: u64,
pub(crate) open_experiment_count: u64,
- pub(crate) updated_at: String,
+ pub(crate) updated_at: TimestampText,
}
#[derive(Clone, Serialize)]
@@ -55,7 +58,7 @@ pub(crate) struct FrontierBriefProjection {
pub(crate) unknowns: Vec<String>,
pub(crate) revision: u64,
#[serde(skip_serializing_if = "Option::is_none")]
- pub(crate) updated_at: Option<String>,
+ pub(crate) updated_at: Option<TimestampText>,
}
#[derive(Clone, Serialize)]
@@ -80,8 +83,8 @@ pub(crate) struct FrontierRecordProjection {
pub(crate) objective: String,
pub(crate) status: String,
pub(crate) revision: u64,
- pub(crate) created_at: String,
- pub(crate) updated_at: String,
+ pub(crate) created_at: TimestampText,
+ pub(crate) updated_at: TimestampText,
pub(crate) brief: FrontierBriefProjection,
}
@@ -127,7 +130,7 @@ pub(crate) struct HypothesisSummaryProjection {
pub(crate) open_experiment_count: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) latest_verdict: Option<String>,
- pub(crate) updated_at: String,
+ pub(crate) updated_at: TimestampText,
}
#[derive(Clone, Serialize)]
@@ -139,8 +142,8 @@ pub(crate) struct HypothesisRecordProjection {
pub(crate) body: String,
pub(crate) tags: Vec<String>,
pub(crate) revision: u64,
- pub(crate) created_at: String,
- pub(crate) updated_at: String,
+ pub(crate) created_at: TimestampText,
+ pub(crate) updated_at: TimestampText,
}
#[derive(Clone, Serialize)]
@@ -151,7 +154,7 @@ pub(crate) struct HypothesisReadRecordProjection {
pub(crate) summary: String,
pub(crate) tags: Vec<String>,
pub(crate) revision: u64,
- pub(crate) updated_at: String,
+ pub(crate) updated_at: TimestampText,
}
#[derive(Clone, Serialize)]
@@ -229,9 +232,9 @@ pub(crate) struct ExperimentSummaryProjection {
pub(crate) verdict: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) primary_metric: Option<MetricObservationSummaryProjection>,
- pub(crate) updated_at: String,
+ pub(crate) updated_at: TimestampText,
#[serde(skip_serializing_if = "Option::is_none")]
- pub(crate) closed_at: Option<String>,
+ pub(crate) closed_at: Option<TimestampText>,
}
#[derive(Clone, Serialize)]
@@ -246,8 +249,8 @@ pub(crate) struct ExperimentRecordProjection {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) outcome: Option<ExperimentOutcomeProjection>,
pub(crate) revision: u64,
- pub(crate) created_at: String,
- pub(crate) updated_at: String,
+ pub(crate) created_at: TimestampText,
+ pub(crate) updated_at: TimestampText,
}
#[derive(Clone, Serialize)]
@@ -262,7 +265,7 @@ pub(crate) struct ExperimentReadRecordProjection {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) verdict: Option<String>,
pub(crate) revision: u64,
- pub(crate) updated_at: String,
+ pub(crate) updated_at: TimestampText,
}
#[derive(Clone, Serialize)]
@@ -329,7 +332,7 @@ pub(crate) struct ArtifactSummaryProjection {
pub(crate) locator: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) media_type: Option<String>,
- pub(crate) updated_at: String,
+ pub(crate) updated_at: TimestampText,
}
#[derive(Clone, Serialize)]
@@ -343,8 +346,8 @@ pub(crate) struct ArtifactRecordProjection {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) media_type: Option<String>,
pub(crate) revision: u64,
- pub(crate) created_at: String,
- pub(crate) updated_at: String,
+ pub(crate) created_at: TimestampText,
+ pub(crate) updated_at: TimestampText,
}
#[derive(Clone, Serialize)]
@@ -358,7 +361,7 @@ pub(crate) struct ArtifactReadRecordProjection {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) media_type: Option<String>,
pub(crate) revision: u64,
- pub(crate) updated_at: String,
+ pub(crate) updated_at: TimestampText,
}
#[derive(Clone, Serialize)]
@@ -452,7 +455,7 @@ pub(crate) struct ExperimentOutcomeProjection {
pub(crate) rationale: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) analysis: Option<ExperimentAnalysisProjection>,
- pub(crate) closed_at: String,
+ pub(crate) closed_at: TimestampText,
}
#[derive(Clone, Serialize)]
@@ -484,7 +487,7 @@ pub(crate) struct VertexSummaryProjection {
pub(crate) title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) summary: Option<String>,
- pub(crate) updated_at: String,
+ pub(crate) updated_at: TimestampText,
}
#[derive(Clone, Serialize)]
@@ -515,6 +518,82 @@ pub(crate) struct MetricBestOutput {
pub(crate) entries: Vec<MetricBestEntryProjection>,
}
+#[derive(Clone, Serialize)]
+pub(crate) struct TagRecordProjection {
+ pub(crate) name: String,
+ pub(crate) description: String,
+ pub(crate) created_at: TimestampText,
+}
+
+#[derive(Clone, Serialize, libmcp::ToolProjection)]
+#[libmcp(kind = "mutation")]
+pub(crate) struct TagRecordOutput {
+ pub(crate) record: TagRecordProjection,
+}
+
+#[derive(Clone, Serialize, libmcp::ToolProjection)]
+#[libmcp(kind = "list")]
+pub(crate) struct TagListOutput {
+ pub(crate) count: usize,
+ pub(crate) tags: Vec<TagRecordProjection>,
+}
+
+#[derive(Clone, Serialize)]
+pub(crate) struct MetricDefinitionProjection {
+ pub(crate) key: String,
+ pub(crate) unit: String,
+ pub(crate) objective: String,
+ pub(crate) visibility: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) description: Option<String>,
+ pub(crate) created_at: TimestampText,
+ pub(crate) updated_at: TimestampText,
+}
+
+#[derive(Clone, Serialize, libmcp::ToolProjection)]
+#[libmcp(kind = "mutation")]
+pub(crate) struct MetricDefinitionOutput {
+ pub(crate) record: MetricDefinitionProjection,
+}
+
+#[derive(Clone, Serialize)]
+pub(crate) struct RunDimensionDefinitionProjection {
+ pub(crate) key: String,
+ pub(crate) value_type: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) description: Option<String>,
+ pub(crate) created_at: TimestampText,
+ pub(crate) updated_at: TimestampText,
+}
+
+#[derive(Clone, Serialize, libmcp::ToolProjection)]
+#[libmcp(kind = "mutation")]
+pub(crate) struct RunDimensionDefinitionOutput {
+ pub(crate) record: RunDimensionDefinitionProjection,
+}
+
+#[derive(Clone, Serialize, libmcp::ToolProjection)]
+#[libmcp(kind = "list")]
+pub(crate) struct RunDimensionListOutput {
+ pub(crate) count: usize,
+ pub(crate) dimensions: Vec<RunDimensionDefinitionProjection>,
+}
+
+#[derive(Clone, Serialize)]
+pub(crate) struct HistoryEntryProjection {
+ pub(crate) revision: u64,
+ pub(crate) event_kind: String,
+ pub(crate) occurred_at: TimestampText,
+ pub(crate) snapshot: Value,
+}
+
+#[derive(Clone, Serialize, libmcp::ToolProjection)]
+#[libmcp(kind = "list")]
+pub(crate) struct HistoryOutput {
+ pub(crate) count: usize,
+ pub(crate) history: Vec<HistoryEntryProjection>,
+}
+
pub(crate) fn frontier_list(frontiers: &[FrontierSummary]) -> FrontierListOutput {
FrontierListOutput {
count: frontiers.len(),
@@ -784,6 +863,50 @@ pub(crate) fn metric_best(entries: &[MetricBestEntry]) -> MetricBestOutput {
}
}
+pub(crate) fn tag_record(tag: &TagRecord) -> TagRecordOutput {
+ TagRecordOutput {
+ record: tag_record_projection(tag),
+ }
+}
+
+pub(crate) fn tag_list(tags: &[TagRecord]) -> TagListOutput {
+ TagListOutput {
+ count: tags.len(),
+ tags: tags.iter().map(tag_record_projection).collect(),
+ }
+}
+
+pub(crate) fn metric_definition(metric: &MetricDefinition) -> MetricDefinitionOutput {
+ MetricDefinitionOutput {
+ record: metric_definition_projection(metric),
+ }
+}
+
+pub(crate) fn run_dimension_definition(
+ dimension: &RunDimensionDefinition,
+) -> RunDimensionDefinitionOutput {
+ RunDimensionDefinitionOutput {
+ record: run_dimension_definition_projection(dimension),
+ }
+}
+
+pub(crate) fn run_dimension_list(dimensions: &[RunDimensionDefinition]) -> RunDimensionListOutput {
+ RunDimensionListOutput {
+ count: dimensions.len(),
+ dimensions: dimensions
+ .iter()
+ .map(run_dimension_definition_projection)
+ .collect(),
+ }
+}
+
+pub(crate) fn history(history: &[EntityHistoryEntry]) -> HistoryOutput {
+ HistoryOutput {
+ count: history.len(),
+ history: history.iter().map(history_entry_projection).collect(),
+ }
+}
+
fn frontier_summary(frontier: &FrontierSummary) -> FrontierSummaryProjection {
FrontierSummaryProjection {
slug: frontier.slug.to_string(),
@@ -969,6 +1092,47 @@ fn metric_key_summary(metric: &MetricKeySummary) -> MetricKeySummaryProjection {
}
}
+fn tag_record_projection(tag: &TagRecord) -> TagRecordProjection {
+ TagRecordProjection {
+ name: tag.name.to_string(),
+ description: tag.description.to_string(),
+ created_at: timestamp_value(tag.created_at),
+ }
+}
+
+fn metric_definition_projection(metric: &MetricDefinition) -> MetricDefinitionProjection {
+ MetricDefinitionProjection {
+ key: metric.key.to_string(),
+ unit: metric.unit.as_str().to_owned(),
+ objective: metric.objective.as_str().to_owned(),
+ visibility: metric.visibility.as_str().to_owned(),
+ description: metric.description.as_ref().map(ToString::to_string),
+ created_at: timestamp_value(metric.created_at),
+ updated_at: timestamp_value(metric.updated_at),
+ }
+}
+
+fn run_dimension_definition_projection(
+ dimension: &RunDimensionDefinition,
+) -> RunDimensionDefinitionProjection {
+ RunDimensionDefinitionProjection {
+ key: dimension.key.to_string(),
+ value_type: dimension.value_type.as_str().to_owned(),
+ description: dimension.description.as_ref().map(ToString::to_string),
+ created_at: timestamp_value(dimension.created_at),
+ updated_at: timestamp_value(dimension.updated_at),
+ }
+}
+
+fn history_entry_projection(entry: &EntityHistoryEntry) -> HistoryEntryProjection {
+ HistoryEntryProjection {
+ revision: entry.revision,
+ event_kind: entry.event_kind.to_string(),
+ occurred_at: timestamp_value(entry.occurred_at),
+ snapshot: entry.snapshot.clone(),
+ }
+}
+
fn metric_best_entry(entry: &MetricBestEntry) -> MetricBestEntryProjection {
MetricBestEntryProjection {
experiment: experiment_summary(&entry.experiment),
@@ -1127,10 +1291,8 @@ fn attachment_target(
}
}
-fn timestamp_value(timestamp: time::OffsetDateTime) -> String {
- timestamp
- .format(&time::format_description::well_known::Rfc3339)
- .unwrap_or_else(|_| timestamp.unix_timestamp().to_string())
+fn timestamp_value(timestamp: time::OffsetDateTime) -> TimestampText {
+ TimestampText::from(timestamp)
}
fn store_fault(operation: &str) -> impl Fn(StoreError) -> FaultRecord + '_ {
diff --git a/crates/fidget-spinner-cli/src/mcp/service.rs b/crates/fidget-spinner-cli/src/mcp/service.rs
index 3ce68ae..7c649aa 100644
--- a/crates/fidget-spinner-cli/src/mcp/service.rs
+++ b/crates/fidget-spinner-cli/src/mcp/service.rs
@@ -26,8 +26,8 @@ use serde_json::{Map, Value, json};
use crate::mcp::fault::{FaultKind, FaultRecord, FaultStage};
use crate::mcp::output::{
- ToolOutput, fallback_detailed_tool_output, fallback_tool_output, projected_tool_output,
- split_presentation, tool_success,
+ ToolOutput, fallback_detailed_tool_output, projected_tool_output, split_presentation,
+ tool_success,
};
use crate::mcp::projection;
use crate::mcp::protocol::{TRANSIENT_ONCE_ENV, TRANSIENT_ONCE_MARKER_ENV, WorkerOperation};
@@ -103,13 +103,7 @@ impl WorkerService {
TagName::new(args.name).map_err(store_fault(&operation))?,
NonEmptyText::new(args.description).map_err(store_fault(&operation))?,
));
- fallback_tool_output(
- &tag,
- &tag,
- libmcp::SurfaceKind::Mutation,
- FaultStage::Worker,
- &operation,
- )?
+ tag_record_output(&tag, &operation)?
}
"tag.list" => tag_list_output(&lift!(self.store.list_tags()), &operation)?,
"frontier.create" => {
@@ -471,13 +465,7 @@ impl WorkerService {
.map_err(store_fault(&operation))?,
})
);
- fallback_tool_output(
- &metric,
- &metric,
- libmcp::SurfaceKind::Mutation,
- FaultStage::Worker,
- &operation,
- )?
+ metric_definition_output(&metric, &operation)?
}
"metric.keys" => {
let args = deserialize::<MetricKeysArgs>(arguments)?;
@@ -517,23 +505,11 @@ impl WorkerService {
.map_err(store_fault(&operation))?,
})
);
- fallback_tool_output(
- &dimension,
- &dimension,
- libmcp::SurfaceKind::Mutation,
- FaultStage::Worker,
- &operation,
- )?
+ run_dimension_definition_output(&dimension, &operation)?
}
"run.dimension.list" => {
let dimensions = lift!(self.store.list_run_dimensions());
- fallback_tool_output(
- &dimensions,
- &dimensions,
- libmcp::SurfaceKind::List,
- FaultStage::Worker,
- &operation,
- )?
+ run_dimension_list_output(&dimensions, &operation)?
}
other => {
return Err(FaultRecord::new(
@@ -1064,17 +1040,27 @@ fn project_status_output(
)
}
+fn tag_record_output(
+ tag: &fidget_spinner_core::TagRecord,
+ operation: &str,
+) -> Result<ToolOutput, FaultRecord> {
+ let projection = projection::tag_record(tag);
+ projected_tool_output(
+ &projection,
+ format!("tag {} — {}", tag.name, tag.description),
+ None,
+ FaultStage::Worker,
+ operation,
+ )
+}
+
fn tag_list_output(
tags: &[fidget_spinner_core::TagRecord],
operation: &str,
) -> Result<ToolOutput, FaultRecord> {
- let concise = json!({
- "count": tags.len(),
- "tags": tags,
- });
- fallback_detailed_tool_output(
- &concise,
- &concise,
+ let projection = projection::tag_list(tags);
+ projected_tool_output(
+ &projection,
if tags.is_empty() {
"no tags".to_owned()
} else {
@@ -1084,7 +1070,6 @@ fn tag_list_output(
.join("\n")
},
None,
- libmcp::SurfaceKind::List,
FaultStage::Worker,
operation,
)
@@ -1527,6 +1512,26 @@ fn metric_keys_output(
)
}
+fn metric_definition_output(
+ metric: &fidget_spinner_core::MetricDefinition,
+ operation: &str,
+) -> Result<ToolOutput, FaultRecord> {
+ let projection = projection::metric_definition(metric);
+ projected_tool_output(
+ &projection,
+ format!(
+ "metric {} [{} {} {}]",
+ metric.key,
+ metric.unit.as_str(),
+ metric.objective.as_str(),
+ metric.visibility.as_str()
+ ),
+ None,
+ FaultStage::Worker,
+ operation,
+ )
+}
+
fn metric_best_output(
entries: &[MetricBestEntry],
operation: &str,
@@ -1562,14 +1567,63 @@ fn metric_best_output(
)
}
+fn run_dimension_definition_output(
+ dimension: &fidget_spinner_core::RunDimensionDefinition,
+ operation: &str,
+) -> Result<ToolOutput, FaultRecord> {
+ let projection = projection::run_dimension_definition(dimension);
+ projected_tool_output(
+ &projection,
+ format!(
+ "dimension {} [{}]",
+ dimension.key,
+ dimension.value_type.as_str()
+ ),
+ None,
+ FaultStage::Worker,
+ operation,
+ )
+}
+
+fn run_dimension_list_output(
+ dimensions: &[fidget_spinner_core::RunDimensionDefinition],
+ operation: &str,
+) -> Result<ToolOutput, FaultRecord> {
+ let projection = projection::run_dimension_list(dimensions);
+ projected_tool_output(
+ &projection,
+ if dimensions.is_empty() {
+ "no run dimensions".to_owned()
+ } else {
+ dimensions
+ .iter()
+ .map(|dimension| {
+ format!(
+ "{} [{}]{}",
+ dimension.key,
+ dimension.value_type.as_str(),
+ dimension
+ .description
+ .as_ref()
+ .map_or_else(String::new, |description| format!(" — {description}"))
+ )
+ })
+ .collect::<Vec<_>>()
+ .join("\n")
+ },
+ None,
+ FaultStage::Worker,
+ operation,
+ )
+}
+
fn history_output(
history: &[EntityHistoryEntry],
operation: &str,
) -> Result<ToolOutput, FaultRecord> {
- let concise = json!({ "count": history.len(), "history": history });
- fallback_detailed_tool_output(
- &concise,
- &concise,
+ let projection = projection::history(history);
+ projected_tool_output(
+ &projection,
if history.is_empty() {
"no history".to_owned()
} else {
@@ -1585,7 +1639,6 @@ fn history_output(
.join("\n")
},
None,
- libmcp::SurfaceKind::List,
FaultStage::Worker,
operation,
)
diff --git a/crates/fidget-spinner-cli/src/ui.rs b/crates/fidget-spinner-cli/src/ui.rs
index d88bab0..9a42411 100644
--- a/crates/fidget-spinner-cli/src/ui.rs
+++ b/crates/fidget-spinner-cli/src/ui.rs
@@ -1,3 +1,4 @@
+use std::collections::{BTreeMap, BTreeSet};
use std::io;
use std::net::SocketAddr;
@@ -55,6 +56,14 @@ enum FrontierTab {
struct FrontierPageQuery {
metric: Option<String>,
tab: Option<String>,
+ #[serde(flatten)]
+ extra: BTreeMap<String, String>,
+}
+
+#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
+struct DimensionFacet {
+ key: String,
+ values: Vec<String>,
}
struct AttachmentDisplay {
@@ -93,6 +102,23 @@ impl FrontierTab {
}
}
+impl FrontierPageQuery {
+ fn dimension_filters(&self) -> BTreeMap<String, String> {
+ self.extra
+ .iter()
+ .filter_map(|(key, value)| {
+ let value = value.trim();
+ (!value.is_empty())
+ .then(|| {
+ key.strip_prefix("dim.")
+ .map(|dimension| (dimension.to_owned(), value.to_owned()))
+ })
+ .flatten()
+ })
+ .collect()
+ }
+}
+
pub(crate) fn serve(
project_root: Utf8PathBuf,
bind: SocketAddr,
@@ -208,13 +234,7 @@ fn render_frontier_detail(
projection.active_hypotheses.len(),
projection.open_experiments.len()
);
- let content = render_frontier_tab_content(
- &store,
- &projection,
- tab,
- query.metric.as_deref(),
- state.limit,
- )?;
+ let content = render_frontier_tab_content(&store, &projection, tab, &query, state.limit)?;
Ok(render_shell(
&title,
&shell,
@@ -225,6 +245,7 @@ fn render_frontier_detail(
&projection.frontier.slug,
tab,
query.metric.as_deref(),
+ &query.dimension_filters(),
)),
content,
))
@@ -370,7 +391,7 @@ fn render_frontier_tab_content(
store: &fidget_spinner_store_sqlite::ProjectStore,
projection: &FrontierOpenProjection,
tab: FrontierTab,
- metric_selector: Option<&str>,
+ query: &FrontierPageQuery,
limit: Option<u32>,
) -> Result<Markup, StoreError> {
match tab {
@@ -415,7 +436,9 @@ fn render_frontier_tab_content(
} else {
projection.active_metric_keys.clone()
};
- let selected_metric = metric_selector
+ let selected_metric = query
+ .metric
+ .as_deref()
.and_then(|selector| NonEmptyText::new(selector.to_owned()).ok())
.or_else(|| metric_keys.first().map(|metric| metric.key.clone()));
let series = selected_metric
@@ -424,6 +447,7 @@ fn render_frontier_tab_content(
store.frontier_metric_series(projection.frontier.slug.as_str(), metric, true)
})
.transpose()?;
+ let dimension_filters = query.dimension_filters();
Ok(html! {
(render_frontier_header(&projection.frontier))
(render_metric_series_section(
@@ -431,6 +455,7 @@ fn render_frontier_tab_content(
&metric_keys,
selected_metric.as_ref(),
series.as_ref(),
+ &dimension_filters,
limit,
))
})
@@ -442,6 +467,7 @@ fn render_frontier_tab_bar(
frontier_slug: &Slug,
active_tab: FrontierTab,
metric: Option<&str>,
+ dimension_filters: &BTreeMap<String, String>,
) -> Markup {
const TABS: [FrontierTab; 4] = [
FrontierTab::Brief,
@@ -452,7 +478,7 @@ fn render_frontier_tab_bar(
html! {
nav.tab-row aria-label="Frontier tabs" {
@for tab in TABS {
- @let href = frontier_tab_href(frontier_slug, tab, metric);
+ @let href = frontier_tab_href_with_filters(frontier_slug, tab, metric, dimension_filters);
a
href=(href)
class={(if tab == active_tab { "tab-chip active" } else { "tab-chip" })}
@@ -511,8 +537,15 @@ fn render_metric_series_section(
metric_keys: &[fidget_spinner_store_sqlite::MetricKeySummary],
selected_metric: Option<&NonEmptyText>,
series: Option<&FrontierMetricSeries>,
+ dimension_filters: &BTreeMap<String, String>,
limit: Option<u32>,
) -> Markup {
+ let facets = series
+ .map(|series| collect_dimension_facets(&series.points))
+ .unwrap_or_default();
+ let filtered_points = series
+ .map(|series| filter_metric_points(&series.points, dimension_filters))
+ .unwrap_or_default();
html! {
section.card {
h2 { "Metrics" }
@@ -556,47 +589,59 @@ fn render_metric_series_section(
@if let Some(description) = series.metric.description.as_ref() {
p.muted { (description) }
}
- @if series.points.is_empty() {
+ @if !facets.is_empty() {
+ (render_metric_filter_panel(
+ frontier_slug,
+ &series.metric.key,
+ &facets,
+ dimension_filters,
+ ))
+ }
+ @if filtered_points.is_empty() {
+ p.muted { "No closed experiments match the current filters." }
+ } @else if series.points.is_empty() {
p.muted { "No closed experiments for this metric yet." }
} @else {
div.chart-frame {
- (PreEscaped(render_metric_chart_svg(series)))
+ (PreEscaped(render_metric_chart_svg(&series.metric, &filtered_points)))
}
p.muted {
"x = close order, y = metric value. Point color tracks verdict."
}
- table.metric-table {
- thead {
- tr {
- th { "#" }
- th { "Experiment" }
- th { "Hypothesis" }
- th { "Closed" }
- th { "Verdict" }
- th { "Value" }
- }
- }
- tbody {
- @for (index, point) in limit_items(&series.points, limit).iter().enumerate() {
+ div.table-scroll {
+ table.metric-table {
+ thead {
tr {
- td { ((index + 1).to_string()) }
- td {
- a href=(experiment_href(&point.experiment.slug)) {
- (point.experiment.title)
+ th { "#" }
+ th { "Experiment" }
+ th { "Hypothesis" }
+ th { "Closed" }
+ th { "Verdict" }
+ th { "Value" }
+ }
+ }
+ tbody {
+ @for (index, point) in limit_items(&filtered_points, limit).iter().copied().enumerate() {
+ tr {
+ td { ((index + 1).to_string()) }
+ td {
+ a href=(experiment_href(&point.experiment.slug)) {
+ (point.experiment.title)
+ }
}
- }
- td {
- a href=(hypothesis_href(&point.hypothesis.slug)) {
- (point.hypothesis.title)
+ td {
+ a href=(hypothesis_href(&point.hypothesis.slug)) {
+ (point.hypothesis.title)
+ }
}
- }
- td { (format_timestamp(point.closed_at)) }
- td {
- span class=(status_chip_classes(verdict_class(point.verdict))) {
- (point.verdict.as_str())
+ td.nowrap { (format_timestamp(point.closed_at)) }
+ td {
+ span class=(status_chip_classes(verdict_class(point.verdict))) {
+ (point.verdict.as_str())
+ }
}
+ td.nowrap { (format_metric_value(point.value, series.metric.unit)) }
}
- td { (format_metric_value(point.value, series.metric.unit)) }
}
}
}
@@ -607,18 +652,80 @@ fn render_metric_series_section(
}
}
-fn render_metric_chart_svg(series: &FrontierMetricSeries) -> String {
+fn render_metric_filter_panel(
+ frontier_slug: &Slug,
+ metric_key: &NonEmptyText,
+ facets: &[DimensionFacet],
+ active_filters: &BTreeMap<String, String>,
+) -> Markup {
+ let clear_href = frontier_tab_href_with_filters(
+ frontier_slug,
+ FrontierTab::Metrics,
+ Some(metric_key.as_str()),
+ &BTreeMap::new(),
+ );
+ html! {
+ section.subcard {
+ h3 id="slice-filters" { "Slice Filters" }
+ form.filter-form method="get" action=(frontier_href(frontier_slug)) {
+ input type="hidden" name="tab" value="metrics";
+ input type="hidden" name="metric" value=(metric_key.as_str());
+ div.filter-form-grid {
+ @for facet in facets {
+ label.filter-control id=(metric_filter_anchor_id(&facet.key)) {
+ span.filter-label { (&facet.key) }
+ select.filter-select name=(format!("dim.{}", facet.key)) {
+ option
+ value=""
+ selected[active_filters.get(&facet.key).is_none()]
+ { "all" }
+ @for value in &facet.values {
+ option
+ value=(value)
+ selected[active_filters.get(&facet.key) == Some(value)]
+ { (value) }
+ }
+ }
+ }
+ }
+ }
+ div.filter-actions {
+ button.filter-apply type="submit" { "Apply" }
+ a.clear-filter href=(clear_href) { "Clear all" }
+ }
+ }
+ @if active_filters.is_empty() {
+ p.muted { "No slice filters active." }
+ } @else {
+ div.chip-row {
+ @for (key, value) in active_filters {
+ @let href = frontier_tab_href_with_filters(
+ frontier_slug,
+ FrontierTab::Metrics,
+ Some(metric_key.as_str()),
+ &remove_dimension_filter(active_filters, key),
+ );
+ a.metric-filter-chip.active href=(href) {
+ (key) "=" (value) " ×"
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+fn render_metric_chart_svg(
+ metric: &fidget_spinner_store_sqlite::MetricKeySummary,
+ points: &[&fidget_spinner_store_sqlite::FrontierMetricPoint],
+) -> String {
let mut svg = String::new();
{
let root = SVGBackend::with_string(&mut svg, (960, 360)).into_drawing_area();
if root.fill(&RGBColor(255, 250, 242)).is_err() {
return chart_error_markup("chart fill failed");
}
- let values = series
- .points
- .iter()
- .map(|point| point.value)
- .collect::<Vec<_>>();
+ let values = points.iter().map(|point| point.value).collect::<Vec<_>>();
let (mut min_value, mut max_value) = values
.iter()
.copied()
@@ -641,7 +748,7 @@ fn render_metric_chart_svg(series: &FrontierMetricSeries) -> String {
min_value -= pad;
max_value += pad;
}
- let x_end = i32::try_from(series.points.len().saturating_sub(1))
+ let x_end = i32::try_from(points.len().saturating_sub(1))
.unwrap_or(0)
.max(1);
let mut chart = match ChartBuilder::on(&root)
@@ -649,7 +756,7 @@ fn render_metric_chart_svg(series: &FrontierMetricSeries) -> String {
.x_label_area_size(32)
.y_label_area_size(72)
.caption(
- format!("{} over closed experiments", series.metric.key),
+ format!("{} over closed experiments", metric.key),
("Iosevka Web", 18).into_font().color(&BLACK),
)
.build_cartesian_2d(0_i32..x_end, min_value..max_value)
@@ -664,7 +771,7 @@ fn render_metric_chart_svg(series: &FrontierMetricSeries) -> String {
.axis_style(RGBColor(103, 86, 63))
.label_style(("Iosevka Web", 12).into_font().color(&RGBColor(79, 71, 58)))
.x_desc("close order")
- .y_desc(series.metric.unit.as_str())
+ .y_desc(metric.unit.as_str())
.x_label_formatter(&|value| format!("{}", value + 1))
.draw()
.is_err()
@@ -672,8 +779,7 @@ fn render_metric_chart_svg(series: &FrontierMetricSeries) -> String {
return chart_error_markup("mesh draw failed");
}
- let line_points = series
- .points
+ let line_points = points
.iter()
.enumerate()
.filter_map(|(index, point)| i32::try_from(index).ok().map(|x| (x, point.value)))
@@ -690,14 +796,13 @@ fn render_metric_chart_svg(series: &FrontierMetricSeries) -> String {
return chart_error_markup("line draw failed");
}
- let points = series
- .points
+ let plotted_points = points
.iter()
.enumerate()
- .filter_map(|(index, point)| i32::try_from(index).ok().map(|x| (x, point)))
+ .filter_map(|(index, point)| i32::try_from(index).ok().map(|x| (x, *point)))
.collect::<Vec<_>>();
if chart
- .draw_series(points.iter().map(|(x, point)| {
+ .draw_series(plotted_points.iter().map(|(x, point)| {
Circle::new(
(*x, point.value),
4,
@@ -709,7 +814,7 @@ fn render_metric_chart_svg(series: &FrontierMetricSeries) -> String {
return chart_error_markup("point draw failed");
}
if chart
- .draw_series(points.iter().map(|(x, point)| {
+ .draw_series(plotted_points.iter().map(|(x, point)| {
Text::new(
format!("{}", x + 1),
(*x, point.value),
@@ -892,30 +997,32 @@ fn render_frontier_active_sets(projection: &FrontierOpenProjection) -> Markup {
@if projection.active_metric_keys.is_empty() {
p.muted { "No live metrics." }
} @else {
- table.metric-table {
- thead {
- tr {
- th { "Key" }
- th { "Unit" }
- th { "Objective" }
- th { "Refs" }
- }
- }
- tbody {
- @for metric in &projection.active_metric_keys {
+ div.table-scroll {
+ table.metric-table {
+ thead {
tr {
- td {
- a href=(frontier_tab_href(
- &projection.frontier.slug,
- FrontierTab::Metrics,
- Some(metric.key.as_str()),
- )) {
- (metric.key)
+ th { "Key" }
+ th { "Unit" }
+ th { "Objective" }
+ th { "Refs" }
+ }
+ }
+ tbody {
+ @for metric in &projection.active_metric_keys {
+ tr {
+ td {
+ a href=(frontier_tab_href(
+ &projection.frontier.slug,
+ FrontierTab::Metrics,
+ Some(metric.key.as_str()),
+ )) {
+ (metric.key)
+ }
}
+ td { (metric.unit.as_str()) }
+ td { (metric.objective.as_str()) }
+ td { (metric.reference_count) }
}
- td { (metric.unit.as_str()) }
- td { (metric.objective.as_str()) }
- td { (metric.reference_count) }
}
}
}
@@ -1093,13 +1200,15 @@ fn render_experiment_outcome(outcome: &ExperimentOutcome) -> Markup {
@if !outcome.dimensions.is_empty() {
section.subcard {
h3 { "Dimensions" }
- table.metric-table {
- thead { tr { th { "Key" } th { "Value" } } }
- tbody {
- @for (key, value) in &outcome.dimensions {
- tr {
- td { (key) }
- td { (render_dimension_value(value)) }
+ div.table-scroll {
+ table.metric-table {
+ thead { tr { th { "Key" } th { "Value" } } }
+ tbody {
+ @for (key, value) in &outcome.dimensions {
+ tr {
+ td { (key) }
+ td { (render_dimension_value(value)) }
+ }
}
}
}
@@ -1148,13 +1257,15 @@ fn render_command_recipe(command: &fidget_spinner_core::CommandRecipe) -> Markup
}
}
@if !command.env.is_empty() {
- table.metric-table {
- thead { tr { th { "Env" } th { "Value" } } }
- tbody {
- @for (key, value) in &command.env {
- tr {
- td { (key) }
- td { (value) }
+ div.table-scroll {
+ table.metric-table {
+ thead { tr { th { "Env" } th { "Value" } } }
+ tbody {
+ @for (key, value) in &command.env {
+ tr {
+ td { (key) }
+ td { (value) }
+ }
}
}
}
@@ -1172,18 +1283,20 @@ fn render_metric_panel(
html! {
section.subcard {
h3 { (title) }
- table.metric-table {
- thead {
- tr {
- th { "Key" }
- th { "Value" }
- }
- }
- tbody {
- @for metric in metrics {
+ div.table-scroll {
+ table.metric-table {
+ thead {
tr {
- td { (metric.key) }
- td { (format_metric_value(metric.value, metric_unit_for(metric, outcome))) }
+ th { "Key" }
+ th { "Value" }
+ }
+ }
+ tbody {
+ @for metric in metrics {
+ tr {
+ td { (metric.key) }
+ td { (format_metric_value(metric.value, metric_unit_for(metric, outcome))) }
+ }
}
}
}
@@ -1565,6 +1678,15 @@ fn frontier_href(slug: &Slug) -> String {
}
fn frontier_tab_href(slug: &Slug, tab: FrontierTab, metric: Option<&str>) -> String {
+ frontier_tab_href_with_filters(slug, tab, metric, &BTreeMap::new())
+}
+
+fn frontier_tab_href_with_filters(
+ slug: &Slug,
+ tab: FrontierTab,
+ metric: Option<&str>,
+ dimension_filters: &BTreeMap<String, String>,
+) -> String {
let mut href = format!(
"/frontier/{}?tab={}",
encode_path_segment(slug.as_str()),
@@ -1574,6 +1696,12 @@ fn frontier_tab_href(slug: &Slug, tab: FrontierTab, metric: Option<&str>) -> Str
href.push_str("&metric=");
href.push_str(&encode_path_segment(metric));
}
+ for (key, value) in dimension_filters {
+ href.push_str("&dim.");
+ href.push_str(&encode_path_segment(key));
+ href.push('=');
+ href.push_str(&encode_path_segment(value));
+ }
href
}
@@ -1684,6 +1812,73 @@ fn limit_items<T>(items: &[T], limit: Option<u32>) -> &[T] {
&items[..end]
}
+fn collect_dimension_facets(
+ points: &[fidget_spinner_store_sqlite::FrontierMetricPoint],
+) -> Vec<DimensionFacet> {
+ let mut values_by_key: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
+ for point in points {
+ for (key, value) in &point.dimensions {
+ let _ = values_by_key
+ .entry(key.to_string())
+ .or_default()
+ .insert(render_dimension_value(value));
+ }
+ }
+ values_by_key
+ .into_iter()
+ .map(|(key, values)| DimensionFacet {
+ key,
+ values: values.into_iter().collect(),
+ })
+ .collect()
+}
+
+fn filter_metric_points<'a>(
+ points: &'a [fidget_spinner_store_sqlite::FrontierMetricPoint],
+ dimension_filters: &BTreeMap<String, String>,
+) -> Vec<&'a fidget_spinner_store_sqlite::FrontierMetricPoint> {
+ points
+ .iter()
+ .filter(|point| point_matches_dimension_filters(point, dimension_filters))
+ .collect()
+}
+
+fn point_matches_dimension_filters(
+ point: &fidget_spinner_store_sqlite::FrontierMetricPoint,
+ dimension_filters: &BTreeMap<String, String>,
+) -> bool {
+ dimension_filters.iter().all(|(key, expected)| {
+ point.dimensions.iter().any(|(point_key, point_value)| {
+ point_key.as_str() == key && render_dimension_value(point_value) == *expected
+ })
+ })
+}
+
+fn remove_dimension_filter(
+ filters: &BTreeMap<String, String>,
+ key: &str,
+) -> BTreeMap<String, String> {
+ let mut next = filters.clone();
+ let _ = next.remove(key);
+ next
+}
+
+fn metric_filter_anchor_id(key: &str) -> String {
+ format!("filter-{}", sanitize_fragment_id(key))
+}
+
+fn sanitize_fragment_id(raw: &str) -> String {
+ raw.chars()
+ .map(|character| {
+ if character.is_ascii_alphanumeric() {
+ character.to_ascii_lowercase()
+ } else {
+ '-'
+ }
+ })
+ .collect()
+}
+
fn styles() -> &'static str {
r#"
:root {
@@ -1718,7 +1913,8 @@ fn styles() -> &'static str {
}
a:hover { text-decoration: underline; }
.shell {
- width: min(1360px, 100%);
+ width: 100%;
+ max-width: none;
margin: 0 auto;
padding: 24px 24px 40px;
display: grid;
@@ -1995,6 +2191,71 @@ fn styles() -> &'static str {
text-transform: uppercase;
letter-spacing: 0.05em;
}
+ .filter-form {
+ display: grid;
+ gap: 12px;
+ }
+ .filter-form-grid {
+ display: grid;
+ gap: 10px 12px;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ }
+ .filter-control {
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+ }
+ .filter-label {
+ color: var(--muted);
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ }
+ .filter-select {
+ width: 100%;
+ min-width: 0;
+ padding: 7px 9px;
+ border: 1px solid var(--border);
+ background: var(--panel);
+ color: var(--text);
+ font: inherit;
+ }
+ .filter-actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+ }
+ .filter-apply {
+ padding: 7px 11px;
+ border: 1px solid var(--border-strong);
+ background: var(--accent-soft);
+ color: var(--text);
+ font: inherit;
+ cursor: pointer;
+ }
+ .metric-filter-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 5px 9px;
+ border: 1px solid var(--border);
+ background: var(--panel);
+ color: var(--text);
+ font-size: 12px;
+ white-space: nowrap;
+ }
+ .metric-filter-chip.active {
+ border-color: var(--border-strong);
+ background: var(--accent-soft);
+ font-weight: 700;
+ }
+ .clear-filter {
+ color: var(--muted);
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ }
.link-chip {
display: inline-grid;
gap: 4px;
@@ -2039,10 +2300,13 @@ fn styles() -> &'static str {
.status-neutral, .classless { color: #5f584d; border-color: var(--border-strong); background: var(--panel); }
.status-archived { color: #7a756d; border-color: var(--border); background: var(--panel); }
.metric-table {
- width: 100%;
+ width: max-content;
+ min-width: 100%;
border-collapse: collapse;
font-size: 13px;
- display: block;
+ }
+ .table-scroll {
+ width: 100%;
overflow-x: auto;
}
.metric-table th,
@@ -2051,7 +2315,9 @@ fn styles() -> &'static str {
border-top: 1px solid var(--border);
text-align: left;
vertical-align: top;
- overflow-wrap: anywhere;
+ white-space: nowrap;
+ overflow-wrap: normal;
+ word-break: normal;
}
.metric-table th {
color: var(--muted);