From ce41a229dcd57f9a2c35359fe77d9f54f603e985 Mon Sep 17 00:00:00 2001 From: main Date: Fri, 20 Mar 2026 00:33:08 -0400 Subject: Refound ontology around hypotheses and experiments --- crates/fidget-spinner-cli/src/main.rs | 207 ++++++++++++++---- crates/fidget-spinner-cli/src/mcp/catalog.rs | 104 ++++++--- crates/fidget-spinner-cli/src/mcp/service.rs | 313 ++++++++++++++++++++------- 3 files changed, 477 insertions(+), 147 deletions(-) (limited to 'crates/fidget-spinner-cli/src') diff --git a/crates/fidget-spinner-cli/src/main.rs b/crates/fidget-spinner-cli/src/main.rs index 7711cb4..491e30d 100644 --- a/crates/fidget-spinner-cli/src/main.rs +++ b/crates/fidget-spinner-cli/src/main.rs @@ -17,9 +17,10 @@ use fidget_spinner_core::{ }; use fidget_spinner_store_sqlite::{ CloseExperimentRequest, CreateFrontierRequest, CreateNodeRequest, DefineMetricRequest, - DefineRunDimensionRequest, EdgeAttachment, EdgeAttachmentDirection, ListNodesQuery, - MetricBestQuery, MetricFieldSource, MetricKeyQuery, MetricRankOrder, ProjectStore, - RemoveSchemaFieldRequest, STORE_DIR_NAME, StoreError, UpsertSchemaFieldRequest, + DefineRunDimensionRequest, EdgeAttachment, EdgeAttachmentDirection, ExperimentAnalysisDraft, + ListNodesQuery, MetricBestQuery, MetricFieldSource, MetricKeyQuery, MetricRankOrder, + OpenExperimentRequest, ProjectStore, RemoveSchemaFieldRequest, STORE_DIR_NAME, StoreError, + UpsertSchemaFieldRequest, }; use serde::Serialize; use serde_json::{Map, Value, json}; @@ -57,13 +58,15 @@ enum Command { }, /// Record terse off-path notes. Note(NoteCommand), + /// Record core-path hypotheses before experimental work begins. + Hypothesis(HypothesisCommand), /// Manage the repo-local tag registry. Tag { #[command(subcommand)] command: TagCommand, }, - /// Record off-path research and enabling work. - Research(ResearchCommand), + /// Record imported sources and documentary context. + Source(SourceCommand), /// Inspect rankable metrics across closed experiments. Metric { #[command(subcommand)] @@ -186,10 +189,10 @@ struct NodeAddArgs { #[arg(long)] title: String, #[arg(long)] - /// Required for `note` and `research` nodes. + /// Required for `note` and `source` nodes. summary: Option, #[arg(long = "payload-json")] - /// JSON object payload. `note` and `research` nodes require a non-empty `body` string. + /// JSON object payload. `note` and `source` nodes require a non-empty `body` string. payload_json: Option, #[arg(long = "payload-file")] payload_file: Option, @@ -263,12 +266,24 @@ struct NoteCommand { command: NoteSubcommand, } +#[derive(Args)] +struct HypothesisCommand { + #[command(subcommand)] + command: HypothesisSubcommand, +} + #[derive(Subcommand)] enum NoteSubcommand { /// Record a quick off-path note. Quick(QuickNoteArgs), } +#[derive(Subcommand)] +enum HypothesisSubcommand { + /// Record a core-path hypothesis with low ceremony. + Add(QuickHypothesisArgs), +} + #[derive(Subcommand)] enum TagCommand { /// Register a new repo-local tag. @@ -278,15 +293,15 @@ enum TagCommand { } #[derive(Args)] -struct ResearchCommand { +struct SourceCommand { #[command(subcommand)] - command: ResearchSubcommand, + command: SourceSubcommand, } #[derive(Subcommand)] -enum ResearchSubcommand { - /// Record off-path research or enabling work. - Add(QuickResearchArgs), +enum SourceSubcommand { + /// Record imported source material or documentary context. + Add(QuickSourceArgs), } #[derive(Subcommand)] @@ -375,6 +390,22 @@ struct QuickNoteArgs { parents: Vec, } +#[derive(Args)] +struct QuickHypothesisArgs { + #[command(flatten)] + project: ProjectArg, + #[arg(long)] + frontier: String, + #[arg(long)] + title: String, + #[arg(long)] + summary: String, + #[arg(long)] + body: String, + #[arg(long = "parent")] + parents: Vec, +} + #[derive(Args)] struct TagAddArgs { #[command(flatten)] @@ -386,7 +417,7 @@ struct TagAddArgs { } #[derive(Args)] -struct QuickResearchArgs { +struct QuickSourceArgs { #[command(flatten)] project: ProjectArg, #[arg(long)] @@ -459,8 +490,12 @@ struct MetricBestArgs { #[derive(Subcommand)] enum ExperimentCommand { + /// Open a stateful experiment against one hypothesis and base checkpoint. + Open(ExperimentOpenArgs), + /// List open experiments, optionally narrowed to one frontier. + List(ExperimentListArgs), /// Close a core-path experiment with checkpoint, run, note, and verdict. - Close(ExperimentCloseArgs), + Close(Box), } #[derive(Subcommand)] @@ -481,12 +516,8 @@ enum UiCommand { struct ExperimentCloseArgs { #[command(flatten)] project: ProjectArg, - #[arg(long)] - frontier: String, - #[arg(long = "base-checkpoint")] - base_checkpoint: String, - #[arg(long = "change-node")] - change_node: String, + #[arg(long = "experiment")] + experiment_id: String, #[arg(long = "candidate-summary")] candidate_summary: String, #[arg(long = "run-title")] @@ -518,12 +549,42 @@ struct ExperimentCloseArgs { next_hypotheses: Vec, #[arg(long = "verdict", value_enum)] verdict: CliFrontierVerdict, + #[arg(long = "analysis-title")] + analysis_title: Option, + #[arg(long = "analysis-summary")] + analysis_summary: Option, + #[arg(long = "analysis-body")] + analysis_body: Option, #[arg(long = "decision-title")] decision_title: String, #[arg(long = "decision-rationale")] decision_rationale: String, } +#[derive(Args)] +struct ExperimentOpenArgs { + #[command(flatten)] + project: ProjectArg, + #[arg(long)] + frontier: String, + #[arg(long = "base-checkpoint")] + base_checkpoint: String, + #[arg(long = "hypothesis-node")] + hypothesis_node: String, + #[arg(long)] + title: String, + #[arg(long)] + summary: Option, +} + +#[derive(Args)] +struct ExperimentListArgs { + #[command(flatten)] + project: ProjectArg, + #[arg(long)] + frontier: Option, +} + #[derive(Subcommand)] enum SkillCommand { /// List bundled skills. @@ -588,12 +649,11 @@ struct UiServeArgs { #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] enum CliNodeClass { Contract, - Change, + Hypothesis, Run, Analysis, Decision, - Research, - Enabling, + Source, Note, } @@ -623,7 +683,7 @@ enum CliExecutionBackend { #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] enum CliMetricSource { RunMetric, - ChangePayload, + HypothesisPayload, RunPayload, AnalysisPayload, DecisionPayload, @@ -713,12 +773,15 @@ fn run() -> Result<(), StoreError> { Command::Note(command) => match command.command { NoteSubcommand::Quick(args) => run_quick_note(args), }, + Command::Hypothesis(command) => match command.command { + HypothesisSubcommand::Add(args) => run_quick_hypothesis(args), + }, Command::Tag { command } => match command { TagCommand::Add(args) => run_tag_add(args), TagCommand::List(project) => run_tag_list(project), }, - Command::Research(command) => match command.command { - ResearchSubcommand::Add(args) => run_quick_research(args), + Command::Source(command) => match command.command { + SourceSubcommand::Add(args) => run_quick_source(args), }, Command::Metric { command } => match command { MetricCommand::Define(args) => run_metric_define(args), @@ -731,7 +794,9 @@ fn run() -> Result<(), StoreError> { DimensionCommand::List(project) => run_dimension_list(project), }, Command::Experiment { command } => match command { - ExperimentCommand::Close(args) => run_experiment_close(args), + ExperimentCommand::Open(args) => run_experiment_open(args), + ExperimentCommand::List(args) => run_experiment_list(args), + ExperimentCommand::Close(args) => run_experiment_close(*args), }, Command::Mcp { command } => match command { McpCommand::Serve(args) => mcp::serve(args.project), @@ -942,6 +1007,25 @@ fn run_quick_note(args: QuickNoteArgs) -> Result<(), StoreError> { print_json(&node) } +fn run_quick_hypothesis(args: QuickHypothesisArgs) -> Result<(), StoreError> { + let mut store = open_store(&args.project.project)?; + let payload = NodePayload::with_schema( + store.schema().schema_ref(), + json_object(json!({ "body": args.body }))?, + ); + let node = store.add_node(CreateNodeRequest { + class: NodeClass::Hypothesis, + frontier_id: Some(parse_frontier_id(&args.frontier)?), + title: NonEmptyText::new(args.title)?, + summary: Some(NonEmptyText::new(args.summary)?), + tags: None, + payload, + annotations: Vec::new(), + attachments: lineage_attachments(args.parents)?, + })?; + print_json(&node) +} + fn run_tag_add(args: TagAddArgs) -> Result<(), StoreError> { let mut store = open_store(&args.project.project)?; let tag = store.add_tag( @@ -956,14 +1040,14 @@ fn run_tag_list(args: ProjectArg) -> Result<(), StoreError> { print_json(&store.list_tags()?) } -fn run_quick_research(args: QuickResearchArgs) -> Result<(), StoreError> { +fn run_quick_source(args: QuickSourceArgs) -> Result<(), StoreError> { let mut store = open_store(&args.project.project)?; let payload = NodePayload::with_schema( store.schema().schema_ref(), json_object(json!({ "body": args.body }))?, ); let node = store.add_node(CreateNodeRequest { - class: NodeClass::Research, + class: NodeClass::Source, frontier_id: args .frontier .as_deref() @@ -1042,9 +1126,31 @@ fn run_dimension_list(args: ProjectArg) -> Result<(), StoreError> { print_json(&store.list_run_dimensions()?) } +fn run_experiment_open(args: ExperimentOpenArgs) -> Result<(), StoreError> { + let mut store = open_store(&args.project.project)?; + let summary = args.summary.map(NonEmptyText::new).transpose()?; + let experiment = store.open_experiment(OpenExperimentRequest { + frontier_id: parse_frontier_id(&args.frontier)?, + base_checkpoint_id: parse_checkpoint_id(&args.base_checkpoint)?, + hypothesis_node_id: parse_node_id(&args.hypothesis_node)?, + title: NonEmptyText::new(args.title)?, + summary, + })?; + print_json(&experiment) +} + +fn run_experiment_list(args: ExperimentListArgs) -> Result<(), StoreError> { + let store = open_store(&args.project.project)?; + let frontier_id = args + .frontier + .as_deref() + .map(parse_frontier_id) + .transpose()?; + print_json(&store.list_open_experiments(frontier_id)?) +} + fn run_experiment_close(args: ExperimentCloseArgs) -> Result<(), StoreError> { let mut store = open_store(&args.project.project)?; - let frontier_id = parse_frontier_id(&args.frontier)?; let snapshot = store .auto_capture_checkpoint(NonEmptyText::new(args.candidate_summary.clone())?)? .map(|seed| seed.snapshot) @@ -1058,10 +1164,28 @@ fn run_experiment_close(args: ExperimentCloseArgs) -> Result<(), StoreError> { to_text_vec(args.argv)?, parse_env(args.env), )?; + let analysis = match ( + args.analysis_title, + args.analysis_summary, + args.analysis_body, + ) { + (Some(title), Some(summary), Some(body)) => Some(ExperimentAnalysisDraft { + title: NonEmptyText::new(title)?, + summary: NonEmptyText::new(summary)?, + body: NonEmptyText::new(body)?, + }), + (None, None, None) => None, + _ => { + return Err(StoreError::Json(serde_json::Error::io( + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "analysis-title, analysis-summary, and analysis-body must be provided together", + ), + ))); + } + }; let receipt = store.close_experiment(CloseExperimentRequest { - frontier_id, - base_checkpoint_id: parse_checkpoint_id(&args.base_checkpoint)?, - change_node_id: parse_node_id(&args.change_node)?, + experiment_id: parse_experiment_id(&args.experiment_id)?, candidate_summary: NonEmptyText::new(args.candidate_summary)?, candidate_snapshot: snapshot, run_title: NonEmptyText::new(args.run_title)?, @@ -1081,9 +1205,9 @@ fn run_experiment_close(args: ExperimentCloseArgs) -> Result<(), StoreError> { next_hypotheses: to_text_vec(args.next_hypotheses)?, }, verdict: args.verdict.into(), + analysis, decision_title: NonEmptyText::new(args.decision_title)?, decision_rationale: NonEmptyText::new(args.decision_rationale)?, - analysis_node_id: None, })?; print_json(&receipt) } @@ -1378,7 +1502,7 @@ fn validate_cli_prose_payload( summary: Option<&str>, payload: &NodePayload, ) -> Result<(), StoreError> { - if !matches!(class, NodeClass::Note | NodeClass::Research) { + if !matches!(class, NodeClass::Note | NodeClass::Source) { return Ok(()); } if summary.is_none() { @@ -1584,6 +1708,12 @@ fn parse_checkpoint_id(raw: &str) -> Result Result { + Ok(fidget_spinner_core::ExperimentId::from_uuid( + Uuid::parse_str(raw)?, + )) +} + fn print_json(value: &T) -> Result<(), StoreError> { println!("{}", to_pretty_json(value)?); Ok(()) @@ -1604,12 +1734,11 @@ impl From for NodeClass { fn from(value: CliNodeClass) -> Self { match value { CliNodeClass::Contract => Self::Contract, - CliNodeClass::Change => Self::Change, + CliNodeClass::Hypothesis => Self::Hypothesis, CliNodeClass::Run => Self::Run, CliNodeClass::Analysis => Self::Analysis, CliNodeClass::Decision => Self::Decision, - CliNodeClass::Research => Self::Research, - CliNodeClass::Enabling => Self::Enabling, + CliNodeClass::Source => Self::Source, CliNodeClass::Note => Self::Note, } } @@ -1651,7 +1780,7 @@ impl From for MetricFieldSource { fn from(value: CliMetricSource) -> Self { match value { CliMetricSource::RunMetric => Self::RunMetric, - CliMetricSource::ChangePayload => Self::ChangePayload, + CliMetricSource::HypothesisPayload => Self::HypothesisPayload, CliMetricSource::RunPayload => Self::RunPayload, CliMetricSource::AnalysisPayload => Self::AnalysisPayload, CliMetricSource::DecisionPayload => Self::DecisionPayload, diff --git a/crates/fidget-spinner-cli/src/mcp/catalog.rs b/crates/fidget-spinner-cli/src/mcp/catalog.rs index 0831ba4..3b8abcc 100644 --- a/crates/fidget-spinner-cli/src/mcp/catalog.rs +++ b/crates/fidget-spinner-cli/src/mcp/catalog.rs @@ -115,9 +115,9 @@ pub(crate) fn tool_spec(name: &str) -> Option { dispatch: DispatchTarget::Worker, replay: ReplayContract::NeverReplay, }), - "change.record" => Some(ToolSpec { - name: "change.record", - description: "Record a core-path change hypothesis with low ceremony.", + "hypothesis.record" => Some(ToolSpec { + name: "hypothesis.record", + description: "Record a core-path hypothesis with low ceremony.", dispatch: DispatchTarget::Worker, replay: ReplayContract::NeverReplay, }), @@ -151,9 +151,9 @@ pub(crate) fn tool_spec(name: &str) -> Option { dispatch: DispatchTarget::Worker, replay: ReplayContract::NeverReplay, }), - "research.record" => Some(ToolSpec { - name: "research.record", - description: "Record off-path research or enabling work that should live in the DAG but not on the bureaucratic core path.", + "source.record" => Some(ToolSpec { + name: "source.record", + description: "Record imported sources and documentary context that should live in the DAG without polluting the core path.", dispatch: DispatchTarget::Worker, replay: ReplayContract::NeverReplay, }), @@ -193,9 +193,27 @@ pub(crate) fn tool_spec(name: &str) -> Option { dispatch: DispatchTarget::Worker, replay: ReplayContract::NeverReplay, }), + "experiment.open" => Some(ToolSpec { + name: "experiment.open", + description: "Open a stateful experiment against one hypothesis and one base checkpoint.", + dispatch: DispatchTarget::Worker, + replay: ReplayContract::NeverReplay, + }), + "experiment.list" => Some(ToolSpec { + name: "experiment.list", + description: "List currently open experiments, optionally narrowed to one frontier.", + dispatch: DispatchTarget::Worker, + replay: ReplayContract::Convergent, + }), + "experiment.read" => Some(ToolSpec { + name: "experiment.read", + description: "Read one currently open experiment by id.", + dispatch: DispatchTarget::Worker, + replay: ReplayContract::Convergent, + }), "experiment.close" => Some(ToolSpec { name: "experiment.close", - description: "Atomically close a core-path experiment with typed run dimensions, preregistered metric observations, candidate checkpoint capture, note, and verdict.", + description: "Close one open experiment with typed run dimensions, preregistered metric observations, candidate checkpoint capture, optional analysis, note, and verdict.", dispatch: DispatchTarget::Worker, replay: ReplayContract::NeverReplay, }), @@ -268,19 +286,22 @@ pub(crate) fn tool_definitions() -> Vec { "frontier.status", "frontier.init", "node.create", - "change.record", + "hypothesis.record", "node.list", "node.read", "node.annotate", "node.archive", "note.quick", - "research.record", + "source.record", "metric.define", "run.dimension.define", "run.dimension.list", "metric.keys", "metric.best", "metric.migrate", + "experiment.open", + "experiment.list", + "experiment.read", "experiment.close", "skill.list", "skill.show", @@ -414,29 +435,26 @@ fn input_schema(name: &str) -> Value { "class": node_class_schema(), "frontier_id": { "type": "string" }, "title": { "type": "string" }, - "summary": { "type": "string", "description": "Required for `note` and `research` nodes." }, + "summary": { "type": "string", "description": "Required for `note` and `source` nodes." }, "tags": { "type": "array", "items": tag_name_schema(), "description": "Required for `note` nodes; optional for other classes." }, - "payload": { "type": "object", "description": "`note` and `research` nodes require a non-empty string `body` field." }, + "payload": { "type": "object", "description": "`note` and `source` nodes require a non-empty string `body` field." }, "annotations": { "type": "array", "items": annotation_schema() }, "parents": { "type": "array", "items": { "type": "string" } } }, "required": ["class", "title"], "additionalProperties": false }), - "change.record" => json!({ + "hypothesis.record" => json!({ "type": "object", "properties": { "frontier_id": { "type": "string" }, "title": { "type": "string" }, "summary": { "type": "string" }, "body": { "type": "string" }, - "hypothesis": { "type": "string" }, - "base_checkpoint_id": { "type": "string" }, - "benchmark_suite": { "type": "string" }, "annotations": { "type": "array", "items": annotation_schema() }, "parents": { "type": "array", "items": { "type": "string" } } }, - "required": ["frontier_id", "title", "body"], + "required": ["frontier_id", "title", "summary", "body"], "additionalProperties": false }), "node.list" => json!({ @@ -483,7 +501,7 @@ fn input_schema(name: &str) -> Value { "required": ["title", "summary", "body", "tags"], "additionalProperties": false }), - "research.record" => json!({ + "source.record" => json!({ "type": "object", "properties": { "frontier_id": { "type": "string" }, @@ -540,12 +558,37 @@ fn input_schema(name: &str) -> Value { "required": ["key"], "additionalProperties": false }), - "experiment.close" => json!({ + "experiment.open" => json!({ "type": "object", "properties": { "frontier_id": { "type": "string" }, "base_checkpoint_id": { "type": "string" }, - "change_node_id": { "type": "string" }, + "hypothesis_node_id": { "type": "string" }, + "title": { "type": "string" }, + "summary": { "type": "string" } + }, + "required": ["frontier_id", "base_checkpoint_id", "hypothesis_node_id", "title"], + "additionalProperties": false + }), + "experiment.list" => json!({ + "type": "object", + "properties": { + "frontier_id": { "type": "string" } + }, + "additionalProperties": false + }), + "experiment.read" => json!({ + "type": "object", + "properties": { + "experiment_id": { "type": "string" } + }, + "required": ["experiment_id"], + "additionalProperties": false + }), + "experiment.close" => json!({ + "type": "object", + "properties": { + "experiment_id": { "type": "string" }, "candidate_summary": { "type": "string" }, "run": run_schema(), "primary_metric": metric_value_schema(), @@ -554,12 +597,10 @@ fn input_schema(name: &str) -> Value { "verdict": verdict_schema(), "decision_title": { "type": "string" }, "decision_rationale": { "type": "string" }, - "analysis_node_id": { "type": "string" } + "analysis": analysis_schema() }, "required": [ - "frontier_id", - "base_checkpoint_id", - "change_node_id", + "experiment_id", "candidate_summary", "run", "primary_metric", @@ -612,6 +653,19 @@ fn annotation_schema() -> Value { }) } +fn analysis_schema() -> Value { + json!({ + "type": "object", + "properties": { + "title": { "type": "string" }, + "summary": { "type": "string" }, + "body": { "type": "string" } + }, + "required": ["title", "summary", "body"], + "additionalProperties": false + }) +} + fn tag_name_schema() -> Value { json!({ "type": "string", @@ -622,7 +676,7 @@ fn tag_name_schema() -> Value { fn node_class_schema() -> Value { json!({ "type": "string", - "enum": ["contract", "change", "run", "analysis", "decision", "research", "enabling", "note"] + "enum": ["contract", "hypothesis", "run", "analysis", "decision", "source", "note"] }) } @@ -638,7 +692,7 @@ fn metric_source_schema() -> Value { "type": "string", "enum": [ "run_metric", - "change_payload", + "hypothesis_payload", "run_payload", "analysis_payload", "decision_payload" diff --git a/crates/fidget-spinner-cli/src/mcp/service.rs b/crates/fidget-spinner-cli/src/mcp/service.rs index 62e3641..05f2382 100644 --- a/crates/fidget-spinner-cli/src/mcp/service.rs +++ b/crates/fidget-spinner-cli/src/mcp/service.rs @@ -11,10 +11,10 @@ use fidget_spinner_core::{ }; use fidget_spinner_store_sqlite::{ CloseExperimentRequest, CreateFrontierRequest, CreateNodeRequest, DefineMetricRequest, - DefineRunDimensionRequest, EdgeAttachment, EdgeAttachmentDirection, ExperimentReceipt, - ListNodesQuery, MetricBestQuery, MetricFieldSource, MetricKeyQuery, MetricKeySummary, - MetricRankOrder, NodeSummary, ProjectStore, RemoveSchemaFieldRequest, StoreError, - UpsertSchemaFieldRequest, + DefineRunDimensionRequest, EdgeAttachment, EdgeAttachmentDirection, ExperimentAnalysisDraft, + ExperimentReceipt, ListNodesQuery, MetricBestQuery, MetricFieldSource, MetricKeyQuery, + MetricKeySummary, MetricRankOrder, NodeSummary, OpenExperimentRequest, OpenExperimentSummary, + ProjectStore, RemoveSchemaFieldRequest, StoreError, UpsertSchemaFieldRequest, }; use serde::Deserialize; use serde_json::{Map, Value, json}; @@ -303,51 +303,43 @@ impl WorkerService { "tools/call:node.create", ) } - "change.record" => { - let args = deserialize::(arguments)?; - let mut fields = Map::new(); - let _ = fields.insert("body".to_owned(), Value::String(args.body)); - if let Some(hypothesis) = args.hypothesis { - let _ = fields.insert("hypothesis".to_owned(), Value::String(hypothesis)); - } - if let Some(base_checkpoint_id) = args.base_checkpoint_id { - let _ = fields.insert( - "base_checkpoint_id".to_owned(), - Value::String(base_checkpoint_id), - ); - } - if let Some(benchmark_suite) = args.benchmark_suite { - let _ = - fields.insert("benchmark_suite".to_owned(), Value::String(benchmark_suite)); - } + "hypothesis.record" => { + let args = deserialize::(arguments)?; let node = self .store .add_node(CreateNodeRequest { - class: NodeClass::Change, + class: NodeClass::Hypothesis, frontier_id: Some( crate::parse_frontier_id(&args.frontier_id) - .map_err(store_fault("tools/call:change.record"))?, + .map_err(store_fault("tools/call:hypothesis.record"))?, ), title: NonEmptyText::new(args.title) - .map_err(store_fault("tools/call:change.record"))?, - summary: args - .summary - .map(NonEmptyText::new) - .transpose() - .map_err(store_fault("tools/call:change.record"))?, + .map_err(store_fault("tools/call:hypothesis.record"))?, + summary: Some( + NonEmptyText::new(args.summary) + .map_err(store_fault("tools/call:hypothesis.record"))?, + ), tags: None, - payload: NodePayload::with_schema(self.store.schema().schema_ref(), fields), + payload: NodePayload::with_schema( + self.store.schema().schema_ref(), + crate::json_object(json!({ "body": args.body })) + .map_err(store_fault("tools/call:hypothesis.record"))?, + ), annotations: tool_annotations(args.annotations) - .map_err(store_fault("tools/call:change.record"))?, + .map_err(store_fault("tools/call:hypothesis.record"))?, attachments: lineage_attachments(args.parents) - .map_err(store_fault("tools/call:change.record"))?, + .map_err(store_fault("tools/call:hypothesis.record"))?, }) - .map_err(store_fault("tools/call:change.record"))?; + .map_err(store_fault("tools/call:hypothesis.record"))?; tool_success( - created_node_output("recorded change", &node, "tools/call:change.record")?, + created_node_output( + "recorded hypothesis", + &node, + "tools/call:hypothesis.record", + )?, presentation, FaultStage::Worker, - "tools/call:change.record", + "tools/call:hypothesis.record", ) } "node.list" => { @@ -498,44 +490,45 @@ impl WorkerService { "tools/call:note.quick", ) } - "research.record" => { - let args = deserialize::(arguments)?; + "source.record" => { + let args = deserialize::(arguments)?; let node = self .store .add_node(CreateNodeRequest { - class: NodeClass::Research, + class: NodeClass::Source, frontier_id: args .frontier_id .as_deref() .map(crate::parse_frontier_id) .transpose() - .map_err(store_fault("tools/call:research.record"))?, + .map_err(store_fault("tools/call:source.record"))?, title: NonEmptyText::new(args.title) - .map_err(store_fault("tools/call:research.record"))?, + .map_err(store_fault("tools/call:source.record"))?, summary: Some( NonEmptyText::new(args.summary) - .map_err(store_fault("tools/call:research.record"))?, - ), - tags: Some( - parse_tag_set(args.tags) - .map_err(store_fault("tools/call:research.record"))?, + .map_err(store_fault("tools/call:source.record"))?, ), + tags: args + .tags + .map(parse_tag_set) + .transpose() + .map_err(store_fault("tools/call:source.record"))?, payload: NodePayload::with_schema( self.store.schema().schema_ref(), crate::json_object(json!({ "body": args.body })) - .map_err(store_fault("tools/call:research.record"))?, + .map_err(store_fault("tools/call:source.record"))?, ), annotations: tool_annotations(args.annotations) - .map_err(store_fault("tools/call:research.record"))?, + .map_err(store_fault("tools/call:source.record"))?, attachments: lineage_attachments(args.parents) - .map_err(store_fault("tools/call:research.record"))?, + .map_err(store_fault("tools/call:source.record"))?, }) - .map_err(store_fault("tools/call:research.record"))?; + .map_err(store_fault("tools/call:source.record"))?; tool_success( - created_node_output("recorded research", &node, "tools/call:research.record")?, + created_node_output("recorded source", &node, "tools/call:source.record")?, presentation, FaultStage::Worker, - "tools/call:research.record", + "tools/call:source.record", ) } "metric.define" => { @@ -702,10 +695,74 @@ impl WorkerService { "tools/call:metric.migrate", ) } + "experiment.open" => { + let args = deserialize::(arguments)?; + let item = self + .store + .open_experiment(OpenExperimentRequest { + frontier_id: crate::parse_frontier_id(&args.frontier_id) + .map_err(store_fault("tools/call:experiment.open"))?, + base_checkpoint_id: crate::parse_checkpoint_id(&args.base_checkpoint_id) + .map_err(store_fault("tools/call:experiment.open"))?, + hypothesis_node_id: crate::parse_node_id(&args.hypothesis_node_id) + .map_err(store_fault("tools/call:experiment.open"))?, + title: NonEmptyText::new(args.title) + .map_err(store_fault("tools/call:experiment.open"))?, + summary: args + .summary + .map(NonEmptyText::new) + .transpose() + .map_err(store_fault("tools/call:experiment.open"))?, + }) + .map_err(store_fault("tools/call:experiment.open"))?; + tool_success( + experiment_open_output( + &item, + "tools/call:experiment.open", + "opened experiment", + )?, + presentation, + FaultStage::Worker, + "tools/call:experiment.open", + ) + } + "experiment.list" => { + let args = deserialize::(arguments)?; + let items = self + .store + .list_open_experiments( + args.frontier_id + .as_deref() + .map(crate::parse_frontier_id) + .transpose() + .map_err(store_fault("tools/call:experiment.list"))?, + ) + .map_err(store_fault("tools/call:experiment.list"))?; + tool_success( + experiment_list_output(items.as_slice())?, + presentation, + FaultStage::Worker, + "tools/call:experiment.list", + ) + } + "experiment.read" => { + let args = deserialize::(arguments)?; + let item = self + .store + .read_open_experiment( + crate::parse_experiment_id(&args.experiment_id) + .map_err(store_fault("tools/call:experiment.read"))?, + ) + .map_err(store_fault("tools/call:experiment.read"))?; + tool_success( + experiment_open_output(&item, "tools/call:experiment.read", "open experiment")?, + presentation, + FaultStage::Worker, + "tools/call:experiment.read", + ) + } "experiment.close" => { let args = deserialize::(arguments)?; - let frontier_id = crate::parse_frontier_id(&args.frontier_id) - .map_err(store_fault("tools/call:experiment.close"))?; let snapshot = self .store .auto_capture_checkpoint( @@ -728,10 +785,7 @@ impl WorkerService { let receipt = self .store .close_experiment(CloseExperimentRequest { - frontier_id, - base_checkpoint_id: crate::parse_checkpoint_id(&args.base_checkpoint_id) - .map_err(store_fault("tools/call:experiment.close"))?, - change_node_id: crate::parse_node_id(&args.change_node_id) + experiment_id: crate::parse_experiment_id(&args.experiment_id) .map_err(store_fault("tools/call:experiment.close"))?, candidate_summary: NonEmptyText::new(args.candidate_summary) .map_err(store_fault("tools/call:experiment.close"))?, @@ -776,16 +830,15 @@ impl WorkerService { }, verdict: parse_verdict_name(&args.verdict) .map_err(store_fault("tools/call:experiment.close"))?, + analysis: args + .analysis + .map(experiment_analysis_from_wire) + .transpose() + .map_err(store_fault("tools/call:experiment.close"))?, decision_title: NonEmptyText::new(args.decision_title) .map_err(store_fault("tools/call:experiment.close"))?, decision_rationale: NonEmptyText::new(args.decision_rationale) .map_err(store_fault("tools/call:experiment.close"))?, - analysis_node_id: args - .analysis_node_id - .as_deref() - .map(crate::parse_node_id) - .transpose() - .map_err(store_fault("tools/call:experiment.close"))?, }) .map_err(store_fault("tools/call:experiment.close"))?; tool_success( @@ -1296,6 +1349,7 @@ fn experiment_close_output( "candidate_checkpoint_id": receipt.experiment.candidate_checkpoint_id, "verdict": format!("{:?}", receipt.experiment.verdict).to_ascii_lowercase(), "run_id": receipt.run.run_id, + "hypothesis_node_id": receipt.experiment.hypothesis_node_id, "decision_node_id": receipt.decision_node.id, "dimensions": run_dimensions_value(&receipt.experiment.result.dimensions), "primary_metric": metric_value(store, &receipt.experiment.result.primary_metric)?, @@ -1308,6 +1362,7 @@ fn experiment_close_output( "closed experiment {} on frontier {}", receipt.experiment.id, receipt.experiment.frontier_id ), + format!("hypothesis: {}", receipt.experiment.hypothesis_node_id), format!("candidate: {}", receipt.experiment.candidate_checkpoint_id), format!( "verdict: {}", @@ -1330,6 +1385,71 @@ fn experiment_close_output( ) } +fn experiment_open_output( + item: &OpenExperimentSummary, + operation: &'static str, + action: &'static str, +) -> Result { + let concise = json!({ + "experiment_id": item.id, + "frontier_id": item.frontier_id, + "base_checkpoint_id": item.base_checkpoint_id, + "hypothesis_node_id": item.hypothesis_node_id, + "title": item.title, + "summary": item.summary, + }); + detailed_tool_output( + &concise, + item, + [ + format!("{action} {}", item.id), + format!("frontier: {}", item.frontier_id), + format!("hypothesis: {}", item.hypothesis_node_id), + format!("base checkpoint: {}", item.base_checkpoint_id), + format!("title: {}", item.title), + item.summary + .as_ref() + .map(|summary| format!("summary: {summary}")) + .unwrap_or_else(|| "summary: ".to_owned()), + ] + .join("\n"), + None, + FaultStage::Worker, + operation, + ) +} + +fn experiment_list_output(items: &[OpenExperimentSummary]) -> Result { + let concise = items + .iter() + .map(|item| { + json!({ + "experiment_id": item.id, + "frontier_id": item.frontier_id, + "base_checkpoint_id": item.base_checkpoint_id, + "hypothesis_node_id": item.hypothesis_node_id, + "title": item.title, + "summary": item.summary, + }) + }) + .collect::>(); + let mut lines = vec![format!("{} open experiment(s)", items.len())]; + lines.extend(items.iter().map(|item| { + format!( + "{} {} | hypothesis={} | checkpoint={}", + item.id, item.title, item.hypothesis_node_id, item.base_checkpoint_id, + ) + })); + detailed_tool_output( + &concise, + &items, + lines.join("\n"), + None, + FaultStage::Worker, + "tools/call:experiment.list", + ) +} + fn metric_keys_output(keys: &[MetricKeySummary]) -> Result { let concise = keys .iter() @@ -1392,8 +1512,8 @@ fn metric_best_output( "order": item.order.as_str(), "experiment_id": item.experiment_id, "frontier_id": item.frontier_id, - "change_node_id": item.change_node_id, - "change_title": item.change_title, + "hypothesis_node_id": item.hypothesis_node_id, + "hypothesis_title": item.hypothesis_title, "verdict": metric_verdict_name(item.verdict), "candidate_checkpoint_id": item.candidate_checkpoint_id, "candidate_commit_hash": item.candidate_commit_hash, @@ -1412,7 +1532,7 @@ fn metric_best_output( item.key, item.value, item.source.as_str(), - item.change_title, + item.hypothesis_title, metric_verdict_name(item.verdict), item.candidate_commit_hash, item.candidate_checkpoint_id, @@ -1775,7 +1895,7 @@ fn filtered_payload_fields( fields: &Map, ) -> impl Iterator + '_ { fields.iter().filter(move |(name, _)| { - !matches!(class, NodeClass::Note | NodeClass::Research) || name.as_str() != "body" + !matches!(class, NodeClass::Note | NodeClass::Source) || name.as_str() != "body" }) } @@ -1817,7 +1937,7 @@ fn payload_value_preview(value: &Value) -> Value { } fn is_prose_node(class: NodeClass) -> bool { - matches!(class, NodeClass::Note | NodeClass::Research) + matches!(class, NodeClass::Note | NodeClass::Source) } fn truncated_inline_preview(text: &str, limit: usize) -> String { @@ -2017,6 +2137,14 @@ fn metric_value_from_wire(raw: WireMetricValue) -> Result Result { + Ok(ExperimentAnalysisDraft { + title: NonEmptyText::new(raw.title)?, + summary: NonEmptyText::new(raw.summary)?, + body: NonEmptyText::new(raw.body)?, + }) +} + fn metric_definition(store: &ProjectStore, key: &NonEmptyText) -> Result { store .list_metric_definitions() @@ -2071,12 +2199,11 @@ fn capture_code_snapshot(project_root: &Utf8Path) -> Result Result { match raw { "contract" => Ok(NodeClass::Contract), - "change" => Ok(NodeClass::Change), + "hypothesis" => Ok(NodeClass::Hypothesis), "run" => Ok(NodeClass::Run), "analysis" => Ok(NodeClass::Analysis), "decision" => Ok(NodeClass::Decision), - "research" => Ok(NodeClass::Research), - "enabling" => Ok(NodeClass::Enabling), + "source" => Ok(NodeClass::Source), "note" => Ok(NodeClass::Note), other => Err(crate::invalid_input(format!( "unknown node class `{other}`" @@ -2091,7 +2218,7 @@ fn parse_metric_unit_name(raw: &str) -> Result { fn parse_metric_source_name(raw: &str) -> Result { match raw { "run_metric" => Ok(MetricFieldSource::RunMetric), - "change_payload" => Ok(MetricFieldSource::ChangePayload), + "hypothesis_payload" => Ok(MetricFieldSource::HypothesisPayload), "run_payload" => Ok(MetricFieldSource::RunPayload), "analysis_payload" => Ok(MetricFieldSource::AnalysisPayload), "decision_payload" => Ok(MetricFieldSource::DecisionPayload), @@ -2234,14 +2361,11 @@ struct NodeCreateToolArgs { } #[derive(Debug, Deserialize)] -struct ChangeRecordToolArgs { +struct HypothesisRecordToolArgs { frontier_id: String, title: String, - summary: Option, + summary: String, body: String, - hypothesis: Option, - base_checkpoint_id: Option, - benchmark_suite: Option, #[serde(default)] annotations: Vec, #[serde(default)] @@ -2292,13 +2416,12 @@ struct QuickNoteToolArgs { } #[derive(Debug, Deserialize)] -struct ResearchRecordToolArgs { +struct SourceRecordToolArgs { frontier_id: Option, title: String, summary: String, body: String, - #[serde(default)] - tags: Vec, + tags: Option>, #[serde(default)] annotations: Vec, #[serde(default)] @@ -2355,10 +2478,27 @@ struct MetricBestToolArgs { } #[derive(Debug, Deserialize)] -struct ExperimentCloseToolArgs { +struct ExperimentOpenToolArgs { frontier_id: String, base_checkpoint_id: String, - change_node_id: String, + hypothesis_node_id: String, + title: String, + summary: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct ExperimentListToolArgs { + frontier_id: Option, +} + +#[derive(Debug, Deserialize)] +struct ExperimentReadToolArgs { + experiment_id: String, +} + +#[derive(Debug, Deserialize)] +struct ExperimentCloseToolArgs { + experiment_id: String, candidate_summary: String, run: WireRun, primary_metric: WireMetricValue, @@ -2368,7 +2508,7 @@ struct ExperimentCloseToolArgs { verdict: String, decision_title: String, decision_rationale: String, - analysis_node_id: Option, + analysis: Option, } #[derive(Debug, Deserialize)] @@ -2402,6 +2542,13 @@ struct WireRun { command: WireRunCommand, } +#[derive(Debug, Deserialize)] +struct WireAnalysis { + title: String, + summary: String, + body: String, +} + #[derive(Debug, Deserialize)] struct WireRunCommand { working_directory: Option, -- cgit v1.2.3