diff --git a/frontend/src/components/protocol/MetricFieldPicker.tsx b/frontend/src/components/protocol/MetricFieldPicker.tsx
new file mode 100644
index 00000000..66eb13d1
--- /dev/null
+++ b/frontend/src/components/protocol/MetricFieldPicker.tsx
@@ -0,0 +1,74 @@
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { projectionLabel, type FieldProjection, type MetricField } from '@/lib/metricFields'
+
+function sameProjection(a: FieldProjection, b: FieldProjection) {
+ return a.path === b.path && (a.transform ?? null) === (b.transform ?? null)
+}
+
+// Which part of a producer's output a custom metric records: the whole output
+// (no picks), or one field per metric. `multi` lets one pass create a metric
+// per ticked field; otherwise ticking a field replaces the current pick.
+export function MetricFieldPicker({ fields, picks, multi, status, onChange }: {
+ fields: MetricField[]
+ picks: FieldProjection[]
+ multi: boolean
+ status?: string
+ onChange: (picks: FieldProjection[]) => void
+}) {
+ const [filter, setFilter] = useState('')
+ const [customPath, setCustomPath] = useState('')
+ const [customCount, setCustomCount] = useState(false)
+ const options: { projection: FieldProjection; origin?: MetricField['origin'] }[] = [
+ ...fields.flatMap((field) => [
+ { projection: { path: field.path }, origin: field.origin },
+ ...(field.countable ? [{ projection: { path: field.path, transform: 'length' as const }, origin: field.origin }] : []),
+ ]),
+ // A typed path (or a saved one no longer offered) stays visible and untickable.
+ ...picks.filter((pick) => !fields.some((field) => field.path === pick.path)).map((projection) => ({ projection })),
+ ]
+ const needle = filter.trim().toLocaleLowerCase()
+ const visible = needle ? options.filter((option) => option.projection.path.toLocaleLowerCase().includes(needle)) : options
+ function toggle(projection: FieldProjection, checked: boolean) {
+ if (!checked) onChange(picks.filter((pick) => !sameProjection(pick, projection)))
+ else onChange(multi ? [...picks, projection] : [projection])
+ }
+ function addCustom() {
+ const path = customPath.trim()
+ if (!path) return
+ toggle(customCount ? { path, transform: 'length' } : { path }, true)
+ setCustomPath('')
+ setCustomCount(false)
+ }
+
+ return
+ Field
+
+ { if (checked === true) onChange([]) }} />
+ Whole output
+
+ {options.length > 8 && setFilter(event.target.value)} />}
+ {visible.length > 0 &&
+ {visible.map(({ projection, origin }) => {
+ const label = projectionLabel(projection)
+ return
+ sameProjection(pick, projection))} onCheckedChange={(checked) => toggle(projection, checked === true)} />
+ {label}
+ {origin === 'declared' && Output Parser }
+
+ })}
+
}
+ {status && {status}
}
+
+
+ Field path
+ setCustomPath(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); addCustom() } }} />
+
+
setCustomCount(checked === true)} />Count items
+
Add path
+
+
+}
diff --git a/frontend/src/components/protocol/MetricsEditor.autosave.test.tsx b/frontend/src/components/protocol/MetricsEditor.autosave.test.tsx
index 98f7e257..caef858d 100644
--- a/frontend/src/components/protocol/MetricsEditor.autosave.test.tsx
+++ b/frontend/src/components/protocol/MetricsEditor.autosave.test.tsx
@@ -31,9 +31,9 @@ describe('MetricsEditor autosave', () => {
await user.click(screen.getByRole('button', { name: 'Add metrics' }))
const dialog = await screen.findByRole('dialog', { name: 'Manage metrics' })
- const contextual = new Set(['tool_calls', 'tool_error_rate', 'critic_approvals', 'critic_rejections'])
+ const contextual = new Set(['tool_calls', 'tool_error_rate', 'critic_approvals', 'critic_rejections', 'critic_invocations', 'critic_rejections_partial', 'critic_rejections_full', 'revision_rounds'])
for (const entry of METRIC_CATALOG.filter((candidate) => candidate.kind === 'runtime')) {
- const checkbox = within(dialog).getByRole('checkbox', { name: new RegExp(`^${entry.name}`) })
+ const checkbox = within(dialog).getByRole('checkbox', { name: new RegExp(`^${entry.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`) })
if (contextual.has(entry.key)) expect(checkbox).not.toBeChecked()
else expect(checkbox).toBeChecked()
}
diff --git a/frontend/src/components/protocol/MetricsEditor.tsx b/frontend/src/components/protocol/MetricsEditor.tsx
index 82e37fd4..78b4ad9c 100644
--- a/frontend/src/components/protocol/MetricsEditor.tsx
+++ b/frontend/src/components/protocol/MetricsEditor.tsx
@@ -10,7 +10,23 @@ import { AGENT_OUTPUT_PRODUCER_ID, agentOutputBindingForMetric, agentOutputSourc
import { applyCustomMetricChange, type CustomMetricProducerConfig } from '@/lib/customMetrics'
import { pythonScriptBindingForMetric, pythonScriptSourceOptions } from '@/lib/pythonScriptMetrics'
import { mcpToolBindingForMetric, mcpToolSourceOptions } from '@/lib/mcpToolMetrics'
-import { localMetricReadinessPreview, removeMetricFromMeasurementPlan, upsertRuntimeMetric } from '@/lib/measurementPlan'
+import { bindingProjection, projectionLabel } from '@/lib/metricFields'
+import { toolStepBindingForMetric, toolStepSourceOptions } from '@/lib/toolStepMetrics'
+import {
+ NODE_RUNTIME_PRODUCER_ID,
+ bindingNodeIds,
+ nodeRuntimeBindingForMetric,
+ nodeRuntimeOutput,
+ nodeRuntimeSourceOptions,
+} from '@/lib/nodeRuntimeMetrics'
+import {
+ DERIVED_PRODUCER_IDS,
+ FEATURE_PIPELINE_PRODUCER_ID,
+ TOOL_STEP_PRODUCER_ID,
+ localMetricReadinessPreview,
+ removeMetricFromMeasurementPlan,
+ upsertRuntimeMetric,
+} from '@/lib/measurementPlan'
import { METRIC_CATALOG, makeCatalogMetric, makeCustomMetric, normalizeDesignMetrics, type MetricCatalogEntry } from '@/lib/metricCatalog'
import type { ProtocolGraph } from '@/types/protocols'
import type { DesignMetric, MeasurementPlan } from '@/types/experiments'
@@ -18,6 +34,15 @@ import { CustomMetricFlow, MetricNodeLabel, type MetricNodeDisplay } from './Cus
import { NodeInspectorDialog } from './NodeInspectorDialog'
import { useDialogAutosave } from './useDialogAutosave'
+const CRITIC_METRIC_KEYS = new Set([
+ 'critic_approvals',
+ 'critic_rejections',
+ 'critic_invocations',
+ 'critic_rejections_partial',
+ 'critic_rejections_full',
+ 'revision_rounds',
+])
+
function synchronizeMeasurementPlanMetrics(plan: MeasurementPlan | null, metrics: DesignMetric[]) {
if (!plan) return null
const definitionsById = new Map(plan.metrics.map((definition) => [definition.id, definition]))
@@ -25,13 +50,19 @@ function synchronizeMeasurementPlanMetrics(plan: MeasurementPlan | null, metrics
const definition = metric.id ? definitionsById.get(metric.id) : undefined
if (!definition) return []
definitionsById.delete(definition.id)
- return [{
+ const synchronized = {
...definition,
name: metric.name,
- direction: metric.direction,
- primary: metric.primary,
description: metric.description,
unit: metric.unit,
+ }
+ // A custom metric's definition is owned by its producer's upsert -- reported
+ // ones are just {id, name}; a node runtime one also carries the value type
+ // and aggregation its output needs -- so keep it rather than rebuild it.
+ return [metric.kind === 'custom' ? { ...definition, name: metric.name } : {
+ ...synchronized,
+ direction: metric.direction,
+ primary: metric.primary,
}]
})
return { ...plan, metrics: [...orderedDefinitions, ...definitionsById.values()] }
@@ -55,6 +86,11 @@ function customMetricProducerDisplay(
if (binding.producer_id === 'asaree.python_script') {
return { label: `${agent}:${nodeLabel(binding.config.script_node_id)}`, type: 'Script' }
}
+ if (binding.producer_id === TOOL_STEP_PRODUCER_ID) return { label: nodeLabel(binding.config.node_id), type: 'Tool Step' }
+ if (binding.producer_id === FEATURE_PIPELINE_PRODUCER_ID) return { label: 'DC → FTE → FS', type: 'Feature pipeline' }
+ if (binding.producer_id === NODE_RUNTIME_PRODUCER_ID) {
+ return { label: bindingNodeIds(binding).map(nodeLabel).join(' + ') || 'No nodes', type: 'Node runtime' }
+ }
if (binding.producer_id === 'asaree.mcp_tool') {
const toolName = typeof binding.config.tool_name === 'string' ? binding.config.tool_name : null
return {
@@ -79,6 +115,7 @@ function MetricsDialog({
metrics,
measurementPlan,
graph,
+ protocolId,
metricIssueById,
}: {
open: boolean
@@ -94,6 +131,7 @@ function MetricsDialog({
metrics: DesignMetric[]
measurementPlan: MeasurementPlan | null
graph: ProtocolGraph | undefined
+ protocolId?: string
metricIssueById: Map
}) {
const selectedKeySignature = selectedKeys.join('\u0000')
@@ -137,12 +175,14 @@ function MetricsDialog({
const hasPythonMetricCandidate = pythonScriptSourceOptions(graph).some((source) => !source.disabledReason)
const hasMcpMetricCandidate = mcpToolSourceOptions(graph).some((source) => !source.disabledReason)
const hasAgentMetricCandidate = agentOutputSourceOptions(graph).some((source) => !source.disabledReason)
+ const hasToolStepMetricCandidate = toolStepSourceOptions(graph).some((source) => !source.disabledReason)
+ const hasNodeRuntimeMetricCandidate = nodeRuntimeSourceOptions(graph).some((source) => !source.disabledReason)
const canvasMetricKeys = new Set(contextualMetricSuggestions(graph).map((suggestion) => suggestion.key))
const hasValidTool = canvasMetricKeys.has('tool_error_rate')
const hasCriticGate = graph?.nodes.some((node) => node.type === 'critic_gate') ?? false
const canvasCannotProduce = (key: string) =>
((key === 'tool_calls' || key === 'tool_error_rate') && !hasValidTool)
- || ((key === 'critic_approvals' || key === 'critic_rejections') && !hasCriticGate)
+ || (CRITIC_METRIC_KEYS.has(key) && !hasCriticGate)
const unavailableBuiltInKeys = capabilitiesLoading || capabilitiesUnavailable
? []
: builtInEntries.flatMap((entry) => {
@@ -150,7 +190,7 @@ function MetricsDialog({
return unavailable ? [entry.key] : []
})
const unavailableBuiltInKeySignature = unavailableBuiltInKeys.join('\u0000')
- const canCreateCustomMetric = hasAgentMetricCandidate || hasPythonMetricCandidate || hasMcpMetricCandidate
+ const canCreateCustomMetric = hasAgentMetricCandidate || hasPythonMetricCandidate || hasMcpMetricCandidate || hasToolStepMetricCandidate || hasNodeRuntimeMetricCandidate
const changedMetrics = metrics.map((metric) => customChanges.find((change) => change.metric.id === metric.id)?.metric ?? metric)
.concat(customChanges.filter((change) => !metrics.some((metric) => metric.id === change.metric.id)).map((change) => change.metric))
const customMetricById = new Map(changedMetrics.filter((metric) => metric.kind === 'custom' && metric.id).map((metric) => [metric.id!, metric]))
@@ -168,6 +208,8 @@ function MetricsDialog({
return agentOutputBindingForMetric(stagedPlan, metricId)
?? pythonScriptBindingForMetric(stagedPlan, metricId)
?? mcpToolBindingForMetric(stagedPlan, metricId)
+ ?? toolStepBindingForMetric(stagedPlan, metricId)
+ ?? nodeRuntimeBindingForMetric(stagedPlan, metricId)
}
function stagedReadinessFor(metric: DesignMetric) {
const local = localMetricReadinessPreview(metric, stagedPlan, graph)
@@ -316,7 +358,7 @@ function MetricsDialog({
? 'Not supported by this runtime.'
: (entry.key === 'tool_calls' || entry.key === 'tool_error_rate') && !hasValidTool
? 'Connect an enabled, configured tool to an active Agent to use this metric.'
- : (entry.key === 'critic_approvals' || entry.key === 'critic_rejections') && !hasCriticGate
+ : CRITIC_METRIC_KEYS.has(entry.key) && !hasCriticGate
? 'Add a Critic Gate to the canvas to use this metric.'
: undefined
const unavailableReasonId = `metric-unavailable-${entry.key}`
@@ -336,37 +378,44 @@ function MetricsDialog({
Custom metrics Create metrics from eligible nodes on this canvas.
- {!customMetricDraft &&
setCustomMetricDraft(makeCustomMetric({ name: '', description: '' }))}>Create custom metric }
+ {!customMetricDraft &&
setCustomMetricDraft(makeCustomMetric({ name: '' }))}>Create custom metric }
{visibleCustomMetrics.length > 0 && {visibleCustomMetrics.map((metric) => {
const binding = stagedBindingForMetric(metric.id)
const readiness = stagedReadinessFor(metric)
const selectedPosition = metric.id ? customMetricIds.indexOf(metric.id) : -1
const producerDisplay = customMetricProducerDisplay(binding, graph)
+ const projection = bindingProjection(binding, metric.id)
+ const runtimeOutput = binding?.producer_id === NODE_RUNTIME_PRODUCER_ID
+ ? nodeRuntimeOutput(Object.keys(binding.outputs)[0])
+ : undefined
return
{producerDisplay
?
: Custom metric }
{metric.name}
+ {projection && {projectionLabel(projection)} }
+ {runtimeOutput && {runtimeOutput.key} }
{!readiness.ready && {readiness.detail} }
moveStagedCustomMetric(metric, -1)}>
moveStagedCustomMetric(metric, 1)}>
- {binding && metric.valueType === 'opaque' &&
setCustomMetricDraft(metric)}>Edit }
+ {binding && (binding.producer_id === TOOL_STEP_PRODUCER_ID || !DERIVED_PRODUCER_IDS.has(binding.producer_id)) &&
setCustomMetricDraft(metric)}>Edit }
setCustomMetricPendingDelete(metric)}>
})}
}
{stagedOrderAnnouncement}
{visibleCustomMetrics.length === 0 && !customMetricDraft && No custom metrics yet.
}
- {!canCreateCustomMetric && !customMetricDraft && Create custom metrics from an enabled Agent, Script, or configured MCP Tool.
}
+ {!canCreateCustomMetric && !customMetricDraft && Create custom metrics from an enabled Agent, Tool Step, Script, or configured MCP Tool.
}
{customMetricDraft && { setCustomMetricDraft(undefined); setCustomMetricDirty(false) }}
onDirtyChange={setCustomMetricDirty}
@@ -401,11 +450,13 @@ export function MetricsEditor({
metrics,
measurementPlan,
graph,
+ protocolId,
onChange,
onApplyMetrics,
disabled = false,
}: {
experimentId?: string
+ protocolId?: string
metrics: DesignMetric[]
measurementPlan: MeasurementPlan | null
graph: ProtocolGraph | undefined
@@ -502,7 +553,7 @@ export function MetricsEditor({
...nextPlan,
metrics: nextPlan.metrics.map((definition) => {
const designMetric = byId.get(definition.id)
- return designMetric ? { ...definition, primary: designMetric.primary } : definition
+ return designMetric?.kind === 'runtime' ? { ...definition, primary: designMetric.primary } : definition
}),
}
return { metrics: nextMetrics, measurementPlan: synchronizedPlan }
@@ -539,6 +590,7 @@ export function MetricsEditor({
metrics={normalized}
measurementPlan={measurementPlan}
graph={graph}
+ protocolId={protocolId}
metricIssueById={serverIssueByMetricId}
onApply={async (selectedKeys, customChanges, customMetricIds) => {
let draft = builtInDraft(selectedKeys)
diff --git a/frontend/src/components/protocol/ProtocolCanvas.test.tsx b/frontend/src/components/protocol/ProtocolCanvas.test.tsx
index 784f4afa..6eb7ddce 100644
--- a/frontend/src/components/protocol/ProtocolCanvas.test.tsx
+++ b/frontend/src/components/protocol/ProtocolCanvas.test.tsx
@@ -15,6 +15,8 @@ import {
defaultReasonActPatternNodeData,
defaultScriptNodeData,
type ProtocolGraph,
+ type ProtocolRun,
+ type TestRun,
} from '@/types/protocols'
import { ProtocolCanvas } from './ProtocolCanvas'
@@ -44,6 +46,58 @@ function renderCanvas(graph: ProtocolGraph, experimentId: string | null = null)
return { client, ...result }
}
+function protocolRun(overrides: Partial = {}): ProtocolRun {
+ return {
+ id: 'production-run',
+ protocol_id: 'protocol-1',
+ status: 'running',
+ node_runs: {},
+ conversation: null,
+ error: null,
+ replicate_label: 'replicate-1',
+ replicate_result_id: 'replicate-result-1',
+ factor_values: {},
+ design_revision_id: 'design-revision-1',
+ protocol_revision_id: 'protocol-revision-1',
+ target_node_id: null,
+ cancel_requested_at: null,
+ created_at: '2026-10-01T12:00:00Z',
+ updated_at: '2026-10-01T12:00:00Z',
+ observations: [],
+ artifacts: [],
+ ...overrides,
+ }
+}
+
+function testRun(overrides: Partial = {}): TestRun {
+ return {
+ id: 'test-run',
+ protocol_id: 'protocol-1',
+ status: 'completed',
+ error: null,
+ protocol_revision_id: 'protocol-revision-1',
+ created_at: '2026-09-30T12:00:00Z',
+ updated_at: '2026-09-30T12:01:00Z',
+ observations: [],
+ artifacts: [],
+ conversation: null,
+ tested_published_revision: null,
+ freshness: { out_of_date: false, reasons: [] },
+ resources: {
+ task: { duration_seconds: null, cost_usd: null },
+ evaluation: { duration_seconds: null, cost_usd: null },
+ total: { duration_seconds: null, cost_usd: null },
+ },
+ execution_summary: {
+ node_runs: {},
+ started_at: null,
+ completed_at: '2026-09-30T12:01:00Z',
+ cancel_requested_at: null,
+ },
+ ...overrides,
+ }
+}
+
describe('ProtocolCanvas connector adds', () => {
afterEach(() => vi.unstubAllGlobals())
@@ -166,6 +220,39 @@ describe('ProtocolCanvas connector adds', () => {
expect(document.querySelector('[data-nodeid="model-1"][data-handleid="model"]')).toHaveClass('connectable')
})
+ it('draws Tool Step main-flow arrows under Sequential and Critic Gate strategies', async () => {
+ const graph: ProtocolGraph = {
+ nodes: [
+ { id: 'agent-1', type: 'agent', position: { x: 100, y: 100 }, data: defaultAgentNodeData('Writer') },
+ {
+ id: 'tool-step-1',
+ type: 'tool_step',
+ position: { x: 400, y: 100 },
+ data: { label: 'Fetch', config: { tool_name: '', arguments: {}, hash_checks: {}, timeout_seconds: null } },
+ },
+ { id: 'agent-2', type: 'agent', position: { x: 700, y: 100 }, data: defaultAgentNodeData('Reviewer') },
+ ],
+ edges: [
+ { id: 'into-tool', source: 'agent-1', target: 'tool-step-1' },
+ { id: 'out-of-tool', source: 'tool-step-1', target: 'agent-2' },
+ ],
+ }
+
+ const sequential = renderCanvas(graph)
+ await waitFor(() => expect(sequential.container.querySelector('.react-flow__arrowhead')).toBeInTheDocument())
+ sequential.unmount()
+
+ vi.spyOn(experimentsApi, 'get').mockResolvedValue({
+ id: 'experiment-1', name: 'Experiment', description: null, hypothesis: null, design_type: 'factorial', task_brief: null,
+ design_spec: { factors: [], metrics: [], coordination_strategy: { slug: 'critic_gate', params: {} } },
+ measurement_plan: null, dataset_ids: [], dataset_id: null, locked_at: null,
+ locked_protocol_revision_id: null, locked_design_spec: null, locked_measurement_plan: null,
+ created_at: '', updated_at: '', archived_at: null,
+ })
+ const critic = renderCanvas(graph, 'experiment-1')
+ await waitFor(() => expect(critic.container.querySelector('.react-flow__arrowhead')).toBeInTheDocument())
+ })
+
it('adds a connector-only Sub-Agent from an Agent', async () => {
const user = userEvent.setup()
const { client } = renderCanvas({
@@ -306,4 +393,39 @@ describe('ProtocolCanvas connector adds', () => {
expect(screen.queryByText('Bind to a field on the canvas')).not.toBeInTheDocument()
})
+ it('keeps the Test Run label when a production replicate is running', async () => {
+ const run = protocolRun()
+ vi.mocked(protocolsApi.listRuns).mockResolvedValue([run])
+ vi.spyOn(protocolsApi, 'getRun').mockResolvedValue(run)
+
+ renderCanvas({ nodes: [], edges: [] })
+
+ await screen.findByRole('button', { name: 'Stop' })
+ expect(screen.getByRole('button', { name: 'Test Run' })).toBeDisabled()
+ expect(screen.queryByRole('button', { name: 'Test Run running…' })).not.toBeInTheDocument()
+ })
+
+ it('stops the active production replicate instead of a completed Test Run', async () => {
+ const user = userEvent.setup()
+ const run = protocolRun()
+ vi.mocked(protocolsApi.listRuns).mockResolvedValue([run])
+ vi.spyOn(protocolsApi, 'getRun').mockResolvedValue(run)
+ vi.spyOn(protocolsApi, 'getLatestTestRun').mockResolvedValue(testRun())
+ const cancelRun = vi.spyOn(protocolsApi, 'cancelRun').mockResolvedValue({
+ ...run,
+ cancel_requested_at: '2026-10-01T12:00:30Z',
+ })
+ vi.spyOn(experimentsApi, 'get').mockResolvedValue({
+ id: 'experiment-1', name: 'Experiment', description: null, hypothesis: null, design_type: 'factorial', task_brief: null,
+ design_spec: { factors: [], metrics: [] }, measurement_plan: null, dataset_ids: [], dataset_id: null,
+ locked_at: null, locked_protocol_revision_id: null, locked_design_spec: null, locked_measurement_plan: null,
+ created_at: '', updated_at: '', archived_at: null,
+ })
+
+ renderCanvas({ nodes: [], edges: [] }, 'experiment-1')
+
+ await user.click(await screen.findByRole('button', { name: 'Stop' }))
+ expect(cancelRun).toHaveBeenCalledWith('protocol-1', 'production-run')
+ })
+
})
diff --git a/frontend/src/components/protocol/ProtocolCanvas.tsx b/frontend/src/components/protocol/ProtocolCanvas.tsx
index 41db3cee..3e65e3bd 100644
--- a/frontend/src/components/protocol/ProtocolCanvas.tsx
+++ b/frontend/src/components/protocol/ProtocolCanvas.tsx
@@ -33,6 +33,7 @@ import {
defaultAnthropicModelNodeData,
defaultAzureFoundryModelNodeData,
defaultCriticGateNodeData,
+ defaultToolStepNodeData,
defaultDatasetNodeData,
defaultLocalModelNodeData,
defaultMcpToolNodeData,
@@ -47,6 +48,7 @@ import {
import type {
AgentNodeData,
CriticGateNodeData,
+ ToolStepNodeData,
DatasetNodeData,
ModelNodeData,
McpToolNodeData,
@@ -75,6 +77,7 @@ import { AgentNodeInspector } from './AgentNodeInspector'
import { agentTracedLabel, factorBoundField, revealsHiddenMcpServers, toolFactorServerId, unboundBindableFields, type UnboundField } from './bindableFields'
import { CanvasControls } from './CanvasControls'
import { CriticGateNodeInspector } from './CriticGateNodeInspector'
+import { ToolStepNodeInspector } from './ToolStepNodeInspector'
import { DatasetNodeInspector } from './DatasetNodeInspector'
import { DeleteNodeConfirmDialog } from './DeleteNodeConfirmDialog'
import { DEFAULT_ZOOM } from './constants'
@@ -110,7 +113,7 @@ import {
} from './ProtocolCanvasContext'
import { ReasonActPatternNodeInspector } from './ReasonActPatternNodeInspector'
import { RunConfirmDialog } from './RunConfirmDialog'
-import { ReopenTestRunResultsButton, TestRunResults } from './TestRunResults'
+import { describeRun, ReopenTestRunResultsButton, TestRunResults } from './TestRunResults'
import type { RunScope } from './runSummary'
import { ScriptNodeInspector } from './ScriptNodeInspector'
import { SingleAgentBaselinePatternNodeInspector } from './SingleAgentBaselinePatternNodeInspector'
@@ -122,10 +125,10 @@ import { OkfDocumentNodeInspector } from './OkfDocumentNodeInspector'
import { SkillBrowserPanel } from './SkillBrowserPanel'
import { SKILL_BROWSE, nodeDataForSkill } from './skillCatalog'
import { SkillNodeInspector } from './SkillNodeInspector'
-import { ConversationTranscript } from './ConversationTranscript'
import { InteractEdge } from './edges/InteractEdge'
import { AgentNode } from './nodes/AgentNode'
import { CriticGateNode } from './nodes/CriticGateNode'
+import { ToolStepNode } from './nodes/ToolStepNode'
import { DatasetNode } from './nodes/DatasetNode'
import { ModelNode } from './nodes/ModelNode'
import { McpClientToolNode } from './nodes/McpClientToolNode'
@@ -167,6 +170,7 @@ const NODE_TYPES = {
// that distinguishes it. Same data, same inspector.
mcp_client_tool: McpClientToolNode,
critic_gate: CriticGateNode,
+ tool_step: ToolStepNode,
// All five LLM provider types render through the same component -- it
// derives icon/accent/placeholder from data.config.provider, not from
// which of these five keys it was registered under.
@@ -217,6 +221,7 @@ function defaultDataFor(nodeType: string): ProtocolNode['data'] {
// it.
if (nodeType === 'mcp_tool') return defaultMcpToolNodeData()
if (nodeType === 'critic_gate') return defaultCriticGateNodeData()
+ if (nodeType === 'tool_step') return defaultToolStepNodeData()
if (nodeType === 'model_anthropic') return defaultAnthropicModelNodeData()
if (nodeType === 'model_openai') return defaultOpenAiModelNodeData()
if (nodeType === 'model_azure_foundry') return defaultAzureFoundryModelNodeData()
@@ -280,6 +285,10 @@ function datasetIdsInGraph(nodes: Node[], factors: DesignFactor[] = EMPTY_FACTOR
// callable capability of its own, so it shares Tool's slot rather than
// getting a dedicated one. Dataset used to share it too, but now has its
// own slot -- what an agent operates ON, not a capability it operates WITH.
+// What MainEdgeAddStub and an edge's insert button can add: the main-flow
+// steps, i.e. an Agent's turn or a Tool Step's fixed call.
+const MAIN_FLOW_NODE_TYPES = ['agent', 'tool_step']
+
const CONNECTOR_PANEL_INFO: Record = {
model: { allowedTypes: MODEL_NODE_TYPES, title: 'Add Model' },
tool: { allowedTypes: [MCP_SERVER_BROWSE, 'script'], title: 'Add Tool' },
@@ -543,7 +552,7 @@ export const ProtocolCanvas = forwardRef(null)
const [testResultsOpen, setTestResultsOpen] = useState(false)
- const [playResultsOpen, setPlayResultsOpen] = useState(false)
+ const [runResultsOpen, setRunResultsOpen] = useState(false)
const paneRef = useRef(null)
const { screenToFlowPosition, fitView } = useReactFlow()
@@ -635,7 +644,7 @@ export const ProtocolCanvas = forwardRef protocolsApi.testRun(protocolId),
onSuccess: (run) => {
setRunId(run.id)
- setPlayResultsOpen(false)
+ setRunResultsOpen(false)
setTestResultsOpen(true)
queryClient.setQueryData(['protocols', protocolId, 'test-run'], run)
queryClient.invalidateQueries({ queryKey: ['experiments', experimentId] })
@@ -652,7 +661,7 @@ export const ProtocolCanvas = forwardRef {
setRunId(run.id)
setTestResultsOpen(false)
- setPlayResultsOpen(true)
+ setRunResultsOpen(true)
},
})
@@ -680,7 +689,11 @@ export const ProtocolCanvas = forwardRef= activeProtocolRun.createdAt ? activeTestRun : activeProtocolRun
+ : activeTestRun ?? activeProtocolRun
// Stop button -- only raises cancel_requested_at; run_protocol's own node
// loop (polled between nodes, not mid-node) is what actually honors it.
@@ -743,14 +771,15 @@ export const ProtocolCanvas = forwardRef protocolsApi.cancelRun(protocolId, testRunQuery.data?.id ?? runId!),
+ mutationFn: () => protocolsApi.cancelRun(protocolId, cancellableRun!.id),
onSuccess: () => {
runQuery.refetch()
testRunQuery.refetch()
},
})
- const cancelRequested = !!testRunQuery.data?.execution_summary.cancel_requested_at || !!runQuery.data?.cancel_requested_at
- const showStandaloneConversation = !!runQuery.data?.conversation && runQuery.data.id !== testRunQuery.data?.id
+ const cancelRequested = !!cancellableRun?.cancelRequested
+ const runResultLabel = runQuery.data?.target_node_id ? 'Play Results' : 'Latest Run'
+ const runResultTitle = runQuery.data?.replicate_label ? `Cell Run · ${runQuery.data.replicate_label}` : runResultLabel
// A connected execution-pattern node must never be deletable directly
// (Backspace/Delete key, NodeHoverToolbar's trash icon -- both go through
@@ -1013,6 +1042,9 @@ export const ProtocolCanvas = forwardRef nodeDisplayNames(nodes), [nodes])
+ const runResultKind = runQuery.data?.target_node_id
+ ? `Play of ${nodeNames.get(runQuery.data.target_node_id) ?? runQuery.data.target_node_id}`
+ : runQuery.data?.replicate_label ? 'Cell run' : 'Graph run'
const nodeTypes = useMemo(() => new Map(nodes.map((node) => [node.id, node.type ?? ''])), [nodes])
// The experiment's declared coordination strategy, which decides what the
@@ -1025,6 +1057,7 @@ export const ProtocolCanvas = forwardRef(() => {
- if (!isSequential) return edges
- const agentIds = new Set(nodes.filter((node) => node.type === 'agent').map((node) => node.id))
+ if (!isDirectedFlow) return edges
return edges.map((edge) => {
- const isAgentFlow =
- !edge.sourceHandle &&
- !edge.targetHandle &&
- agentIds.has(edge.source) &&
- agentIds.has(edge.target)
- if (!isAgentFlow) return edge
+ if (edge.sourceHandle || edge.targetHandle) return edge
return {
...edge,
- data: { ...edge.data, sequentialAgentFlow: true },
+ data: { ...edge.data, directedFlow: true },
markerEnd: {
type: MarkerType.ArrowClosed,
width: 12,
@@ -1059,7 +1086,7 @@ export const ProtocolCanvas = forwardRef()
for (const metricId of Object.values(producer.outputs)) {
@@ -1560,9 +1589,9 @@ export const ProtocolCanvas = forwardRef n.id === originId)
@@ -1571,24 +1600,28 @@ export const ProtocolCanvas = forwardRef n.position), desired)
const newId = newNodeId()
- const newNode: Node = { id: newId, type: 'agent', position, data: defaultDataFor('agent') }
+ const newNode: Node = { id: newId, type: nodeType, position, data: dataOverride ?? defaultDataFor(nodeType) }
const mainEdge: Edge =
direction === 'outgoing'
? { id: newNodeId(), source: originId, target: newId }
: { id: newNodeId(), source: newId, target: originId }
- const { patternNode, patternEdge } = agentDefaultPattern(newId, position, nodes.map((n) => n.position))
- setNodes((nds) => nds.concat(newNode, patternNode))
- setEdges((eds) => eds.concat(mainEdge, patternEdge))
+ const pattern = nodeType === 'agent' ? agentDefaultPattern(newId, position, nodes.map((n) => n.position)) : null
+ setNodes((nds) => nds.concat(newNode, ...(pattern ? [pattern.patternNode] : [])))
+ setEdges((eds) => eds.concat(mainEdge, ...(pattern ? [pattern.patternEdge] : [])))
setPendingMainEdgeAdd(null)
setAddPanelOpen(false)
setSelectedNodeId(newId)
return
}
if (pendingEdgeInsert) {
- // Splits the original edge into origin->newAgent->target -- always an
- // Agent (AddNodePanel restricted to ['agent'] below), positioned at
+ // Splits the original edge into origin->new->target -- an Agent or a
+ // Tool Step (AddNodePanel restricted to MAIN_FLOW_NODE_TYPES below), positioned at
// the midpoint of the two nodes the removed edge used to connect.
const { edgeId, source, target } = pendingEdgeInsert
+ // The first half keeps the split edge's handoff: same sender, so the
+ // narrowed fields still apply. The second half starts at Full output --
+ // the new node has no Output Parser to narrow yet.
+ const handoff = edges.find((e) => e.id === edgeId)?.data?.handoff
const sourceNode = nodes.find((n) => n.id === source)
const targetNode = nodes.find((n) => n.id === target)
const desired =
@@ -1597,16 +1630,16 @@ export const ProtocolCanvas = forwardRef n.position), desired)
const newId = newNodeId()
- const newNode: Node = { id: newId, type: 'agent', position, data: defaultDataFor('agent') }
- const { patternNode, patternEdge } = agentDefaultPattern(newId, position, nodes.map((n) => n.position))
- setNodes((nds) => nds.concat(newNode, patternNode))
+ const newNode: Node = { id: newId, type: nodeType, position, data: dataOverride ?? defaultDataFor(nodeType) }
+ const pattern = nodeType === 'agent' ? agentDefaultPattern(newId, position, nodes.map((n) => n.position)) : null
+ setNodes((nds) => nds.concat(newNode, ...(pattern ? [pattern.patternNode] : [])))
setEdges((eds) =>
eds
.filter((e) => e.id !== edgeId)
.concat(
- { id: newNodeId(), source, target: newId },
+ { id: newNodeId(), source, target: newId, ...(handoff ? { data: { handoff } } : {}) },
{ id: newNodeId(), source: newId, target },
- patternEdge,
+ ...(pattern ? [pattern.patternEdge] : []),
),
)
setPendingEdgeInsert(null)
@@ -1867,6 +1900,17 @@ export const ProtocolCanvas = forwardRef n.id === selectedNodeId) ?? null
+ // What's wired into a selected Tool Step's Tool connector: its MCP Tool
+ // node (the callable tools and their server) and any Script node.
+ const toolStepSources =
+ selectedNode?.type === 'tool_step'
+ ? edges
+ .filter((edge) => edge.target === selectedNode.id && edge.targetHandle === 'tool')
+ .map((edge) => nodes.find((node) => node.id === edge.source))
+ : []
+ const toolStepMcpNodes = toolStepSources.filter(
+ (node): node is typeof node & { data: McpToolNodeData } => !!node && MCP_TOOL_NODE_TYPES.includes(node.type ?? ''),
+ )
// Computed once per selection change, not per FactorBindableField -- an
// Model/Tool/Memory node's plain label alone doesn't say which agent it
// belongs to (see bindableFields.ts's own comment), so every inspector
@@ -1908,6 +1952,7 @@ export const ProtocolCanvas = forwardRef cancelMutation.mutate()}
>
@@ -2089,13 +2134,13 @@ export const ProtocolCanvas = forwardRef
- {isRunning ? 'Test Run running…' : 'Test Run'}
+ {testRunIsRunning ? 'Test Run running…' : 'Test Run'}
{testRunQuery.data && !testResultsOpen && (
- setTestResultsOpen(true)} refresh={() => { testRunQuery.refetch() }} />
+ setTestResultsOpen(true)} refresh={() => { testRunQuery.refetch() }} />
)}
- {playResult && !playResultsOpen && (
- setPlayResultsOpen(true)} refresh={() => { runQuery.refetch() }} />
+ {runResult && !runResultsOpen && (
+ setRunResultsOpen(true)} refresh={() => { runQuery.refetch() }} />
)}
setTestResultsOpen(false)} />
)}
- {playResultsOpen && playResult && (
- setPlayResultsOpen(false)} />
+ {runResultsOpen && runResult && (
+ setRunResultsOpen(false)} />
)}
- {/* One top-left column rather than two independently-positioned
- overlays: the lock badge and the transcript are both anchored
- here, and stacking them is what keeps them from landing on top of
- each other. `items-start` so each stays its own natural width.
- Top-LEFT because bottom-right is the MiniMap's corner and
- top-right is the Add/menu buttons'. The column itself is
- `pointer-events-none` so the empty space it reserves stays part of
- the canvas -- panning and node drags must still work under it --
- and each child turns events back on for itself. */}
- {(experimentLocked || showStandaloneConversation) && (
-
- {experimentLocked && (
-
- Canvas locked
-
- )}
- {showStandaloneConversation && runQuery.data?.conversation && (
-
- )}
+ {experimentLocked && (
+
+ Canvas locked
)}
@@ -2184,16 +2213,16 @@ export const ProtocolCanvas = forwardRef
@@ -2215,6 +2244,18 @@ export const ProtocolCanvas = forwardRef setSelectedNodeId(null)}
/>
+ ) : selectedNode?.type === 'tool_step' ? (
+ node.data.config?.tool_names ?? [])}
+ serverId={toolStepMcpNodes[0]?.data.config?.server_id ?? null}
+ hasScript={toolStepSources.some((node) => node?.type === 'script')}
+ nodeRun={latestNodeRuns?.[selectedNode.id]}
+ onChange={updateNodeData}
+ onDelete={requestDeleteNode}
+ onClose={() => setSelectedNodeId(null)}
+ />
) : MODEL_NODE_TYPES.includes(selectedNode?.type ?? '') ? (
publishAndRunMutation.mutate()}
- confirmLabel={pendingRunConfirm.type === 'graph' ? 'Start Test Run' : undefined}
+ confirmLabel={pendingRunConfirm.type === 'graph' ? 'Test Run' : undefined}
/>
)}
{factorPickerNodeId && (
diff --git a/frontend/src/components/protocol/RunConfirmDialog.test.tsx b/frontend/src/components/protocol/RunConfirmDialog.test.tsx
index 8f116506..cee5526f 100644
--- a/frontend/src/components/protocol/RunConfirmDialog.test.tsx
+++ b/frontend/src/components/protocol/RunConfirmDialog.test.tsx
@@ -80,4 +80,44 @@ describe('RunConfirmDialog', () => {
expect(screen.queryByText(/stopped at this iteration limit/)).not.toBeInTheDocument()
})
+
+ it('offers only publish-and-run for a canvas that was never published', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Canvas has not been published')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Publish & Run replicate' })).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: /published v/ })).not.toBeInTheDocument()
+ })
+
+ it('names the chosen action on both publish choices', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('button', { name: 'Run all cells (published v3)' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Publish & Run all cells' })).toBeInTheDocument()
+ })
})
diff --git a/frontend/src/components/protocol/RunConfirmDialog.tsx b/frontend/src/components/protocol/RunConfirmDialog.tsx
index 55a85823..4764da04 100644
--- a/frontend/src/components/protocol/RunConfirmDialog.tsx
+++ b/frontend/src/components/protocol/RunConfirmDialog.tsx
@@ -77,6 +77,11 @@ export function RunConfirmDialog({
confirmError?: string | null
}) {
const summary = summarizeRun(nodes, edges, scope)
+ // A never-published canvas has no snapshot to fall back on, so publishing is
+ // the only way forward; a published one with edits offers both choices.
+ const neverPublished = publishedRevision === null && !!onPublishAndRun
+ const needsPublishChoice = (hasUnpublishedChanges || neverPublished) && !!onPublishAndRun
+ const actionLabel = confirmLabel ?? 'Run'
const allIssues = findNodeConfigIssues(nodes, edges, queryClient, truncatedCaps)
// A node-scoped run only ever touches that node plus its own directly
// wired dependencies -- an issue on some unrelated node elsewhere on the
@@ -163,11 +168,13 @@ export function RunConfirmDialog({
{additionalContent}
{confirmError && {confirmError}
}
- {hasUnpublishedChanges && (
+ {needsPublishChoice && (
-
Canvas has unpublished changes
+
{neverPublished ? 'Canvas has not been published' : 'Canvas has unpublished changes'}
- This run will use published canvas v{publishedRevision}. Publish the latest canvas first to run the changes you are viewing.
+ {neverPublished
+ ? 'Runs always use a published canvas. Publish the canvas you are viewing to run it.'
+ : `This run will use published canvas v${publishedRevision}. Publish the latest canvas first to run the changes you are viewing.`}
{publishError &&
{publishError}
}
@@ -205,13 +212,15 @@ export function RunConfirmDialog({
Cancel
- {hasUnpublishedChanges ? (
+ {needsPublishChoice ? (
<>
-
- Test Run published v{publishedRevision}
-
+ {!neverPublished && (
+
+ {actionLabel} (published v{publishedRevision})
+
+ )}
- {isPublishing ? 'Publishing…' : 'Publish & Test Run'}
+ {isPublishing ? 'Publishing…' : isConfirming ? 'Starting…' : `Publish & ${actionLabel}`}
>
) : (
diff --git a/frontend/src/components/protocol/RunsTab.tsx b/frontend/src/components/protocol/RunsTab.tsx
index 7a7130df..912bf051 100644
--- a/frontend/src/components/protocol/RunsTab.tsx
+++ b/frontend/src/components/protocol/RunsTab.tsx
@@ -398,20 +398,17 @@ export function RunAllCellsButton({
(pendingReplicateCount === 0 && previouslyRunCells.length === 0) ||
regenerationRequired ||
unboundFactors.length > 0 ||
- bindingDiscrepancies.length > 0 ||
- !protocol.published_revision_id
+ bindingDiscrepancies.length > 0
const blockedTitle = regenerationRequired
? 'Design changed — review and regenerate before running the experiment.'
: unboundFactors.length > 0
? `Rebind or remove: ${unboundFactors.join(', ')}.`
: bindingDiscrepancies.length > 0
? 'Canvas values and generated factor levels disagree. Resolve the warning in Runs before starting.'
- : !protocol?.published_revision_id
- ? 'Publish a valid canvas before running cells.'
- : replicates.length === 0
- ? 'Generate design first — there are no cells to run yet.'
- : pendingReplicateCount === 0 && previouslyRunCells.length === 0
- ? 'All selected replicates are already running.'
+ : replicates.length === 0
+ ? 'Generate design first — there are no cells to run yet.'
+ : pendingReplicateCount === 0 && previouslyRunCells.length === 0
+ ? 'All selected replicates are already running.'
: undefined
const runnableReplicateCount = pendingReplicateCount + selectedReruns.size
const actionLabel = label ?? (hasCompletedRun ? 'Re-run all cells' : 'Run all cells')
@@ -658,15 +655,13 @@ function RunReplicateButton({
runMutation.mutate()
},
})
- const runBlocked = !protocol || regenerationRequired || unboundFactors.length > 0 || bindingDiscrepancies.length > 0 || !protocol.published_revision_id
+ const runBlocked = !protocol || regenerationRequired || unboundFactors.length > 0 || bindingDiscrepancies.length > 0
const blockedTitle = regenerationRequired
? 'Design changed — review and regenerate before running this replicate.'
: unboundFactors.length > 0
? `Rebind or remove: ${unboundFactors.join(', ')}.`
: bindingDiscrepancies.length > 0
? 'Canvas values and generated factor levels disagree. Resolve the warning in Runs before starting.'
- : !protocol?.published_revision_id
- ? 'Publish a valid canvas before running replicates.'
: undefined
const errorMessage = runMutation.error instanceof ApiError && typeof runMutation.error.detail === 'string'
? runMutation.error.detail
diff --git a/frontend/src/components/protocol/TestRunResults.test.tsx b/frontend/src/components/protocol/TestRunResults.test.tsx
index 2f6e9741..63299072 100644
--- a/frontend/src/components/protocol/TestRunResults.test.tsx
+++ b/frontend/src/components/protocol/TestRunResults.test.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react'
-import { render, screen, within } from '@testing-library/react'
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import type { TestRun } from '@/types/protocols'
@@ -61,6 +61,21 @@ describe('TestRunResults', () => {
expect(screen.getByText('Analyze this.')).toBeInTheDocument()
})
+ it('minimizes and restores the agent conversation flow', async () => {
+ const user = userEvent.setup()
+ render( )
+
+ await user.click(screen.getByRole('button', { name: 'Minimize Agent conversation flow' }))
+ expect(screen.getByText('Analyze this.').closest('div')).toHaveClass('hidden')
+ await user.click(screen.getByRole('button', { name: 'Restore Agent conversation flow' }))
+ expect(screen.getByText('Analyze this.').closest('div')).not.toHaveClass('hidden')
+ })
+
it('orders executable nodes by the canvas and omits configuration-only nodes', () => {
render( {
expect(screen.getByLabelText('Test Run Results')).toBeInTheDocument()
expect(screen.getByText('Task execution in progress…')).toBeInTheDocument()
})
+
+ it('minimizes, restores, and moves the results window', async () => {
+ const user = userEvent.setup()
+ render( )
+
+ await user.click(screen.getByRole('button', { name: 'Minimize Test Run Results' }))
+ expect(screen.queryByText('Task execution in progress…')).not.toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'Restore Test Run Results' }))
+ expect(screen.getByText('Task execution in progress…')).toBeInTheDocument()
+
+ const panel = screen.getByLabelText('Test Run Results')
+ const handle = screen.getByLabelText('Move Test Run Results')
+ fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 100, clientY: 100 })
+ fireEvent.pointerMove(handle, { pointerId: 1, clientX: 140, clientY: 125 })
+ fireEvent.pointerUp(handle, { pointerId: 1 })
+ expect(panel).toHaveStyle({ transform: 'translate3d(40px, 25px, 0)' })
+ })
+
+ it('keeps the drag header inside the canvas when a top-aligned minimized window is restored', async () => {
+ const user = userEvent.setup()
+ render( )
+ const panel = screen.getByLabelText('Test Run Results')
+ const handle = screen.getByLabelText('Move Test Run Results')
+ const boundary = document.createElement('div')
+ Object.defineProperty(panel, 'offsetParent', { configurable: true, get: () => boundary })
+ boundary.getBoundingClientRect = () => ({
+ x: 0, y: 0, top: 0, left: 0, right: 1000, bottom: 800, width: 1000, height: 800, toJSON: () => ({}),
+ }) as DOMRect
+ panel.getBoundingClientRect = () => {
+ const minimized = screen.queryByRole('button', { name: 'Restore Test Run Results' }) !== null
+ const match = panel.style.transform.match(/translate3d\((-?\d+)px, (-?\d+)px/)
+ const offsetX = Number(match?.[1] ?? 0)
+ const offsetY = Number(match?.[2] ?? 0)
+ const height = minimized ? 80 : 600
+ const top = (minimized ? 720 : 200) + offsetY
+ const left = 300 + offsetX
+ return { x: left, y: top, top, left, right: left + 700, bottom: top + height, width: 700, height, toJSON: () => ({}) } as DOMRect
+ }
+
+ await user.click(screen.getByRole('button', { name: 'Minimize Test Run Results' }))
+ await waitFor(() => expect(panel).toHaveStyle({ transform: 'translate3d(0px, -520px, 0)' }))
+ fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 300, clientY: 200 })
+ fireEvent.pointerMove(handle, { pointerId: 1, clientX: 300, clientY: 0 })
+ fireEvent.pointerUp(handle, { pointerId: 1 })
+ expect(panel).toHaveStyle({ transform: 'translate3d(0px, -720px, 0)' })
+ await user.click(screen.getByRole('button', { name: 'Restore Test Run Results' }))
+
+ await waitFor(() => expect(panel).toHaveStyle({ transform: 'translate3d(0px, -200px, 0)' }))
+ })
+
+ it('minimizes in place instead of dropping to the bottom anchor', async () => {
+ const user = userEvent.setup()
+ render( )
+ const panel = screen.getByLabelText('Test Run Results')
+ const boundary = document.createElement('div')
+ Object.defineProperty(panel, 'offsetParent', { configurable: true, get: () => boundary })
+ boundary.getBoundingClientRect = () => ({
+ x: 0, y: 0, top: 0, left: 0, right: 1000, bottom: 800, width: 1000, height: 800, toJSON: () => ({}),
+ }) as DOMRect
+ panel.getBoundingClientRect = () => {
+ const minimized = screen.queryByRole('button', { name: 'Restore Test Run Results' }) !== null
+ const match = panel.style.transform.match(/translate3d\((-?\d+)px, (-?\d+)px/)
+ const offsetX = Number(match?.[1] ?? 0)
+ const offsetY = Number(match?.[2] ?? 0)
+ const height = minimized ? 80 : 600
+ const top = (minimized ? 720 : 200) + offsetY
+ const left = 300 + offsetX
+ return { x: left, y: top, top, left, right: left + 700, bottom: top + height, width: 700, height, toJSON: () => ({}) } as DOMRect
+ }
+
+ await user.click(screen.getByRole('button', { name: 'Minimize Test Run Results' }))
+
+ await waitFor(() => expect(panel).toHaveStyle({ transform: 'translate3d(0px, -520px, 0)' }))
+ })
})
diff --git a/frontend/src/components/protocol/TestRunResults.tsx b/frontend/src/components/protocol/TestRunResults.tsx
index 923056e7..6be9242b 100644
--- a/frontend/src/components/protocol/TestRunResults.tsx
+++ b/frontend/src/components/protocol/TestRunResults.tsx
@@ -8,6 +8,7 @@ import { TERMINAL_RUN_STATUSES, nodeRunBadge } from '@/lib/protocolRun'
import type { EvaluationArtifact, MetricObservation } from '@/types/experiments'
import type { TestRun, TestRunResourceUsage } from '@/types/protocols'
import { ConversationTranscript } from './ConversationTranscript'
+import { useDraggableOverlay } from './useDraggableOverlay'
function outcomeLabel(run: TestRun): string {
if (run.status === 'finalizing') return 'Calculating metrics…'
@@ -77,7 +78,7 @@ function ObservationCard({ observation, artifacts }: { observation: MetricObserv
// `nodeNames` is every canvas node's friendly name by id (lib/nodeNames.ts),
// not just the agents': Node progress lists a row per node in the graph, and a
// bare uuid there matches nothing the user can point at on the canvas.
-const EXECUTABLE_NODE_TYPES = new Set(['agent', 'sub_agent', 'critic_gate'])
+const EXECUTABLE_NODE_TYPES = new Set(['agent', 'sub_agent', 'critic_gate', 'tool_step'])
export function TestRunResults({
run,
@@ -85,14 +86,20 @@ export function TestRunResults({
nodeNames = new Map(),
nodeTypes = new Map(),
title = 'Test Run Results',
+ kind = 'Test Run',
}: {
run: TestRun
onClose: () => void
nodeNames?: Map
nodeTypes?: Map
title?: string
+ // What kind of run this is (Test Run, node Play, cell run...), shown in the
+ // header so two open panels can't be mistaken for the same run.
+ kind?: string
}) {
const [nodeProgressCollapsed, setNodeProgressCollapsed] = useState(false)
+ const [collapsed, setCollapsed] = useState(false)
+ const drag = useDraggableOverlay({ recomputeKey: collapsed })
const running = !TERMINAL_RUN_STATUSES.has(run.status)
const canvasOrder = new Map(Array.from(nodeTypes.keys(), (nodeId, index) => [nodeId, index]))
const nodeRuns = Object.entries(run.execution_summary.node_runs)
@@ -106,25 +113,52 @@ export function TestRunResults({
return leftOrder - rightOrder || (nodeNames.get(leftId) ?? leftId).localeCompare(nodeNames.get(rightId) ?? rightId)
})
const nodeRunGroups = [
- { label: 'Agents', entries: nodeRuns.filter(([nodeId]) => nodeTypes.get(nodeId) !== 'critic_gate') },
+ {
+ label: 'Agents',
+ entries: nodeRuns.filter(([nodeId]) => !['critic_gate', 'tool_step'].includes(nodeTypes.get(nodeId) ?? '')),
+ },
+ { label: 'Tool steps', entries: nodeRuns.filter(([nodeId]) => nodeTypes.get(nodeId) === 'tool_step') },
{ label: 'Critic gates', entries: nodeRuns.filter(([nodeId]) => nodeTypes.get(nodeId) === 'critic_gate') },
].filter((group) => group.entries.length > 0)
- const timestamp = new Date(run.created_at)
return (
-
-
+
+
{title}
{run.freshness.out_of_date && Out of date }
{outcomeLabel(run)}
- {run.tested_published_revision && <> · Published revision {run.tested_published_revision.number} · {Number.isNaN(timestamp.valueOf()) ? run.created_at : timestamp.toLocaleString()}>}
+ · {kind} · {shortRunId(run.id)}
+ {run.tested_published_revision && <> · Published revision {run.tested_published_revision.number}>}
+ {' · '}{formatRunTime(run.created_at)}
-
+
+
+ event.stopPropagation()}
+ onClick={() => {
+ drag.preservePositionOnNextLayout()
+ setCollapsed((value) => !value)
+ }}
+ >
+ {collapsed ? : }
+
+ event.stopPropagation()} onClick={onClose}>
+
+
-
+ {!collapsed &&
{run.freshness.out_of_date && (
Out of date
{staleReason(run.freshness.reasons)}
@@ -176,11 +210,24 @@ export function TestRunResults({
{run.artifacts.length > 0 &&
Evaluation artifacts {run.artifacts.map((artifact) =>
{artifact.artifact_key} · producer {artifact.producer.binding_id}
{JSON.stringify(artifact.payload, null, 2)} )}
}
Resources
Unknown cost means the provider did not report pricing; it is never treated as zero.
-
+ }
)
}
-export function ReopenTestRunResultsButton({ onOpen, refresh, label = 'Test Run Results' }: { onOpen: () => void; refresh: () => void; label?: string }) {
- return
{ onOpen(); refresh() }} title={`Reopen and refresh ${label}`}>{label}
+const shortRunId = (id: string) => id.slice(0, 8)
+
+function formatRunTime(createdAt: string) {
+ const timestamp = new Date(createdAt)
+ return Number.isNaN(timestamp.valueOf()) ? createdAt : timestamp.toLocaleString()
+}
+
+// One line naming a run -- kind, short id, start time -- shared by the reopen
+// button's tooltip and the panel header so both identify the run identically.
+export function describeRun(kind: string, run: { id: string; created_at: string }) {
+ return `${kind} · ${shortRunId(run.id)} · ${formatRunTime(run.created_at)}`
+}
+
+export function ReopenTestRunResultsButton({ onOpen, refresh, label = 'Test Run Results', detail }: { onOpen: () => void; refresh: () => void; label?: string; detail?: string }) {
+ return
{ onOpen(); refresh() }} title={detail ? `${label}: ${detail}\nClick to reopen and refresh` : `Reopen and refresh ${label}`}>{label}
}
diff --git a/frontend/src/components/protocol/ToolStepNodeInspector.tsx b/frontend/src/components/protocol/ToolStepNodeInspector.tsx
new file mode 100644
index 00000000..ebd00eca
--- /dev/null
+++ b/frontend/src/components/protocol/ToolStepNodeInspector.tsx
@@ -0,0 +1,373 @@
+import { useRef, useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { Cog, Plus, X } from 'lucide-react'
+import { mcpServersApi } from '@/api/client'
+import { nodeAccent } from '@/lib/nodeAccent'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Textarea } from '@/components/ui/textarea'
+import { EditableNodeTitle } from './EditableNodeTitle'
+import { NodeInspectorDialog } from './NodeInspectorDialog'
+import { NodeRunOutputPanel } from './NodeRunOutputPanel'
+import { RESIZE_HANDLE_CLASSNAME, useResizablePane } from './useResizablePane'
+import type {
+ NodeRunState,
+ ProtocolNode,
+ ToolStepArgumentSource,
+ ToolStepNodeConfig,
+ ToolStepNodeData,
+} from '@/types/protocols'
+
+const ACCENT = nodeAccent('tool_step')
+const MIN_CONFIG_WIDTH = 380
+const RESIZE_GUTTER_WIDTH = 16
+
+type SourceKey = 'unset' | 'value' | 'payload_json' | 'payload_object' | 'script_code' | 'workspace_id'
+
+const SOURCE_LABELS: Record
= {
+ unset: 'Not sent',
+ value: 'Fixed value',
+ payload_json: 'Upstream payload (JSON text)',
+ payload_object: 'Upstream payload (object)',
+ script_code: "Wired Script's code",
+ workspace_id: 'Workspace ID',
+}
+
+interface SchemaProperty {
+ type?: string
+ description?: string
+}
+
+function sourceKey(argument: ToolStepArgumentSource | undefined): SourceKey {
+ if (!argument) return 'unset'
+ if (argument.source === 'upstream_payload') return argument.format === 'object' ? 'payload_object' : 'payload_json'
+ return argument.source
+}
+
+function argumentFor(key: SourceKey, schemaType: string | undefined): ToolStepArgumentSource | undefined {
+ switch (key) {
+ case 'unset':
+ return undefined
+ case 'value':
+ return { source: 'value', value: schemaType === 'string' ? '' : null }
+ case 'payload_json':
+ return { source: 'upstream_payload', format: 'json_string' }
+ case 'payload_object':
+ return { source: 'upstream_payload', format: 'object' }
+ default:
+ return { source: key }
+ }
+}
+
+function valueText(value: unknown): string {
+ if (value === null || value === undefined) return ''
+ return typeof value === 'string' ? value : JSON.stringify(value)
+}
+
+// A string-typed parameter keeps exactly what was typed; anything else is read
+// as JSON when it parses (so `7` is a number, `true` a boolean), else as text.
+function parseValue(text: string, schemaType: string | undefined): unknown {
+ if (schemaType === 'string') return text
+ if (text.trim() === '') return null
+ try {
+ return JSON.parse(text)
+ } catch {
+ return text
+ }
+}
+
+function schemaProperties(inputSchema: unknown): Record | null {
+ if (!inputSchema || typeof inputSchema !== 'object') return null
+ const properties = (inputSchema as { properties?: unknown }).properties
+ return properties && typeof properties === 'object' ? (properties as Record) : null
+}
+
+// Same NodeInspectorDialog shell as CriticGateNodeInspector. `toolOptions` is
+// the wired MCP Tool node's own enabled tools: a Tool Step may only call one
+// its MCP node allow-lists (services/tool_steps.py's validate_tool_step), so
+// the picker offers exactly those rather than a free-text name. The argument
+// rows come from the selected tool's own input schema, as its server
+// published it -- nothing here knows any particular tool's parameters.
+export function ToolStepNodeInspector({
+ node,
+ toolOptions,
+ serverId,
+ hasScript,
+ nodeRun,
+ onChange,
+ onDelete,
+ onClose,
+}: {
+ node: (ProtocolNode & { data: ToolStepNodeData }) | null
+ toolOptions: string[]
+ serverId: string | null
+ hasScript: boolean
+ nodeRun?: NodeRunState
+ onChange: (nodeId: string, data: ToolStepNodeData) => void
+ onDelete: (nodeId: string) => void
+ onClose: () => void
+}) {
+ const columnsRef = useRef(null)
+ const outputPane = useResizablePane({
+ storageKey: 'asaree:tool-step-output-pane-width',
+ defaultWidth: 384,
+ minWidth: 280,
+ maxWidth: 760,
+ side: 'right',
+ resolveMaxWidth: () =>
+ (columnsRef.current?.clientWidth ?? Number.POSITIVE_INFINITY) - MIN_CONFIG_WIDTH - RESIZE_GUTTER_WIDTH,
+ recomputeKey: node?.id ?? '',
+ })
+ const serversQuery = useQuery({
+ queryKey: ['mcp-servers'],
+ queryFn: mcpServersApi.list,
+ staleTime: 60_000,
+ enabled: !!serverId,
+ })
+ const [newArgument, setNewArgument] = useState('')
+ if (!node) return null
+ const data = node.data
+ const config = data.config
+ const mapped = config.arguments ?? {}
+ const hashChecks = config.hash_checks ?? {}
+ const options = config.tool_name && !toolOptions.includes(config.tool_name) ? [config.tool_name, ...toolOptions] : toolOptions
+ const tool = serversQuery.data
+ ?.find((server) => server.id === serverId)
+ ?.capabilities?.tools?.find((candidate) => candidate.name === config.tool_name)
+ const properties = schemaProperties(tool?.input_schema)
+ // Schema parameters first, in the server's order, then anything mapped that
+ // the schema doesn't list (or all of them when no schema is available).
+ const argumentNames = [
+ ...Object.keys(properties ?? {}),
+ ...Object.keys(mapped).filter((name) => !properties || !(name in properties)),
+ ]
+
+ function patchConfig(patch: Partial) {
+ onChange(node!.id, { ...data, config: { ...config, ...patch } })
+ }
+
+ function setArgument(name: string, argument: ToolStepArgumentSource | undefined) {
+ const next = { ...mapped }
+ if (argument) next[name] = argument
+ else delete next[name]
+ // A hash check on an argument that's no longer sent could never pass.
+ const checks = Object.fromEntries(Object.entries(hashChecks).filter(([, arg]) => arg in next))
+ patchConfig({ arguments: next, hash_checks: checks })
+ }
+
+ function setHashCheck(oldField: string, field: string, argument: string) {
+ const entries = Object.entries(hashChecks).map(([f, a]) => (f === oldField ? [field, argument] : [f, a]))
+ patchConfig({ hash_checks: Object.fromEntries(entries) })
+ }
+
+ return (
+ {
+ if (!open) onClose()
+ }}
+ accent={ACCENT}
+ title={
+ <>
+
+ onChange(node.id, { ...data, label })} />
+ >
+ }
+ onDelete={() => onDelete(node.id)}
+ onClose={onClose}
+ >
+
+
+
+ Calls one MCP tool directly -- no model decides whether or how to call it. Choose where each argument's
+ value comes from; an argument that isn't mapped isn't sent.
+
+
+
+ Tool
+ patchConfig({ tool_name: value ?? '' })}>
+
+
+ {(value) => value || (options.length ? 'Select a tool…' : 'Wire an MCP Tool node to the Tool connector')}
+
+
+
+ {options.map((name) => (
+
+ {name}
+
+ ))}
+
+
+
+
+ {config.tool_name && (
+
+
Arguments
+ {!properties && (
+
+ {serversQuery.isLoading
+ ? 'Loading the tool’s parameters…'
+ : 'This tool’s parameters aren’t available -- add arguments by name.'}
+
+ )}
+ {argumentNames.map((name) => {
+ const property = properties?.[name]
+ const argument = mapped[name]
+ const key = sourceKey(argument)
+ const text = argument?.source === 'value' ? valueText(argument.value) : ''
+ return (
+
+
+
+ {name}
+ {property?.type && : {property.type} }
+
+ setArgument(name, argumentFor((value ?? 'unset') as SourceKey, property?.type))}
+ >
+
+ {(value) => SOURCE_LABELS[(value ?? 'unset') as SourceKey]}
+
+
+ {(Object.keys(SOURCE_LABELS) as SourceKey[]).map((option) => (
+
+ {SOURCE_LABELS[option]}
+
+ ))}
+
+
+
+ {key === 'value' &&
+ (text.length > 60 || text.includes('\n') ? (
+
+ )
+ })}
+ {!properties && (
+
+
setNewArgument(e.target.value)}
+ />
+
{
+ setArgument(newArgument.trim(), { source: 'value', value: null })
+ setNewArgument('')
+ }}
+ >
+ Add
+
+
+ )}
+
+ )}
+
+
+
Hash checks
+
+ Fail the step unless the tool reports, in the named result field, the SHA-256 of an argument exactly as
+ sent -- e.g. proof it ran the wired script verbatim.
+
+ {Object.entries(hashChecks).map(([field, argument]) => (
+
+ {
+ const next = e.target.value.trim()
+ if (next && next !== field && !(next in hashChecks)) setHashCheck(field, next, argument)
+ }}
+ />
+ = sha256 of
+ value && setHashCheck(field, field, value)}>
+
+ {(value) => value}
+
+
+ {Object.keys(mapped).map((name) => (
+
+ {name}
+
+ ))}
+
+
+
+ patchConfig({ hash_checks: Object.fromEntries(Object.entries(hashChecks).filter(([f]) => f !== field)) })
+ }
+ >
+
+
+
+ ))}
+
{
+ let field = 'sha256'
+ for (let i = 2; field in hashChecks; i++) field = `sha256_${i}`
+ patchConfig({ hash_checks: { ...hashChecks, [field]: Object.keys(mapped)[0] } })
+ }}
+ >
+ Add hash check
+
+
+
+
+ Timeout (seconds)
+ patchConfig({ timeout_seconds: e.target.value ? Number(e.target.value) : null })}
+ />
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/protocol/connectionValidation.test.ts b/frontend/src/components/protocol/connectionValidation.test.ts
index 9f97d028..03f023a6 100644
--- a/frontend/src/components/protocol/connectionValidation.test.ts
+++ b/frontend/src/components/protocol/connectionValidation.test.ts
@@ -68,4 +68,18 @@ describe('isProtocolConnectionValid', () => {
expect(isProtocolConnectionValid(connection, [target, firstSource, secondSource], [existingEdge], false)).toBe(true)
})
+
+ it('lets a Tool Step take one MCP Tool and one Script, and nothing more', () => {
+ const step: Node = { id: 'step', type: 'tool_step', position: { x: 0, y: 0 }, data: {} }
+ const mcp: Node = { id: 'mcp', type: 'mcp_tool', position: { x: 0, y: 0 }, data: {} }
+ const script: Node = { id: 'script', type: 'script', position: { x: 0, y: 0 }, data: {} }
+ const otherMcp: Node = { id: 'mcp-2', type: 'mcp_tool', position: { x: 0, y: 0 }, data: {} }
+ const nodes = [step, mcp, script, otherMcp]
+ const wired: Edge[] = [{ id: 'e1', source: 'mcp', target: 'step', targetHandle: 'tool' }]
+ const toStep = (source: string): Connection => ({ source, sourceHandle: null, target: 'step', targetHandle: 'tool' })
+
+ expect(isProtocolConnectionValid(toStep('mcp'), nodes, [], false)).toBe(true)
+ expect(isProtocolConnectionValid(toStep('script'), nodes, wired, false)).toBe(true)
+ expect(isProtocolConnectionValid(toStep('mcp-2'), nodes, wired, false)).toBe(false)
+ })
})
diff --git a/frontend/src/components/protocol/connectionValidation.ts b/frontend/src/components/protocol/connectionValidation.ts
index 31c98bbe..dd218b69 100644
--- a/frontend/src/components/protocol/connectionValidation.ts
+++ b/frontend/src/components/protocol/connectionValidation.ts
@@ -22,6 +22,18 @@ const SINGLE_CAPACITY_SLOTS = new Set([
'output_parser',
])
+// A Tool Step calls exactly one MCP tool and passes at most one Script's code,
+// so its Tool slot takes one of each (services/tool_steps.py's
+// validate_tool_step) -- unlike an Agent's, which is an open allow-list.
+function toolStepAccepts(source: Node, targetId: string, nodes: Node[], edges: Edge[]): boolean {
+ const sourceIsScript = source.type === 'script'
+ return !edges.some((edge) => {
+ if (edge.target !== targetId || edge.targetHandle !== 'tool') return false
+ const wired = nodes.find((node) => node.id === edge.source)
+ return (wired?.type === 'script') === sourceIsScript
+ })
+}
+
export function isProtocolConnectionValid(
connection: Edge | Connection,
nodes: Node[],
@@ -52,7 +64,7 @@ export function isProtocolConnectionValid(
case 'tool':
return (
(MCP_TOOL_NODE_TYPES.includes(sourceNode.type ?? '') || sourceNode.type === 'script') &&
- targetIsAgentLike
+ (targetIsAgentLike || (targetNode.type === 'tool_step' && toolStepAccepts(sourceNode, targetNode.id, nodes, edges)))
)
case 'memory':
return sourceNode.type === 'memory' && targetIsAgentLike
diff --git a/frontend/src/components/protocol/edges/EdgeHandoffPanel.tsx b/frontend/src/components/protocol/edges/EdgeHandoffPanel.tsx
new file mode 100644
index 00000000..7d7a8927
--- /dev/null
+++ b/frontend/src/components/protocol/edges/EdgeHandoffPanel.tsx
@@ -0,0 +1,144 @@
+import { useState } from 'react'
+import { useEdges, useNodes, useReactFlow } from '@xyflow/react'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Input } from '@/components/ui/input'
+import { displayNames, handoffSenderFields } from '@/lib/promptReferences'
+import type { EdgeHandoff, EdgeHandoffField, EdgeHandoffMode, ProtocolEdge, ProtocolNode } from '@/types/protocols'
+
+// What a main edge into an Agent passes along -- `edge.data.handoff`, read by
+// `protocol_execution._handoff_block`. Three choices and never "nothing": the
+// edge is the request, so it always carries something. The narrowed modes need
+// the sender's Output Parser (a gate forwards its worker's, see
+// `handoffSenderFields`); without one, Full output is the only option. A
+// narrowed edge whose sender extracted none of its fields falls back to the
+// full output at run time, so a failed extraction never starves the receiver.
+const MODES: { mode: EdgeHandoffMode; label: string; hint: string }[] = [
+ { mode: 'full', label: 'Full output', hint: 'The answer plus every extracted field.' },
+ { mode: 'fields', label: 'Extracted fields only', hint: "The Output Parser's fields, without the prose." },
+ { mode: 'selected', label: 'Selected fields', hint: 'Only the fields ticked below.' },
+]
+
+export function EdgeHandoffPanel({ edgeId, source }: { edgeId: string; source: string }) {
+ const { setEdges } = useReactFlow()
+ const nodes = useNodes() as unknown as ProtocolNode[]
+ const edges = useEdges() as unknown as ProtocolEdge[]
+ const handoff = edges.find((e) => e.id === edgeId)?.data?.handoff ?? { mode: 'full' as const }
+ const fields = handoffSenderFields(nodes, edges, source)
+ const senderName = displayNames(nodes)[source] ?? source
+
+ function update(next: EdgeHandoff) {
+ setEdges((current) =>
+ current.map((e) => {
+ if (e.id !== edgeId) return e
+ const rest = { ...(e.data ?? {}) }
+ delete rest.handoff
+ return { ...e, data: next.mode === 'full' ? rest : { ...rest, handoff: next } }
+ }),
+ )
+ }
+
+ const selected = handoff.fields ?? []
+ function toggleField(name: string, checked: boolean) {
+ const nextFields = checked
+ ? [...selected, { name }].sort(
+ (a, b) => fields.findIndex((f) => f.name === a.name) - fields.findIndex((f) => f.name === b.name),
+ )
+ : selected.filter((f) => f.name !== name)
+ update({ mode: 'selected', fields: nextFields })
+ }
+ function setItemKeys(name: string, keys: string[]) {
+ update({
+ mode: 'selected',
+ fields: selected.map((f): EdgeHandoffField => {
+ if (f.name !== name) return f
+ return keys.length ? { name, item_keys: keys } : { name }
+ }),
+ })
+ }
+
+ return (
+
+
What passes
+
+ {MODES.map(({ mode, label, hint }) => {
+ const disabled = mode !== 'full' && fields.length === 0
+ return (
+
+ update(mode === 'selected' ? { mode, fields: selected } : { mode })}
+ />
+
+ {label}
+ {hint}
+
+
+ )
+ })}
+
+ {fields.length === 0 && (
+
+ {senderName} has no Output Parser, so only its full output can pass.
+
+ )}
+ {handoff.mode === 'selected' && fields.length > 0 && (
+
+ {fields.map((field) => {
+ const pick = selected.find((f) => f.name === field.name)
+ return (
+
+
+ toggleField(field.name, checked === true)}
+ />
+ {field.name}
+ {field.type}
+
+ {pick && field.type === 'array' && (
+ setItemKeys(field.name, keys)}
+ />
+ )}
+
+ )
+ })}
+ {selected.length === 0 &&
Tick at least one field — publishing refuses an empty selection.
}
+
+ )}
+
+ )
+}
+
+// Optional: narrows each list item to these keys (`name` -> just the names).
+// Committed on blur so a half-typed comma doesn't reformat under the cursor.
+function ItemKeysInput({ field, keys, onCommit }: { field: string; keys: string[]; onCommit: (keys: string[]) => void }) {
+ const [text, setText] = useState(keys.join(', '))
+ return (
+ setText(e.target.value)}
+ onBlur={() =>
+ onCommit(
+ text
+ .split(',')
+ .map((k) => k.trim())
+ .filter(Boolean),
+ )
+ }
+ />
+ )
+}
diff --git a/frontend/src/components/protocol/edges/InteractEdge.tsx b/frontend/src/components/protocol/edges/InteractEdge.tsx
index acbcdc2f..b0af7931 100644
--- a/frontend/src/components/protocol/edges/InteractEdge.tsx
+++ b/frontend/src/components/protocol/edges/InteractEdge.tsx
@@ -1,11 +1,15 @@
import { useState, type CSSProperties } from 'react'
-import { BaseEdge, EdgeToolbar, getBezierPath, useReactFlow, type EdgeProps } from '@xyflow/react'
+import { BaseEdge, EdgeLabelRenderer, EdgeToolbar, getBezierPath, useReactFlow, type EdgeProps } from '@xyflow/react'
// Trash2, not an X -- the same glyph NodeHoverToolbar's own Delete button
// uses, so "remove this thing" looks identical whether the thing is a node or
// an edge. An X here also collided with the two other X's on the canvas
// (dismissing a panel, unbinding a factor), neither of which deletes anything.
-import { Plus, Trash2 } from 'lucide-react'
+import { Filter, Plus, Trash2 } from 'lucide-react'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { describeHandoff } from '@/lib/promptReferences'
+import type { EdgeHandoff } from '@/types/protocols'
import { useProtocolCanvasActions } from '../ProtocolCanvasContext'
+import { EdgeHandoffPanel } from './EdgeHandoffPanel'
// Every edge's look is decided here and nowhere else -- no edge in a persisted
// graph carries its own `style`, and index.css overrides none of xyflow's
@@ -27,22 +31,22 @@ import { useProtocolCanvasActions } from '../ProtocolCanvasContext'
// A solid edge is deliberately not one relationship: between two Agent nodes it
// is BOTH the left-to-right pipeline edge a normal run walks AND the "these two
// may consult each other" edge a Peer Collaboration run reads (undirected). The
-// experiment's coordination strategy picks which. Sequential Agent-to-Agent
-// edges get a heavier stroke and an arrow from source to target; under the
-// collaboration strategies the same relationship stays undirected. An earlier
-// pass captioned peer edges "can consult"; it read as clutter on a canvas where
-// most solid edges qualify, and the Design tab already says which strategy is
-// in force.
+// experiment's coordination strategy picks which. Sequential and Critic Gate
+// main-flow edges get a heavier stroke and an arrow from source to target;
+// under the collaboration strategies the same relationship stays undirected.
+// An earlier pass captioned peer edges "can consult"; it read as clutter on a
+// canvas where most solid edges qualify, and the Design tab already says which
+// strategy is in force.
//
// Note the dashes are NOT the same statement as MemoryNode's dashed ring,
// which means "not yet functional"; here they only mean "connector, not
// pipeline". Nothing currently renders both, but don't add a third meaning.
const EDGE_STROKE = 'color-mix(in oklch, var(--muted-foreground), transparent 30%)'
-function edgeStyle(isMainEdge: boolean, isSequentialAgentFlow: boolean, hovered: boolean): CSSProperties {
+function edgeStyle(isMainEdge: boolean, isDirectedFlow: boolean, hovered: boolean): CSSProperties {
return {
stroke: hovered ? 'var(--primary)' : EDGE_STROKE,
- strokeWidth: isSequentialAgentFlow ? (hovered ? 3.5 : 3) : hovered ? 2.5 : 2,
+ strokeWidth: isDirectedFlow ? (hovered ? 3.5 : 3) : hovered ? 2.5 : 2,
strokeDasharray: isMainEdge ? undefined : '6 4',
filter: hovered ? 'drop-shadow(0 0 5px var(--primary))' : undefined,
transition: 'stroke 120ms ease, stroke-width 120ms ease',
@@ -83,12 +87,17 @@ export function InteractEdge({
markerEnd,
}: EdgeProps) {
const [hovered, setHovered] = useState(false)
- const { setEdges } = useReactFlow()
+ const [handoffOpen, setHandoffOpen] = useState(false)
+ const { setEdges, getNode } = useReactFlow()
const { requestEdgeInsert } = useProtocolCanvasActions()
const [edgePath, labelX, labelY] = getBezierPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition })
const isMainEdge = !sourceHandleId && !targetHandleId
- const isSequentialAgentFlow = data?.sequentialAgentFlow === true
+ const isDirectedFlow = data?.directedFlow === true
const isPatternEdge = targetHandleId === 'architectural_pattern'
+ // "What passes" is only a choice on a main edge into an Agent: a Critic Gate
+ // always reviews the whole answer (validate_edge_handoffs).
+ const canNarrow = isMainEdge && getNode(target)?.type === 'agent'
+ const handoffLabel = describeHandoff((data as { handoff?: EdgeHandoff } | undefined)?.handoff)
return (
<>
@@ -97,7 +106,7 @@ export function InteractEdge({
@@ -116,7 +125,19 @@ export function InteractEdge({
/>
{/* Nothing to put in it for a pattern edge -- no delete (see above) and
no insert -- so it's skipped entirely rather than rendered empty. */}
-
+ {/* A narrowed edge says so without hovering -- otherwise "this agent
+ only sees three fields" would be invisible on the canvas. */}
+ {handoffLabel && !hovered && !handoffOpen && (
+
+
+ {handoffLabel}
+
+
+ )}
+
setHovered(true)}
@@ -133,6 +154,25 @@ export function InteractEdge({
)}
+ {canNarrow && (
+
+
+ }
+ >
+
+
+
+
+
+
+ )}
; critic_gate: Partial> } = {
+const CONNECTOR_X: {
+ agent: Record
+ critic_gate: Partial>
+ tool_step: Partial>
+} = {
agent: {
architectural_pattern: 0.05,
skill: 0.18,
@@ -33,10 +37,11 @@ const CONNECTOR_X: { agent: Record; critic_gate: Partial<
output_parser: 0.95,
},
critic_gate: { model: 0.5 },
+ tool_step: { tool: 0.5 },
}
-// The host cards' own widths: AgentNode is w-72, CriticGateNode w-36.
-const HOST_WIDTH: Record = { agent: 288, sub_agent: 288, critic_gate: 144 }
+// The host cards' own widths: AgentNode is w-72, CriticGateNode/ToolStepNode w-36.
+const HOST_WIDTH: Record = { agent: 288, sub_agent: 288, critic_gate: 144, tool_step: 224 }
// A connector's node is a CircleNode: a 56px circle under a caption that can
// grow to 96px, with the circle -- where its handle is -- centered in
@@ -54,7 +59,7 @@ export const SUB_AGENT_CHILD_OFFSET_Y = 320
/** The `left` style for each of a host card's connectors, as a percentage
* string — for the ``, its caption and its "+" stub, which must all
* sit at the same x. */
-export function connectorLefts(host: 'agent' | 'critic_gate'): Record {
+export function connectorLefts(host: 'agent' | 'critic_gate' | 'tool_step'): Record {
const table = CONNECTOR_X[host]
const slots = Object.keys(CONNECTOR_X.agent) as ConnectorSlot[]
return Object.fromEntries(slots.map((slot) => [slot, `${(table[slot] ?? 0.5) * 100}%`])) as Record
@@ -67,7 +72,10 @@ export function connectorLefts(host: 'agent' | 'critic_gate'): Record {
- const isHost = (n: TidyNode) => n.type === 'agent' || n.type === 'sub_agent' || n.type === 'critic_gate'
+ const isHost = (n: TidyNode) =>
+ n.type === 'agent' || n.type === 'sub_agent' || n.type === 'critic_gate' || n.type === 'tool_step'
const hosts = nodes.filter(isHost)
// A connector edge carries a targetHandle (the slot it feeds); a main-flow
// edge between two hosts doesn't. So the presence of a handle is what
diff --git a/frontend/src/components/protocol/nodes/AgentNode.tsx b/frontend/src/components/protocol/nodes/AgentNode.tsx
index e54b9c13..cef07cb9 100644
--- a/frontend/src/components/protocol/nodes/AgentNode.tsx
+++ b/frontend/src/components/protocol/nodes/AgentNode.tsx
@@ -162,7 +162,7 @@ export function AgentNode({
Agent
@@ -438,7 +438,7 @@ export function AgentNode({
Agent
diff --git a/frontend/src/components/protocol/nodes/MainEdgeAddStub.tsx b/frontend/src/components/protocol/nodes/MainEdgeAddStub.tsx
index 1d7168e3..dbcec92d 100644
--- a/frontend/src/components/protocol/nodes/MainEdgeAddStub.tsx
+++ b/frontend/src/components/protocol/nodes/MainEdgeAddStub.tsx
@@ -32,8 +32,8 @@ export function MainEdgeAddStub({
+ updateNodeData(id, { active: !isActive })}
+ onMakeFactor={() => requestMakeFactor(id)}
+ />
+ {badge && (
+
+ {badge.label}
+
+ )}
+ {hasBoundFactor(data) && }
+
+
+
+
+
+ {data.label || 'Tool Step'}
+
+
+
+ {summary}
+
+
+ Tool
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/protocol/useDraggableOverlay.ts b/frontend/src/components/protocol/useDraggableOverlay.ts
new file mode 100644
index 00000000..d8d8c9c3
--- /dev/null
+++ b/frontend/src/components/protocol/useDraggableOverlay.ts
@@ -0,0 +1,127 @@
+import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react'
+
+interface Position {
+ x: number
+ y: number
+}
+
+// Pointer-drag behavior shared by the movable canvas overlays. Positions are
+// offsets from each panel's authored anchor, so each keeps its sensible
+// initial placement.
+export function useDraggableOverlay({
+ recomputeKey,
+}: {
+ recomputeKey?: string | number | boolean
+} = {}) {
+ const panelRef = useRef(null)
+ const [position, setPosition] = useState({ x: 0, y: 0 })
+ const [dragging, setDragging] = useState(false)
+ const preservedPosition = useRef | null>(null)
+ const drag = useRef<{
+ pointerId: number
+ clientX: number
+ clientY: number
+ position: Position
+ panelRect: DOMRect
+ boundaryRect: DOMRect | null
+ } | null>(null)
+
+ useEffect(() => {
+ if (!dragging) return
+ const previousCursor = document.body.style.cursor
+ const previousSelect = document.body.style.userSelect
+ document.body.style.cursor = 'move'
+ document.body.style.userSelect = 'none'
+ return () => {
+ document.body.style.cursor = previousCursor
+ document.body.style.userSelect = previousSelect
+ }
+ }, [dragging])
+
+ const placeInsideBoundary = useCallback((preserve?: Pick | null) => {
+ const panel = panelRef.current
+ const boundary = panel?.offsetParent
+ if (!panel || !(boundary instanceof HTMLElement)) return
+ const panelRect = panel.getBoundingClientRect()
+ const boundaryRect = boundary.getBoundingClientRect()
+ if (boundaryRect.width <= 0 || boundaryRect.height <= 0) return
+
+ let shiftX = preserve ? preserve.left - panelRect.left : 0
+ let shiftY = preserve ? preserve.top - panelRect.top : 0
+ const shiftedLeft = panelRect.left + shiftX
+ const shiftedRight = panelRect.right + shiftX
+ const shiftedTop = panelRect.top + shiftY
+ const shiftedBottom = panelRect.bottom + shiftY
+ if (shiftedLeft < boundaryRect.left) shiftX += boundaryRect.left - shiftedLeft
+ else if (shiftedRight > boundaryRect.right) shiftX += boundaryRect.right - shiftedRight
+ if (shiftedTop < boundaryRect.top) shiftY += boundaryRect.top - shiftedTop
+ else if (shiftedBottom > boundaryRect.bottom) shiftY += boundaryRect.bottom - shiftedBottom
+ if (shiftX || shiftY) {
+ setPosition((current) => ({ x: current.x + shiftX, y: current.y + shiftY }))
+ }
+ }, [])
+
+ const clampInsideBoundary = useCallback(() => placeInsideBoundary(), [placeInsideBoundary])
+
+ const preservePositionOnNextLayout = () => {
+ const rect = panelRef.current?.getBoundingClientRect()
+ preservedPosition.current = rect ? { left: rect.left, top: rect.top } : null
+ }
+
+ // A bottom-anchored minimized panel grows upward when restored. Re-clamp
+ // after that layout change so its drag handle can never expand above the
+ // canvas and become unreachable. Viewport resizes need the same correction.
+ useLayoutEffect(() => {
+ const preserve = preservedPosition.current
+ preservedPosition.current = null
+ placeInsideBoundary(preserve)
+ }, [placeInsideBoundary, recomputeKey])
+ useEffect(() => {
+ window.addEventListener('resize', clampInsideBoundary)
+ return () => window.removeEventListener('resize', clampInsideBoundary)
+ }, [clampInsideBoundary])
+
+ const finishDrag = () => {
+ drag.current = null
+ setDragging(false)
+ }
+
+ const handleProps = {
+ onPointerDown: (event: ReactPointerEvent) => {
+ if (event.button !== 0) return
+ event.currentTarget.setPointerCapture?.(event.pointerId)
+ const panel = panelRef.current
+ if (!panel) return
+ drag.current = {
+ pointerId: event.pointerId,
+ clientX: event.clientX,
+ clientY: event.clientY,
+ position,
+ panelRect: panel.getBoundingClientRect(),
+ boundaryRect: panel.offsetParent instanceof HTMLElement ? panel.offsetParent.getBoundingClientRect() : null,
+ }
+ setDragging(true)
+ },
+ onPointerMove: (event: ReactPointerEvent) => {
+ const start = drag.current
+ if (!start || start.pointerId !== event.pointerId) return
+ let deltaX = event.clientX - start.clientX
+ let deltaY = event.clientY - start.clientY
+ const boundary = start.boundaryRect
+ if (boundary && boundary.width > 0 && boundary.height > 0) {
+ const minLeft = boundary.left
+ const maxLeft = Math.max(minLeft, boundary.right - start.panelRect.width)
+ const minTop = boundary.top
+ const maxTop = Math.max(minTop, boundary.bottom - start.panelRect.height)
+ deltaX = Math.min(maxLeft, Math.max(minLeft, start.panelRect.left + deltaX)) - start.panelRect.left
+ deltaY = Math.min(maxTop, Math.max(minTop, start.panelRect.top + deltaY)) - start.panelRect.top
+ }
+ setPosition({ x: start.position.x + deltaX, y: start.position.y + deltaY })
+ },
+ onPointerUp: finishDrag,
+ onPointerCancel: finishDrag,
+ }
+
+ const style: CSSProperties = { transform: `translate3d(${position.x}px, ${position.y}px, 0)` }
+ return { panelRef, handleProps, preservePositionOnNextLayout, style }
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index d76f3d03..88433ce3 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -152,6 +152,13 @@
/* Sub-Agent uses a bright electric blue for stronger contrast with the
Agent's emerald while remaining in the same cool-hued agent family. */
--node-10: oklch(0.8 0.17 220);
+ /* Tool Step (kind fifteen) -- the different axis --node-9's comment
+ anticipated: lightness plus chroma. Every other node is saturated and
+ sits at 0.66-0.88 lightness, so a near-white chrome reads as its own
+ kind without claiming a hue -- fitting for the canvas's only
+ deterministic, model-free step. A dimmer steel (0.86/0.025) was tried
+ first and read as washed-out rather than distinct. */
+ --node-11: oklch(0.95 0.015 240);
/* Connector captions (Pattern/Skill/Dataset/Knowledge/Model/Memory/Tool on
AgentNode and CriticGateNode). One fixed yellow rather than the host
node's own accent: these sit outside the card on the canvas backdrop,
diff --git a/frontend/src/lib/agentOutputMetrics.ts b/frontend/src/lib/agentOutputMetrics.ts
index 14c799ca..1c5646cc 100644
--- a/frontend/src/lib/agentOutputMetrics.ts
+++ b/frontend/src/lib/agentOutputMetrics.ts
@@ -1,5 +1,6 @@
import type { DesignMetric, MeasurementPlan } from '@/types/experiments'
import type { ProtocolGraph } from '@/types/protocols'
+import { projectionConfig, type FieldProjection } from './metricFields'
export const AGENT_OUTPUT_PRODUCER_ID = 'asaree.agent_output'
@@ -29,6 +30,7 @@ export function upsertAgentOutputMetric(
plan: MeasurementPlan | null,
metric: DesignMetric,
agentNodeId: string,
+ projection?: FieldProjection,
): MeasurementPlan {
if (!metric.id) throw new Error('An Agent output metric needs a stable id.')
const current = plan ?? { metrics: [], producers: [], inputs: [] }
@@ -39,12 +41,6 @@ export function upsertAgentOutputMetric(
{
id: metric.id,
name: metric.name,
- value_type: 'opaque',
- direction: 'neutral',
- aggregation: 'none',
- primary: false,
- description: metric.description,
- unit: metric.unit,
},
],
producers: [
@@ -55,7 +51,7 @@ export function upsertAgentOutputMetric(
kind: 'reported',
outputs: { value: metric.id },
artifacts: [],
- config: { agent_node_id: agentNodeId },
+ config: { agent_node_id: agentNodeId, ...projectionConfig(projection) },
},
],
inputs: current.inputs.filter((input) => input.producer_binding_id !== bindingId),
diff --git a/frontend/src/lib/customMetrics.ts b/frontend/src/lib/customMetrics.ts
index 3708a107..56cbbbcc 100644
--- a/frontend/src/lib/customMetrics.ts
+++ b/frontend/src/lib/customMetrics.ts
@@ -2,20 +2,28 @@ import type { DesignMetric, MeasurementPlan } from '@/types/experiments'
import type { ProtocolGraph } from '@/types/protocols'
import { upsertAgentOutputMetric } from './agentOutputMetrics'
import { upsertMcpToolMetric, type McpToolSourceOption } from './mcpToolMetrics'
+import { upsertNodeRuntimeMetric } from './nodeRuntimeMetrics'
+import type { FieldProjection } from './metricFields'
import { removeMetricFromMeasurementPlan } from './measurementPlan'
import { pythonScriptSourceOptions, upsertPythonScriptMetric } from './pythonScriptMetrics'
+import { upsertToolStepMetric } from './toolStepMetrics'
-export type CustomMetricProducer = 'agent' | 'python' | 'mcp'
+export type CustomMetricProducer = 'agent' | 'python' | 'mcp' | 'tool_step' | 'node_runtime'
export type CustomMetricSourceContext =
| { producer: 'agent'; nodeId: string }
| { producer: 'python'; nodeId: string }
| { producer: 'mcp'; nodeId: string }
+ | { producer: 'tool_step'; nodeId: string }
+ | { producer: 'node_runtime'; nodeId: string }
+// `projection` picks one field of the output; absent records the whole of it.
export type CustomMetricProducerConfig =
- | { producer: 'agent'; agentNodeId: string }
+ | { producer: 'agent'; agentNodeId: string; projection?: FieldProjection }
+ | { producer: 'tool_step'; nodeId: string; projection?: FieldProjection }
| { producer: 'python'; sourceKey: string }
| { producer: 'mcp'; source: McpToolSourceOption; toolName: string }
+ | { producer: 'node_runtime'; nodeIds: string[]; output: string }
// The one write interface shared by Manage Metrics and the canvas's
// node-first custom-metric flow. Keeping producer replacement here prevents
@@ -28,7 +36,13 @@ export function applyCustomMetricChange(
): MeasurementPlan {
const planWithoutPreviousProducer = removeMetricFromMeasurementPlan(plan, metric.id)
if (config.producer === 'agent') {
- return upsertAgentOutputMetric(planWithoutPreviousProducer, metric, config.agentNodeId)
+ return upsertAgentOutputMetric(planWithoutPreviousProducer, metric, config.agentNodeId, config.projection)
+ }
+ if (config.producer === 'tool_step') {
+ return upsertToolStepMetric(planWithoutPreviousProducer, metric, config.nodeId, config.projection)
+ }
+ if (config.producer === 'node_runtime') {
+ return upsertNodeRuntimeMetric(planWithoutPreviousProducer, metric, config.nodeIds, config.output)
}
if (config.producer === 'mcp') {
return upsertMcpToolMetric(
diff --git a/frontend/src/lib/edgeHandoff.test.ts b/frontend/src/lib/edgeHandoff.test.ts
new file mode 100644
index 00000000..71a68f6e
--- /dev/null
+++ b/frontend/src/lib/edgeHandoff.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest'
+import type { Edge, Node } from '@xyflow/react'
+import { describeHandoff, handoffPeers, handoffSenderFields, toDisplayPrompt, toStoredPrompt } from './promptReferences'
+import { toPersistedGraph } from './protocolGraph'
+import type { ProtocolEdge, ProtocolNode } from '@/types/protocols'
+
+const nodes = [
+ { id: 'a', type: 'agent', position: { x: 0, y: 0 }, data: { label: 'Analyst', config: {} } },
+ {
+ id: 'p',
+ type: 'output_parser',
+ position: { x: 0, y: 0 },
+ data: { label: 'Parser', config: { output_contract: { name: 'r', fields: [{ name: 'notes', type: 'string' }, { name: 'recipe', type: 'array' }] } } },
+ },
+ { id: 'g', type: 'critic_gate', position: { x: 0, y: 0 }, data: { label: 'Gate', config: {} } },
+ { id: 'b', type: 'agent', position: { x: 0, y: 0 }, data: { label: 'Builder', config: {} } },
+] as unknown as ProtocolNode[]
+const edges: ProtocolEdge[] = [
+ { id: 'pa', source: 'p', target: 'a', sourceHandle: 'output_parser', targetHandle: 'output_parser' },
+ { id: 'ag', source: 'a', target: 'g' },
+ { id: 'gb', source: 'g', target: 'b', data: { handoff: { mode: 'selected', fields: [{ name: 'recipe', item_keys: ['name'] }] } } },
+]
+
+describe('edge handoff', () => {
+ it("offers a gate's worker's fields on the gate's outgoing edge", () => {
+ expect(handoffSenderFields(nodes, edges, 'g').map((f) => f.name)).toEqual(['notes', 'recipe'])
+ })
+
+ it('reports the narrowed handoff on the receiving side', () => {
+ const [peer] = handoffPeers(nodes, edges, 'b').receives
+ expect(peer.handoff?.mode).toBe('selected')
+ expect(describeHandoff(peer.handoff)).toBe('1 field')
+ expect(describeHandoff({ mode: 'full' })).toBeNull()
+ })
+
+ it('persists the handoff but not display-only edge data', () => {
+ const flowEdges = edges.map((e) => ({ ...e, data: { ...e.data, directedFlow: true } })) as Edge[]
+ const persisted = toPersistedGraph(nodes as unknown as Node[], flowEdges).edges
+ expect(persisted.find((e) => e.id === 'gb')?.data).toEqual({ handoff: edges[2].data!.handoff })
+ expect(persisted.find((e) => e.id === 'ag')).not.toHaveProperty('data')
+ })
+})
+
+describe('item-key field references', () => {
+ it('round-trip between the stored and display forms', () => {
+ const names = { a: 'Analyst' }
+ const stored = 'Steps: {{node:a.recipe[name, op]}}'
+ expect(toDisplayPrompt(stored, names)).toBe('Steps: {{Analyst.recipe[name, op]}}')
+ expect(toStoredPrompt('Steps: {{Analyst.recipe[name, op]}}', names)).toBe(stored)
+ })
+})
diff --git a/frontend/src/lib/experiment.test.ts b/frontend/src/lib/experiment.test.ts
index 56fc1be1..fee701c8 100644
--- a/frontend/src/lib/experiment.test.ts
+++ b/frontend/src/lib/experiment.test.ts
@@ -36,8 +36,8 @@ describe('displayFactorLevel', () => {
})
})
-describe('reported scalar metrics', () => {
- it('preserves an explicitly typed custom metric and uses it as primary', () => {
+describe('custom metric observations', () => {
+ it('removes imported value semantics and does not use a custom metric as primary', () => {
const metrics = normalizeDesignMetrics([{
id: 'pr-auc',
name: 'pr_auc',
@@ -49,16 +49,17 @@ describe('reported scalar metrics', () => {
}])
const experiment = { design_spec: { metrics } } as Experiment
- expect(metrics[0]).toMatchObject({ valueType: 'number', direction: 'maximize', primary: true })
- expect(primaryMetric(experiment)).toEqual({ key: 'pr_auc', direction: 'maximize', valueType: 'number' })
+ expect(metrics[0]).toEqual({ id: 'pr-auc', name: 'pr_auc', kind: 'custom' })
+ expect(primaryMetric(experiment)).toBeNull()
})
- it('keeps an ordinary custom draft opaque and non-primary', () => {
- expect(normalizeDesignMetrics([{
+ it('keeps only the identity and label of a custom draft', () => {
+ const metric = normalizeDesignMetrics([{
name: 'Reviewer report',
kind: 'custom',
direction: 'maximize',
primary: true,
- }])[0]).toMatchObject({ valueType: 'opaque', direction: 'neutral', primary: false })
+ }])[0]
+ expect(metric).toEqual({ id: metric.id, name: 'Reviewer report', kind: 'custom' })
})
})
diff --git a/frontend/src/lib/experiment.ts b/frontend/src/lib/experiment.ts
index 811d8d44..b593dd81 100644
--- a/frontend/src/lib/experiment.ts
+++ b/frontend/src/lib/experiment.ts
@@ -303,7 +303,7 @@ export function pickMetricColumns(experiment: Experiment | undefined, replicates
* must never choose the canvas top-bar result. */
export function primaryMetric(experiment: Experiment | undefined): { key: string; direction: 'maximize' | 'minimize'; valueType?: 'number' | 'boolean' | 'string' } | null {
const metric = experiment?.design_spec?.metrics?.find((candidate) => candidate.primary && candidate.name)
- if (!metric || metric.direction === 'neutral' || metric.valueType === 'string' || metric.valueType === 'opaque') return null
+ if (!metric || (metric.direction !== 'maximize' && metric.direction !== 'minimize') || metric.valueType === 'string' || metric.valueType === 'opaque') return null
const key = metric.kind === 'runtime' && typeof metric.catalogKey === 'string' ? metric.catalogKey : metric.name
return { key, direction: metric.direction, valueType: metric.valueType }
}
diff --git a/frontend/src/lib/mcpToolMetrics.ts b/frontend/src/lib/mcpToolMetrics.ts
index fecdc979..cf4954bf 100644
--- a/frontend/src/lib/mcpToolMetrics.ts
+++ b/frontend/src/lib/mcpToolMetrics.ts
@@ -42,5 +42,5 @@ export function mcpToolBindingForMetric(plan: MeasurementPlan | null, metricId:
export function upsertMcpToolMetric(plan: MeasurementPlan | null, metric: DesignMetric, source: McpToolSourceOption | undefined, toolName = ''): MeasurementPlan {
if (!metric.id) throw new Error('An MCP Tool metric needs a stable id.')
const current = plan ?? { metrics: [], producers: [], inputs: [] }; const id = `mcp-${metric.id}`
- return { metrics: [...current.metrics.filter((item) => item.id !== metric.id), { id: metric.id, name: metric.name, value_type: 'opaque', direction: 'neutral', aggregation: 'none', primary: false, description: metric.description, unit: metric.unit }], producers: [...current.producers.filter((item) => item.id !== id), { id, producer_id: MCP_TOOL_PRODUCER_ID, kind: 'reported', outputs: { value: metric.id }, artifacts: [], config: { agent_node_id: source?.agentNodeId ?? '', mcp_node_id: source?.mcpNodeId ?? '', server_id: source?.serverId ?? '', tool_name: toolName } }], inputs: current.inputs.filter((item) => item.producer_binding_id !== id) }
+ return { metrics: [...current.metrics.filter((item) => item.id !== metric.id), { id: metric.id, name: metric.name }], producers: [...current.producers.filter((item) => item.id !== id), { id, producer_id: MCP_TOOL_PRODUCER_ID, kind: 'reported', outputs: { value: metric.id }, artifacts: [], config: { agent_node_id: source?.agentNodeId ?? '', mcp_node_id: source?.mcpNodeId ?? '', server_id: source?.serverId ?? '', tool_name: toolName } }], inputs: current.inputs.filter((item) => item.producer_binding_id !== id) }
}
diff --git a/frontend/src/lib/measurementPlan.ts b/frontend/src/lib/measurementPlan.ts
index 27fc069c..ba813fd5 100644
--- a/frontend/src/lib/measurementPlan.ts
+++ b/frontend/src/lib/measurementPlan.ts
@@ -3,6 +3,8 @@ import type { ProtocolGraph } from '@/types/protocols'
import { metricCatalogEntry, type MetricCatalogEntry } from './metricCatalog'
import { AGENT_OUTPUT_PRODUCER_ID } from './agentOutputMetrics'
import { MCP_TOOL_PRODUCER_ID } from './mcpToolMetrics'
+import { NODE_RUNTIME_PRODUCER_ID } from './nodeRuntimeMetrics'
+import { TOOL_STEP_PRODUCER_ID } from './toolStepMetrics'
export type MetricReadiness = {
ready: boolean
@@ -19,11 +21,22 @@ export const OBSERVATION_LABELS: Record = {
not_applicable: 'Not applicable',
}
+// Producers ASAREE computes in code from what a run recorded
+// (services/derived_metrics.py). A Tool Step metric is authored through the
+// custom-metric dialog; the feature pipeline is declared in the plan document.
+export { NODE_RUNTIME_PRODUCER_ID, TOOL_STEP_PRODUCER_ID }
+export const FEATURE_PIPELINE_PRODUCER_ID = 'asaree.feature_pipeline'
+export const DERIVED_PRODUCER_IDS = new Set([TOOL_STEP_PRODUCER_ID, FEATURE_PIPELINE_PRODUCER_ID])
+const FEATURE_PIPELINE_STAGE_KEYS = ['dc_node_id', 'fte_node_id', 'fs_node_id'] as const
+
const PRODUCER_LABELS: Record = {
'asaree.runtime': 'ASAREE runtime',
'asaree.python_script': 'Python Script',
[AGENT_OUTPUT_PRODUCER_ID]: 'Agent output',
[MCP_TOOL_PRODUCER_ID]: 'MCP Tool',
+ [TOOL_STEP_PRODUCER_ID]: 'Tool Step',
+ [FEATURE_PIPELINE_PRODUCER_ID]: 'Feature pipeline',
+ [NODE_RUNTIME_PRODUCER_ID]: 'Node runtime',
}
export function removeMetricFromMeasurementPlan(
@@ -33,7 +46,13 @@ export function removeMetricFromMeasurementPlan(
if (!plan || !metricId) return plan
const producers = plan.producers.flatMap((producer) => {
const outputs = Object.fromEntries(Object.entries(producer.outputs).filter(([, id]) => id !== metricId))
- return Object.keys(outputs).length ? [{ ...producer, outputs }] : []
+ if (!Object.keys(outputs).length) return []
+ // A field projection belongs to its output key, so it leaves with it.
+ const projections = producer.config.projections
+ const config = projections && typeof projections === 'object'
+ ? { ...producer.config, projections: Object.fromEntries(Object.entries(projections).filter(([key]) => key in outputs)) }
+ : producer.config
+ return [{ ...producer, outputs, config }]
})
const producerIds = new Set(producers.map((producer) => producer.id))
return {
@@ -111,6 +130,25 @@ export function localMetricReadinessPreview(
if (agent.data.active === false) return { ready: false, producer, detail: 'The Agent is disabled.' }
return { ready: true, producer, detail: 'The Agent final output will be captured after execution.' }
}
+ if (binding.producer_id === TOOL_STEP_PRODUCER_ID) {
+ const step = graph?.nodes.find((node) => node.id === binding.config.node_id && node.type === 'tool_step')
+ if (!step) return { ready: false, producer, detail: 'The Tool Step is unavailable.' }
+ if (step.data.active === false) return { ready: false, producer, detail: 'The Tool Step is disabled.' }
+ return { ready: true, producer, detail: "The Tool Step's result will be read after execution." }
+ }
+ if (binding.producer_id === FEATURE_PIPELINE_PRODUCER_ID) {
+ const missing = FEATURE_PIPELINE_STAGE_KEYS.find((key) => !graph?.nodes.some(
+ (node) => node.id === binding.config[key] && (node.type === 'agent' || node.type === 'sub_agent'),
+ ))
+ if (missing) return { ready: false, producer, detail: `The ${missing.split('_')[0].toUpperCase()} Agent is unavailable.` }
+ return { ready: true, producer, detail: 'Computed from the stage payloads after execution.' }
+ }
+ if (binding.producer_id === NODE_RUNTIME_PRODUCER_ID) {
+ const nodeIds = Array.isArray(binding.config.node_ids) ? binding.config.node_ids.map(String) : []
+ if (nodeIds.length === 0) return { ready: false, producer, detail: 'No node is selected to measure.' }
+ const missing = nodeIds.filter((id) => !graph?.nodes.some((node) => node.id === id))
+ if (missing.length) return { ready: false, producer, detail: `Node ${missing.join(', ')} is not on the canvas.` }
+ }
if (binding.kind === 'reported') {
const graphNodes = graph?.nodes ?? []
const graphEdges = graph?.edges ?? []
@@ -157,6 +195,7 @@ export function localMetricReadinessPreview(
}
export function metricMetadata(metric: DesignMetric): string {
+ if (metric.kind === 'custom') return 'captured output'
const catalog = metricCatalogEntry(metric.catalogKey)
const unit = metric.unit || catalog?.unit || 'unitless'
const aggregation = metric.valueType === 'boolean' ? 'rate' : (metric.aggregation ?? catalog?.aggregation ?? 'mean')
diff --git a/frontend/src/lib/metricCatalog.ts b/frontend/src/lib/metricCatalog.ts
index 06255364..e2f6e825 100644
--- a/frontend/src/lib/metricCatalog.ts
+++ b/frontend/src/lib/metricCatalog.ts
@@ -41,6 +41,16 @@ export const METRIC_CATALOG: readonly MetricCatalogEntry[] = [
{ key: 'agent_loop_iterations', name: 'Agent-loop iterations', shortDescription: 'Distinct Agent reasoning-loop iterations in the run.', kind: 'runtime', valueType: 'number', defaultDirection: 'minimize', aggregation: 'sum', unit: 'iterations', category: 'Agents', source: 'Protocol activity' },
{ key: 'critic_rejections', name: 'Critic rejections', shortDescription: 'Rejected critic-gate reviews during the run.', kind: 'runtime', valueType: 'number', defaultDirection: 'minimize', aggregation: 'sum', unit: 'reviews', category: 'Critics', source: 'Protocol activity' },
{ key: 'critic_approvals', name: 'Critic approvals', shortDescription: 'Critic gates ending in an actual approval.', kind: 'runtime', valueType: 'number', defaultDirection: 'neutral', aggregation: 'sum', unit: 'reviews', category: 'Critics', source: 'Protocol activity' },
+ { key: 'critic_invocations', name: 'Critic invocations', shortDescription: 'Critic-gate reviews run during the run.', kind: 'runtime', valueType: 'number', defaultDirection: 'neutral', aggregation: 'sum', unit: 'reviews', category: 'Critics', source: 'Protocol activity' },
+ { key: 'critic_rejections_partial', name: 'Partial critic rejections', shortDescription: 'Rejections scoped to a targeted correction.', kind: 'runtime', valueType: 'number', defaultDirection: 'minimize', aggregation: 'sum', unit: 'reviews', category: 'Critics', source: 'Protocol activity' },
+ { key: 'critic_rejections_full', name: 'Full critic rejections', shortDescription: 'Rejections asking the stage to reconsider its approach (includes unscoped ones).', kind: 'runtime', valueType: 'number', defaultDirection: 'minimize', aggregation: 'sum', unit: 'reviews', category: 'Critics', source: 'Protocol activity' },
+ { key: 'revision_rounds', name: 'Revision rounds', shortDescription: 'Worker reruns triggered by critic rejections.', kind: 'runtime', valueType: 'number', defaultDirection: 'minimize', aggregation: 'sum', unit: 'rounds', category: 'Critics', source: 'Protocol activity' },
+ { key: 'prompt_sha256', name: 'Prompt SHA-256', shortDescription: 'Hash of the published stage prompts, critic criteria and output contracts.', kind: 'runtime', valueType: 'opaque', defaultDirection: 'neutral', aggregation: 'none', category: 'Agents', source: 'Protocol activity' },
+ { key: 'capped_agent_runs', name: 'Capped agent runs', shortDescription: 'Reason+Act runs cut off by their iteration ceiling.', kind: 'runtime', valueType: 'number', defaultDirection: 'minimize', aggregation: 'sum', unit: 'runs', category: 'Agents', source: 'Protocol activity' },
+ { key: 'agent_runs', name: 'Agent runs', shortDescription: 'Every agent and critic run the attempt launched, revisions included.', kind: 'runtime', valueType: 'number', defaultDirection: 'neutral', aggregation: 'sum', unit: 'runs', category: 'Agents', source: 'Protocol activity' },
+ { key: 'react_runs', name: 'Reason+Act runs', shortDescription: 'Runs that went through a Reason+Act loop.', kind: 'runtime', valueType: 'number', defaultDirection: 'neutral', aggregation: 'sum', unit: 'runs', category: 'Agents', source: 'Protocol activity' },
+ { key: 'react_turns', name: 'Reason+Act turns', shortDescription: 'Loop iterations reported by Reason+Act runs.', kind: 'runtime', valueType: 'number', defaultDirection: 'minimize', aggregation: 'sum', unit: 'turns', category: 'Agents', source: 'Protocol activity' },
+ { key: 'react_tool_calls', name: 'Reason+Act tool calls', shortDescription: 'Tool calls counted by Reason+Act loops.', kind: 'runtime', valueType: 'number', defaultDirection: 'neutral', aggregation: 'sum', unit: 'calls', category: 'Agents', source: 'Protocol activity' },
]
export const RECOMMENDED_METRIC_KEYS = METRIC_CATALOG.filter((entry) => entry.recommended).map((entry) => entry.key)
@@ -66,25 +76,28 @@ export function normalizeDesignMetrics(metrics: DesignMetric[] | undefined): Des
const catalog = metricCatalogEntry(raw.catalogKey)
if (raw.kind && !['runtime', 'custom'].includes(raw.kind)) return []
const kind = raw.kind ?? catalog?.kind ?? 'custom'
- const scalarCustom = kind === 'custom' && raw.kind === 'custom' && (raw.valueType === 'number' || raw.valueType === 'boolean')
- return [{
+ const id = typeof raw.id === 'string' && raw.id ? raw.id : legacyId(raw)
+ const name = raw.name.trim()
+ if (kind === 'custom') return [{ id, name, kind }]
+ const metric: DesignMetric = {
...raw,
- id: typeof raw.id === 'string' && raw.id ? raw.id : legacyId(raw),
+ id,
catalogKey: catalog ? catalog.key : raw.catalogKey,
- name: raw.name.trim(),
+ name,
description: typeof raw.description === 'string' && raw.description.trim()
? raw.description.trim()
: catalog?.shortDescription ?? `Legacy metric declaration for ${raw.name.trim()}.`,
kind,
- valueType: kind === 'custom' && !scalarCustom ? 'opaque' : raw.valueType ?? catalog?.valueType ?? 'number',
- direction: kind === 'custom' && !scalarCustom ? 'neutral' : raw.direction === 'maximize' || raw.direction === 'minimize' || raw.direction === 'neutral'
+ valueType: raw.valueType ?? catalog?.valueType ?? 'number',
+ direction: raw.direction === 'maximize' || raw.direction === 'minimize' || raw.direction === 'neutral'
? raw.direction
: (catalog?.defaultDirection ?? 'maximize'),
- aggregation: kind === 'custom' && !scalarCustom ? 'none' : raw.valueType === 'boolean' ? 'mean' : (raw.aggregation === 'sum' ? 'sum' : raw.aggregation === 'none' ? 'none' : (catalog?.aggregation ?? 'mean')),
- primary: kind === 'custom' && !scalarCustom ? false : Boolean(raw.primary),
+ aggregation: raw.valueType === 'boolean' ? 'mean' : (raw.aggregation === 'sum' ? 'sum' : raw.aggregation === 'none' ? 'none' : (catalog?.aggregation ?? 'mean')),
+ primary: Boolean(raw.primary),
unit: raw.unit ?? catalog?.unit,
scoring: undefined,
- }]
+ }
+ return [metric]
})
}
@@ -103,17 +116,11 @@ export function makeCatalogMetric(entry: MetricCatalogEntry, isPrimary: boolean)
}
}
-export function makeCustomMetric(input: { name: string; description: string; unit?: string }): DesignMetric {
+export function makeCustomMetric(input: { name: string }): DesignMetric {
return {
id: newMetricId(),
name: input.name.trim(),
- description: input.description.trim(),
kind: 'custom',
- valueType: 'opaque',
- direction: 'neutral',
- aggregation: 'none',
- primary: false,
- unit: input.unit?.trim() || undefined,
}
}
diff --git a/frontend/src/lib/metricFields.test.ts b/frontend/src/lib/metricFields.test.ts
new file mode 100644
index 00000000..2ff01970
--- /dev/null
+++ b/frontend/src/lib/metricFields.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest'
+import { removeMetricFromMeasurementPlan } from './measurementPlan'
+import { bindingProjection, defaultFieldNames, observedFields } from './metricFields'
+import { upsertToolStepMetric } from './toolStepMetrics'
+
+describe('metric fields', () => {
+ it('lists nested payload fields without splitting dotted keys', () => {
+ const fields = observedFields({ test_metrics: { 'metrics_at_0.5': { f1: 0.4 } }, notes: ['a'] })
+ expect(fields.map((field) => field.path)).toEqual([
+ 'test_metrics', 'test_metrics.metrics_at_0.5', 'test_metrics.metrics_at_0.5.f1', 'notes',
+ ])
+ expect(fields.find((field) => field.path === 'test_metrics.metrics_at_0.5.f1')?.segments).toEqual(['test_metrics', 'metrics_at_0.5', 'f1'])
+ expect(fields.find((field) => field.path === 'notes')?.countable).toBe(true)
+ })
+
+ it('names picks by leaf key and widens only on collision', () => {
+ const pick = (segments: string[], transform?: 'length') => ({ field: { segments }, projection: { path: segments.join('.'), ...(transform ? { transform } : {}) } })
+ expect(defaultFieldNames([
+ pick(['test_metrics', 'roc_auc']),
+ pick(['val_metrics', 'roc_auc']),
+ pick(['notes'], 'length'),
+ ], ['other'])).toEqual(['test_metrics_roc_auc', 'val_metrics_roc_auc', 'notes_count'])
+ expect(defaultFieldNames([pick(['roc_auc'])], ['roc_auc'])).toEqual(['roc_auc_2'])
+ })
+
+ it('round-trips a Tool Step projection and prunes it with the metric', () => {
+ const plan = upsertToolStepMetric(null, { id: 'm1', name: 'auc', kind: 'custom' }, 'tool-1', { path: 'test_metrics.roc_auc' })
+ const binding = plan.producers[0]
+ expect(binding.config).toEqual({ node_id: 'tool-1', projections: { value: { path: 'test_metrics.roc_auc' } } })
+ expect(bindingProjection(binding, 'm1')).toEqual({ path: 'test_metrics.roc_auc' })
+ expect(removeMetricFromMeasurementPlan(plan, 'm1')?.producers ?? []).toHaveLength(0)
+ })
+})
diff --git a/frontend/src/lib/metricFields.ts b/frontend/src/lib/metricFields.ts
new file mode 100644
index 00000000..ed2428ba
--- /dev/null
+++ b/frontend/src/lib/metricFields.ts
@@ -0,0 +1,150 @@
+import type { MeasurementPlan } from '@/types/experiments'
+import type { NodeRunState, OutputContractField, ProtocolGraph, ProtocolRun } from '@/types/protocols'
+
+// One field picked out of a producer's JSON output -- the backend's
+// `config.projections[outputKey]` (services/derived_metrics.py). `path` is an
+// exact dotted key (result keys may themselves contain dots, e.g.
+// `metrics_at_0.5`), and `length` records a list/object's size instead of it.
+// A metric with no projection records the producer's whole output.
+export interface FieldProjection {
+ path: string
+ transform?: 'length'
+}
+
+export interface MetricField {
+ path: string
+ // Path segments, kept so a default metric name can use the leaf key
+ // without re-splitting a path whose keys contain dots.
+ segments: string[]
+ countable: boolean
+ // 'declared' = from an Output Parser contract; 'observed' = from a run.
+ origin: 'declared' | 'observed'
+}
+
+type Binding = MeasurementPlan['producers'][number]
+
+export function bindingProjection(binding: Binding | undefined, metricId: string | undefined): FieldProjection | undefined {
+ if (!binding || !metricId) return undefined
+ const outputKey = Object.entries(binding.outputs).find(([, id]) => id === metricId)?.[0]
+ const projections = binding.config.projections
+ const projection = outputKey && projections && typeof projections === 'object'
+ ? (projections as Record)[outputKey]
+ : undefined
+ if (!projection || typeof projection !== 'object') return undefined
+ const { path, transform } = projection as { path?: unknown; transform?: unknown }
+ if (typeof path !== 'string' || !path) return undefined
+ return transform === 'length' ? { path, transform } : { path }
+}
+
+export function projectionConfig(projection: FieldProjection | undefined): Record {
+ return projection ? { projections: { value: projection } } : {}
+}
+
+export function projectionLabel(projection: FieldProjection | undefined): string {
+ if (!projection) return 'whole output'
+ return projection.transform === 'length' ? `${projection.path} (count)` : projection.path
+}
+
+const COUNTABLE_CONTRACT_TYPES = new Set(['list', 'dict', 'array', 'object'])
+
+function contractFields(fields: OutputContractField[] | undefined): MetricField[] {
+ return (fields ?? []).filter((field) => field.name.trim()).map((field) => ({
+ path: field.name.trim(),
+ segments: [field.name.trim()],
+ countable: COUNTABLE_CONTRACT_TYPES.has(field.type),
+ origin: 'declared',
+ }))
+}
+
+// The Agent's declared output shape: its enabled wired Output Parser, else
+// the legacy contract stored on the Agent itself.
+export function declaredAgentFields(graph: ProtocolGraph | undefined, agentNodeId: string): MetricField[] {
+ if (!graph) return []
+ const parser = graph.edges
+ .filter((edge) => edge.target === agentNodeId && edge.targetHandle === 'output_parser')
+ .map((edge) => graph.nodes.find((node) => node.id === edge.source))
+ .find((node) => node?.type === 'output_parser')
+ if (parser) {
+ const config = 'config' in parser.data ? parser.data.config : undefined
+ if (config && 'enabled' in config && config.enabled === false) return []
+ return contractFields(config && 'output_contract' in config ? config.output_contract?.fields : undefined)
+ }
+ const agent = graph.nodes.find((node) => node.id === agentNodeId)
+ const config = agent && 'config' in agent.data ? agent.data.config : undefined
+ return contractFields(config && 'output_contract' in config ? config.output_contract?.fields : undefined)
+}
+
+function flatten(value: unknown, segments: string[], into: MetricField[]) {
+ if (segments.length) {
+ const countable = Array.isArray(value) || (value !== null && typeof value === 'object')
+ into.push({ path: segments.join('.'), segments, countable, origin: 'observed' })
+ }
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ for (const [key, child] of Object.entries(value)) flatten(child, [...segments, key], into)
+ }
+}
+
+export function observedFields(payload: unknown): MetricField[] {
+ const fields: MetricField[] = []
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) flatten(payload, [], fields)
+ return fields
+}
+
+// The node's typed output from the most recent run that completed it.
+export function latestNodePayload(runs: ProtocolRun[] | undefined, nodeId: string): Record | undefined {
+ const sorted = [...(runs ?? [])].sort((a, b) => b.created_at.localeCompare(a.created_at))
+ for (const run of sorted) {
+ const state: NodeRunState | undefined = run.node_runs?.[nodeId]
+ if (state?.status === 'completed' && state.payload && typeof state.payload === 'object') return state.payload
+ }
+ return undefined
+}
+
+export function mergeFields(...lists: MetricField[][]): MetricField[] {
+ const byPath = new Map()
+ for (const field of lists.flat()) {
+ const existing = byPath.get(field.path)
+ byPath.set(field.path, existing ? { ...existing, countable: existing.countable || field.countable } : field)
+ }
+ return [...byPath.values()]
+}
+
+function identifier(value: string) {
+ return value.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'field'
+}
+
+// A default metric name for each picked field: its leaf key, widened with
+// parent keys only where two picks (or an existing metric) would collide.
+export function defaultFieldNames(
+ picks: { field: Pick; projection: FieldProjection }[],
+ taken: Iterable,
+): string[] {
+ const used = new Set([...taken].map((name) => name.toLocaleLowerCase()))
+ const candidate = (segments: string[], depth: number, count: boolean) =>
+ identifier(segments.slice(-depth).join('_')) + (count ? '_count' : '')
+ const depths = picks.map(() => 1)
+ for (let changed = true; changed;) {
+ changed = false
+ const names = picks.map((pick, index) => candidate(pick.field.segments, depths[index], pick.projection.transform === 'length'))
+ names.forEach((name, index) => {
+ const clash = used.has(name.toLocaleLowerCase()) || names.some((other, j) => j !== index && other === name)
+ if (clash && depths[index] < picks[index].field.segments.length) {
+ depths[index] += 1
+ changed = true
+ }
+ })
+ }
+ const result: string[] = []
+ picks.forEach((pick, index) => {
+ const base = candidate(pick.field.segments, depths[index], pick.projection.transform === 'length')
+ let name = base
+ for (let suffix = 2; used.has(name.toLocaleLowerCase()); suffix += 1) name = `${base}_${suffix}`
+ used.add(name.toLocaleLowerCase())
+ result.push(name)
+ })
+ return result
+}
+
+export function segmentsForPath(path: string, fields: MetricField[]): string[] {
+ return fields.find((field) => field.path === path)?.segments ?? path.split('.')
+}
diff --git a/frontend/src/lib/nodeAccent.ts b/frontend/src/lib/nodeAccent.ts
index 9aea8f3e..1f61de32 100644
--- a/frontend/src/lib/nodeAccent.ts
+++ b/frontend/src/lib/nodeAccent.ts
@@ -48,6 +48,10 @@ const NODE_ACCENTS: Record = {
// Kind fourteen, added rather than moved: nothing above changed hue to make
// room for it (see index.css's --node-9 for why the slot is where it is).
output_parser: 'var(--node-9)',
+ // Kind fifteen. Hue is full (see index.css's --node-9), so this one moves to
+ // the chroma axis instead: a near-neutral steel, the only unsaturated node
+ // on the canvas -- which fits the one kind with no model in it at all.
+ tool_step: 'var(--node-11)',
}
/** The accent color for a canvas node kind — pass the same key the node card
diff --git a/frontend/src/lib/nodeNames.ts b/frontend/src/lib/nodeNames.ts
index 6b3e677e..49733920 100644
--- a/frontend/src/lib/nodeNames.ts
+++ b/frontend/src/lib/nodeNames.ts
@@ -12,6 +12,7 @@ const NODE_TYPE_NAMES: Record = {
agent: 'Agent',
sub_agent: 'Sub-Agent',
critic_gate: 'Critic Gate',
+ tool_step: 'Tool Step',
dataset: 'Dataset',
model_anthropic: 'Anthropic',
model_openai: 'OpenAI',
diff --git a/frontend/src/lib/nodeRuntimeMetrics.test.ts b/frontend/src/lib/nodeRuntimeMetrics.test.ts
new file mode 100644
index 00000000..fff494d6
--- /dev/null
+++ b/frontend/src/lib/nodeRuntimeMetrics.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest'
+import { applyCustomMetricChange } from './customMetrics'
+import { removeMetricFromMeasurementPlan } from './measurementPlan'
+import { nodeRuntimeBindingForMetric } from './nodeRuntimeMetrics'
+
+describe('node runtime metrics', () => {
+ it('writes one binding, with its facts input, per metric', () => {
+ const tokens = { id: 'tokens-dc', name: 'tokens_dc', kind: 'custom' as const }
+ const turns = { id: 'turns-dc', name: 'n_turns_dc', kind: 'custom' as const }
+ let plan = applyCustomMetricChange(null, tokens, { producer: 'node_runtime', nodeIds: ['agent-dc'], output: 'total_tokens' }, undefined)
+ plan = applyCustomMetricChange(plan, turns, { producer: 'node_runtime', nodeIds: ['agent-dc'], output: 'agent_loop_iterations' }, undefined)
+
+ expect(plan.metrics).toContainEqual({ id: 'tokens-dc', name: 'tokens_dc', value_type: 'number', aggregation: 'sum', unit: 'tokens' })
+ expect(nodeRuntimeBindingForMetric(plan, 'tokens-dc')).toMatchObject({
+ producer_id: 'asaree.node_runtime',
+ kind: 'runtime',
+ outputs: { total_tokens: 'tokens-dc' },
+ config: { node_ids: ['agent-dc'] },
+ })
+ expect(plan.inputs.map((input) => input.producer_binding_id)).toEqual(['node-runtime-tokens-dc', 'node-runtime-turns-dc'])
+
+ const edited = applyCustomMetricChange(plan, tokens, { producer: 'node_runtime', nodeIds: ['gate-dc', 'gate-fs'], output: 'output_tokens' }, undefined)
+ expect(nodeRuntimeBindingForMetric(edited, 'tokens-dc')).toMatchObject({
+ outputs: { output_tokens: 'tokens-dc' },
+ config: { node_ids: ['gate-dc', 'gate-fs'] },
+ })
+ expect(edited.producers).toHaveLength(2)
+
+ const removed = removeMetricFromMeasurementPlan(edited, 'tokens-dc')
+ expect(removed?.inputs.map((input) => input.producer_binding_id)).toEqual(['node-runtime-turns-dc'])
+ })
+})
diff --git a/frontend/src/lib/nodeRuntimeMetrics.ts b/frontend/src/lib/nodeRuntimeMetrics.ts
new file mode 100644
index 00000000..67461032
--- /dev/null
+++ b/frontend/src/lib/nodeRuntimeMetrics.ts
@@ -0,0 +1,112 @@
+import type { DesignMetric, MeasurementPlan } from '@/types/experiments'
+import type { ProtocolGraph } from '@/types/protocols'
+
+// Runtime totals over only the runs that chosen canvas nodes launched --
+// `runtime_metrics.NodeRuntimeMetricProducer`. Several nodes sum into one
+// metric (e.g. every Critic Gate as one "critic" stage). One binding per
+// metric, so editing or deleting one never touches another.
+export const NODE_RUNTIME_PRODUCER_ID = 'asaree.node_runtime'
+
+export interface NodeRuntimeOutput {
+ key: string
+ label: string
+ unit?: string
+ aggregation: 'sum' | 'mean'
+}
+
+// Mirrors `NodeRuntimeMetricProducer.output_keys`.
+export const NODE_RUNTIME_OUTPUTS: NodeRuntimeOutput[] = [
+ { key: 'total_tokens', label: 'Total tokens', unit: 'tokens', aggregation: 'sum' },
+ { key: 'input_tokens', label: 'Input tokens', unit: 'tokens', aggregation: 'sum' },
+ { key: 'output_tokens', label: 'Output tokens', unit: 'tokens', aggregation: 'sum' },
+ { key: 'cache_read_tokens', label: 'Cache-read tokens', unit: 'tokens', aggregation: 'sum' },
+ { key: 'cache_creation_tokens', label: 'Cache-creation tokens', unit: 'tokens', aggregation: 'sum' },
+ { key: 'thinking_tokens', label: 'Thinking tokens', unit: 'tokens', aggregation: 'sum' },
+ { key: 'agent_loop_iterations', label: 'Turns', unit: 'turns', aggregation: 'sum' },
+ { key: 'agent_runs', label: 'Runs', unit: 'runs', aggregation: 'sum' },
+ { key: 'tool_calls', label: 'Tool calls', unit: 'calls', aggregation: 'sum' },
+ { key: 'tool_error_rate', label: 'Tool error rate', aggregation: 'mean' },
+ { key: 'capped_agent_runs', label: 'Runs that hit the iteration cap', unit: 'runs', aggregation: 'sum' },
+ { key: 'react_runs', label: 'Reason+Act runs', unit: 'runs', aggregation: 'sum' },
+ { key: 'react_turns', label: 'Reason+Act turns', unit: 'turns', aggregation: 'sum' },
+ { key: 'react_tool_calls', label: 'Reason+Act tool calls', unit: 'calls', aggregation: 'sum' },
+ { key: 'cost_usd', label: 'Cost', unit: 'USD', aggregation: 'sum' },
+]
+
+const NODE_RUNTIME_NODE_TYPES = new Set(['agent', 'sub_agent', 'critic_gate'])
+
+export interface NodeRuntimeSourceOption {
+ nodeId: string
+ label: string
+ type: 'Agent' | 'Critic Gate'
+ disabledReason?: string
+}
+
+export function nodeRuntimeSourceOptions(graph: ProtocolGraph | undefined): NodeRuntimeSourceOption[] {
+ if (!graph) return []
+ return graph.nodes.flatMap((node) => {
+ if (!NODE_RUNTIME_NODE_TYPES.has(node.type)) return []
+ return [{
+ nodeId: node.id,
+ label: node.data.label || node.id,
+ type: node.type === 'critic_gate' ? 'Critic Gate' as const : 'Agent' as const,
+ disabledReason: node.data.active === false ? 'Disabled.' : undefined,
+ }]
+ })
+}
+
+export function nodeRuntimeBindingForMetric(plan: MeasurementPlan | null, metricId: string | undefined) {
+ if (!plan || !metricId) return undefined
+ return plan.producers.find((producer) =>
+ producer.producer_id === NODE_RUNTIME_PRODUCER_ID && Object.values(producer.outputs).includes(metricId),
+ )
+}
+
+export function bindingNodeIds(binding: MeasurementPlan['producers'][number] | undefined): string[] {
+ const nodeIds = binding?.config.node_ids
+ return Array.isArray(nodeIds) ? nodeIds.filter((id): id is string => typeof id === 'string' && !!id) : []
+}
+
+export function nodeRuntimeOutput(key: string | undefined): NodeRuntimeOutput | undefined {
+ return NODE_RUNTIME_OUTPUTS.find((output) => output.key === key)
+}
+
+export function upsertNodeRuntimeMetric(
+ plan: MeasurementPlan | null,
+ metric: DesignMetric,
+ nodeIds: string[],
+ outputKey: string,
+): MeasurementPlan {
+ if (!metric.id) throw new Error('A node runtime metric needs a stable id.')
+ const output = nodeRuntimeOutput(outputKey)
+ if (!output) throw new Error(`Unknown node runtime output ${outputKey}.`)
+ const current = plan ?? { metrics: [], producers: [], inputs: [] }
+ const bindingId = `node-runtime-${metric.id}`
+ return {
+ metrics: [
+ ...current.metrics.filter((definition) => definition.id !== metric.id),
+ {
+ id: metric.id,
+ name: metric.name,
+ value_type: 'number',
+ aggregation: output.aggregation,
+ ...(output.unit ? { unit: output.unit } : {}),
+ },
+ ],
+ producers: [
+ ...current.producers.filter((producer) => producer.id !== bindingId),
+ {
+ id: bindingId,
+ producer_id: NODE_RUNTIME_PRODUCER_ID,
+ kind: 'runtime',
+ outputs: { [outputKey]: metric.id },
+ artifacts: [],
+ config: { node_ids: nodeIds },
+ },
+ ],
+ inputs: [
+ ...current.inputs.filter((input) => input.producer_binding_id !== bindingId),
+ { producer_binding_id: bindingId, input_key: 'facts', source_key: 'attempt.runtime' },
+ ],
+ }
+}
diff --git a/frontend/src/lib/promptReferences.ts b/frontend/src/lib/promptReferences.ts
index 3cab7cca..91f1b608 100644
--- a/frontend/src/lib/promptReferences.ts
+++ b/frontend/src/lib/promptReferences.ts
@@ -1,4 +1,11 @@
-import type { OutputContract, OutputParserNodeConfig, ProtocolEdge, ProtocolNode } from '@/types/protocols'
+import type {
+ EdgeHandoff,
+ OutputContract,
+ OutputContractField,
+ OutputParserNodeConfig,
+ ProtocolEdge,
+ ProtocolNode,
+} from '@/types/protocols'
import { isMainEdge } from '@/lib/coordinationStrategy'
/** The reference syntax, on the authoring side.
@@ -35,13 +42,15 @@ const PREVIOUS_RE = /\{\{\s*previous\s*(\|\s*raw\s*)?\}\}/i
/** The storage form. Mirrors `prompt_references._REFERENCE_RE`'s node arm --
* deliberately narrow, because anything it does not match must survive
* untouched (a prompt may legitimately ask an agent to emit a template). */
-const STORED_RE = /\{\{\s*node:([A-Za-z0-9_-]+)(?:\.([A-Za-z_][A-Za-z0-9_]*))?\s*(\|\s*raw\s*)?\}\}/gi
+const STORED_RE =
+ /\{\{\s*node:([A-Za-z0-9_-]+)(?:\.([A-Za-z_][A-Za-z0-9_]*(?:\[\s*[A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*\s*\])?))?\s*(\|\s*raw\s*)?\}\}/gi
/** An Output Parser field name, on its own. Mirrors `prompt_references._FIELD`:
* it becomes an attribute on the model Motoro builds from the contract, so it
* has to be an identifier -- which is also what makes the display form's
- * `Name.field` split unambiguous enough to reverse. */
-const FIELD_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
+ * `Name.field` split unambiguous enough to reverse. An optional `[key, ...]`
+ * suffix narrows a list field to those item keys and round-trips verbatim. */
+const FIELD_RE = /^[A-Za-z_][A-Za-z0-9_]*(?:\[\s*[A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*\s*\])?$/
/** The display form: any `{{...}}` that is not obviously the storage form.
* Labels contain spaces and punctuation, so this is broad on purpose and the
@@ -98,7 +107,7 @@ function baseName(node: ProtocolNode): string {
* meant to be: it is the visible cost of duplicate labels, and renaming one of
* them removes it.
*/
-function displayNames(nodes: ProtocolNode[]): Record {
+export function displayNames(nodes: ProtocolNode[]): Record {
const counts = new Map()
for (const node of nodes) {
const base = baseName(node).toLowerCase()
@@ -139,6 +148,10 @@ function splitDisplayTarget(target: string, isName: (candidate: string) => boole
* field it may still be carrying.
*/
function parserFields(nodes: ProtocolNode[], edges: ProtocolEdge[], nodeId: string): string[] {
+ return parserContractFields(nodes, edges, nodeId).map((field) => field.name.trim())
+}
+
+function parserContractFields(nodes: ProtocolNode[], edges: ProtocolEdge[], nodeId: string): OutputContractField[] {
const byId = new Map(nodes.map((n) => [n.id, n]))
let wired = false
for (const edge of edges) {
@@ -148,17 +161,40 @@ function parserFields(nodes: ProtocolNode[], edges: ProtocolEdge[], nodeId: stri
wired = true
const config = (source.data as { config?: OutputParserNodeConfig } | undefined)?.config
if (config?.enabled === false) continue
- const names = contractFieldNames(config?.output_contract)
- if (names.length) return names
+ const fields = namedContractFields(config?.output_contract)
+ if (fields.length) return fields
}
if (wired) return []
const legacy = (byId.get(nodeId)?.data as { config?: { output_contract?: OutputContract | null } } | undefined)
?.config?.output_contract
- return contractFieldNames(legacy)
+ return namedContractFields(legacy)
+}
+
+function namedContractFields(contract: OutputContract | null | undefined): OutputContractField[] {
+ return (contract?.fields ?? []).filter((field) => field.name.trim())
}
-function contractFieldNames(contract: OutputContract | null | undefined): string[] {
- return (contract?.fields ?? []).map((field) => field.name.trim()).filter(Boolean)
+/** The fields a main edge out of *senderId* can pass.
+ *
+ * Mirrors `protocol_execution._handoff_sender_contract_fields`: a Critic Gate
+ * forwards its worker's payload, so an edge out of a gate offers the worker's
+ * parser fields. */
+export function handoffSenderFields(nodes: ProtocolNode[], edges: ProtocolEdge[], senderId: string): OutputContractField[] {
+ const sender = nodes.find((n) => n.id === senderId)
+ if (sender?.type === 'critic_gate') {
+ const worker = edges.find((e) => e.target === senderId && isMainEdge(e))?.source
+ return worker ? parserContractFields(nodes, edges, worker) : []
+ }
+ return parserContractFields(nodes, edges, senderId)
+}
+
+/** Short readout of an edge's handoff -- `null` for the full output, which is
+ * the default and needs no mention. */
+export function describeHandoff(handoff: EdgeHandoff | undefined): string | null {
+ if (!handoff || handoff.mode === 'full') return null
+ if (handoff.mode === 'fields') return 'fields only'
+ const count = handoff.fields?.length ?? 0
+ return `${count} field${count === 1 ? '' : 's'}`
}
/** Which nodes *nodeId*'s prompt may reference.
@@ -221,7 +257,10 @@ export function promptReferenceScope(
* one would reintroduce exactly the unasked-for platform prose that design
* removed. So it is surfaced to the user instead, in the Receives readout,
* where a mismatch is something they can act on while wiring. */
-export type HandoffPeer = ReferenceTarget
+export type HandoffPeer = ReferenceTarget & {
+ /** The edge's narrowed handoff (receives only); absent = full output. */
+ handoff?: EdgeHandoff
+}
export interface HandoffPeers {
/** Direct main-edge predecessors -- the nodes whose output is handed to this
@@ -248,20 +287,27 @@ export function handoffPeers(nodes: ProtocolNode[], edges: ProtocolEdge[], nodeI
const names = displayNames(nodes)
if (!nodeId) return { receives: [], sends: [] }
const known = new Set(nodes.map((n) => n.id))
- const peers = (pick: (edge: ProtocolEdge) => string, match: (edge: ProtocolEdge) => string) => {
- const ids: string[] = []
+ const peers = (pick: (edge: ProtocolEdge) => string, match: (edge: ProtocolEdge) => string): HandoffPeer[] => {
+ const edgeById = new Map()
for (const edge of edges) {
if (!isMainEdge(edge) || match(edge) !== nodeId) continue
const id = pick(edge)
- if (id !== nodeId && known.has(id) && !ids.includes(id)) ids.push(id)
+ if (id !== nodeId && known.has(id) && !edgeById.has(id)) edgeById.set(id, edge)
}
// Canvas declaration order, matching the picker -- an edge list's own order
// is whatever the user happened to draw in.
return nodes
- .filter((n) => ids.includes(n.id))
+ .filter((n) => edgeById.has(n.id))
.map((n) => {
- const fields = parserFields(nodes, edges, n.id)
- return { id: n.id, name: names[n.id], ...(fields.length ? { fields } : {}) }
+ // What the edge can carry: a gate forwards its worker's fields.
+ const fields = handoffSenderFields(nodes, edges, n.id).map((field) => field.name.trim())
+ const handoff = edgeById.get(n.id)?.data?.handoff
+ return {
+ id: n.id,
+ name: names[n.id],
+ ...(fields.length ? { fields } : {}),
+ ...(handoff && handoff.mode !== 'full' ? { handoff } : {}),
+ }
})
}
return {
diff --git a/frontend/src/lib/protocolGraph.ts b/frontend/src/lib/protocolGraph.ts
index 29ea7eaf..f9c43feb 100644
--- a/frontend/src/lib/protocolGraph.ts
+++ b/frontend/src/lib/protocolGraph.ts
@@ -1,6 +1,6 @@
import type { QueryClient } from '@tanstack/react-query'
import type { Edge, Node } from '@xyflow/react'
-import type { AgentNodeData, CriticGateNodeData, McpToolNodeData, Protocol, ProtocolGraph } from '@/types/protocols'
+import type { AgentNodeData, CriticGateNodeData, McpToolNodeData, Protocol, ProtocolEdge, ProtocolGraph } from '@/types/protocols'
// The name the canvas gives a protocol it auto-creates for an experiment.
// The "[shortid]" suffix is load-bearing: protocol names are unique per owner
@@ -95,12 +95,18 @@ export function toPersistedGraph(nodes: Node[], edges: Edge[]): ProtocolGraph {
position: n.position,
data: n.data as AgentNodeData | McpToolNodeData | CriticGateNodeData,
})),
- edges: edges.map((e) => ({
- id: e.id,
- source: e.source,
- target: e.target,
- sourceHandle: e.sourceHandle,
- targetHandle: e.targetHandle,
- })),
+ edges: edges.map((e) => {
+ // Only the handoff is persisted from edge data -- the rest of it
+ // (`directedFlow`) is display state the canvas derives on render.
+ const handoff = (e.data as ProtocolEdge['data'])?.handoff
+ return {
+ id: e.id,
+ source: e.source,
+ target: e.target,
+ sourceHandle: e.sourceHandle,
+ targetHandle: e.targetHandle,
+ ...(handoff && handoff.mode !== 'full' ? { data: { handoff } } : {}),
+ }
+ }),
}
}
diff --git a/frontend/src/lib/pythonScriptMetrics.ts b/frontend/src/lib/pythonScriptMetrics.ts
index f91ec81c..bdf5c8c8 100644
--- a/frontend/src/lib/pythonScriptMetrics.ts
+++ b/frontend/src/lib/pythonScriptMetrics.ts
@@ -52,12 +52,6 @@ export function upsertPythonScriptMetric(
{
id: metric.id,
name: metric.name,
- value_type: 'opaque',
- direction: 'neutral',
- aggregation: 'none',
- primary: false,
- description: metric.description,
- unit: metric.unit,
},
],
producers: [
diff --git a/frontend/src/lib/reportedMetrics.test.ts b/frontend/src/lib/reportedMetrics.test.ts
index 2e495d88..13ffb6b9 100644
--- a/frontend/src/lib/reportedMetrics.test.ts
+++ b/frontend/src/lib/reportedMetrics.test.ts
@@ -10,10 +10,6 @@ const metric: DesignMetric = {
id: 'quality',
name: 'Quality',
kind: 'custom',
- valueType: 'opaque',
- direction: 'neutral',
- aggregation: 'none',
- primary: false,
}
const graph = {
@@ -29,10 +25,10 @@ const graph = {
} as unknown as ProtocolGraph
describe('reported custom metric plans', () => {
- it('stores Agent output as an opaque, display-only metric', () => {
+ it('stores an Agent output metric without value semantics', () => {
const plan = upsertAgentOutputMetric(null, metric, 'agent')
- expect(plan.metrics[0]).toMatchObject({ value_type: 'opaque', direction: 'neutral', aggregation: 'none', primary: false })
+ expect(plan.metrics[0]).toEqual({ id: 'quality', name: 'Quality' })
expect(plan.producers[0]).toMatchObject({ producer_id: 'asaree.agent_output', kind: 'reported', config: { agent_node_id: 'agent' } })
expect(plan.inputs).toEqual([])
expect(localMetricReadinessPreview(metric, plan, graph).ready).toBe(true)
diff --git a/frontend/src/lib/toolStepMetrics.ts b/frontend/src/lib/toolStepMetrics.ts
new file mode 100644
index 00000000..b9f16327
--- /dev/null
+++ b/frontend/src/lib/toolStepMetrics.ts
@@ -0,0 +1,53 @@
+import type { DesignMetric, MeasurementPlan } from '@/types/experiments'
+import type { ProtocolGraph } from '@/types/protocols'
+import { projectionConfig, type FieldProjection } from './metricFields'
+
+export const TOOL_STEP_PRODUCER_ID = 'asaree.tool_step'
+
+export interface ToolStepSourceOption {
+ nodeId: string
+ label: string
+ disabledReason?: string
+}
+
+export function toolStepSourceOptions(graph: ProtocolGraph | undefined): ToolStepSourceOption[] {
+ if (!graph) return []
+ return graph.nodes.flatMap((node) => {
+ if (node.type !== 'tool_step') return []
+ const disabledReason = node.data.active === false ? 'Tool Step is disabled.' : undefined
+ return [{ nodeId: node.id, label: node.data.label || node.id, disabledReason }]
+ })
+}
+
+export function toolStepBindingForMetric(plan: MeasurementPlan | null, metricId: string | undefined) {
+ if (!plan || !metricId) return undefined
+ return plan.producers.find((producer) =>
+ producer.producer_id === TOOL_STEP_PRODUCER_ID && Object.values(producer.outputs).includes(metricId),
+ )
+}
+
+export function upsertToolStepMetric(
+ plan: MeasurementPlan | null,
+ metric: DesignMetric,
+ nodeId: string,
+ projection: FieldProjection | undefined,
+): MeasurementPlan {
+ if (!metric.id) throw new Error('A Tool Step metric needs a stable id.')
+ const current = plan ?? { metrics: [], producers: [], inputs: [] }
+ const bindingId = `tool-step-${metric.id}`
+ return {
+ metrics: [...current.metrics.filter((definition) => definition.id !== metric.id), { id: metric.id, name: metric.name }],
+ producers: [
+ ...current.producers.filter((producer) => producer.id !== bindingId),
+ {
+ id: bindingId,
+ producer_id: TOOL_STEP_PRODUCER_ID,
+ kind: 'reported',
+ outputs: { value: metric.id },
+ artifacts: [],
+ config: { node_id: nodeId, ...projectionConfig(projection) },
+ },
+ ],
+ inputs: current.inputs.filter((input) => input.producer_binding_id !== bindingId),
+ }
+}
diff --git a/frontend/src/types/experiments.ts b/frontend/src/types/experiments.ts
index 5e745691..92fe91e5 100644
--- a/frontend/src/types/experiments.ts
+++ b/frontend/src/types/experiments.ts
@@ -33,8 +33,8 @@ export interface DesignMetric {
kind?: 'runtime' | 'custom'
valueType?: 'number' | 'boolean' | 'string' | 'opaque'
unit?: string
- primary: boolean
- direction: 'maximize' | 'minimize' | 'neutral'
+ primary?: boolean
+ direction?: 'maximize' | 'minimize' | 'neutral'
aggregation?: 'mean' | 'sum' | 'none'
[key: string]: unknown
}
@@ -47,10 +47,10 @@ export interface MeasurementPlan {
metrics: Array<{
id: string
name: string
- value_type: 'number' | 'boolean' | 'opaque'
- direction: MeasurementDirection
- aggregation: MeasurementAggregation
- primary: boolean
+ value_type?: 'number' | 'boolean' | 'opaque'
+ direction?: MeasurementDirection
+ aggregation?: MeasurementAggregation
+ primary?: boolean
description?: string
unit?: string
}>
@@ -315,7 +315,7 @@ export interface ResultNodeRun {
export interface MetricObservation {
metric_id: string
metric_name: string
- value_type: 'number' | 'boolean' | 'opaque'
+ value_type?: 'number' | 'boolean' | 'opaque'
status: ObservationStatus
value: unknown
error: string | null
diff --git a/frontend/src/types/protocols.ts b/frontend/src/types/protocols.ts
index f3228f4c..2a992a68 100644
--- a/frontend/src/types/protocols.ts
+++ b/frontend/src/types/protocols.ts
@@ -26,6 +26,7 @@ export interface ProtocolNode {
| AgentNodeData
| McpToolNodeData
| CriticGateNodeData
+ | ToolStepNodeData
| ModelNodeData
| MemoryNodeData
| OutputParserNodeData
@@ -38,12 +39,29 @@ export interface ProtocolNode {
| SingleAgentBaselinePatternNodeData
}
+/** What a main edge passes to the Agent it feeds (`edge.data.handoff`;
+ * absent = `full`). Mirrors `protocol_execution.HANDOFF_MODES`. There is no
+ * "nothing": an edge always carries something. */
+export type EdgeHandoffMode = 'full' | 'fields' | 'selected'
+
+export interface EdgeHandoffField {
+ name: string
+ /** Narrows a list of objects to these keys (one key -> a list of values). */
+ item_keys?: string[]
+}
+
+export interface EdgeHandoff {
+ mode: EdgeHandoffMode
+ fields?: EdgeHandoffField[]
+}
+
export interface ProtocolEdge {
id: string
source: string
target: string
sourceHandle?: string | null
targetHandle?: string | null
+ data?: { handoff?: EdgeHandoff }
}
export type NodeRunStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled'
@@ -417,6 +435,46 @@ export function defaultCriticGateNodeData(label = 'Critic Gate'): CriticGateNode
}
}
+// A "Tool Step" calls one MCP tool directly in the main flow -- no LLM, so the
+// same upstream payload always produces the same call. Deliberately use-case
+// agnostic: each argument it sends names where its value comes from, and
+// anything the tool needs done to its inputs (e.g. sanitizing) is the tool's
+// job (services/tool_steps.py). Its Tool connector takes exactly one MCP Tool
+// node and at most one Script.
+export type ToolStepArgumentSource =
+ | { source: 'value'; value: unknown }
+ // The upstream node's typed payload -- as canonical JSON text or the object
+ // itself; `null` when upstream produced none.
+ | { source: 'upstream_payload'; format?: 'json_string' | 'object' }
+ | { source: 'script_code' }
+ | { source: 'workspace_id' }
+
+export interface ToolStepNodeConfig {
+ tool_name: string
+ // Only mapped arguments are sent.
+ arguments: Record
+ // result field -> argument name: the tool must report that argument's
+ // SHA-256 (as sent) in that field, or the step fails.
+ hash_checks?: Record
+ timeout_seconds?: number | null
+}
+
+export interface ToolStepNodeData {
+ label: string
+ active?: boolean
+ config: ToolStepNodeConfig
+ factor_bindings?: Record
+ [key: string]: unknown
+}
+
+export function defaultToolStepNodeData(label = 'Tool Step'): ToolStepNodeData {
+ return {
+ label,
+ active: true,
+ config: { tool_name: '', arguments: {}, hash_checks: {} },
+ }
+}
+
// The Model connector's node family. One node type per provider
// (model_anthropic/model_openai/model_azure_foundry/model_openrouter/model_local), not
// one generic node with a Provider field -- a dedicated node per capability
diff --git a/mcp-servers/asaree-sklearn-core/src/asaree_sklearn_core/hyperparams.py b/mcp-servers/asaree-sklearn-core/src/asaree_sklearn_core/hyperparams.py
new file mode 100644
index 00000000..c5a0d940
--- /dev/null
+++ b/mcp-servers/asaree-sklearn-core/src/asaree_sklearn_core/hyperparams.py
@@ -0,0 +1,163 @@
+"""Coerce a hyperparameter payload into one a modeling script can run.
+
+The payload contract ``run_model_script`` binds as ``hp``::
+
+ {"search_space": [{"param": str, "low": num, "high": num}, ...],
+ "fixed_params": {param: num, ...},
+ "n_trials": int}
+
+*param_spec* is the caller's closed vocabulary of tunable parameters and
+their hard bounds (``{param: {"low": num, "high": num, ...}}``) -- it is data
+the caller passes, not something this module knows, so the same sanitizer
+serves any model family. A port of the spinal notebook's ``sanitize_payload``:
+every suggestion outside the spec is DROPPED (the script's own default then
+stands) and noted, so a malformed suggestion still scores instead of crashing
+the script.
+"""
+
+from __future__ import annotations
+
+import json
+import math
+from collections.abc import Mapping
+from typing import Any, TypeGuard
+
+DEFAULT_N_TRIALS = 30
+MAX_N_TRIALS = 100
+
+
+def _is_number(value: Any) -> TypeGuard[int | float]:
+ return isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(float(value))
+
+
+def _sanitize_n_trials(value: Any, notes: list[str], default: int) -> int:
+ if not _is_number(value):
+ notes.append(f"n_trials was not a number ({value!r}); defaulted to {default}")
+ return default
+ rounded = int(round(float(value)))
+ if rounded < 1:
+ notes.append(f"n_trials {value} < 1; raised to 1")
+ return 1
+ if rounded > MAX_N_TRIALS:
+ notes.append(f"n_trials {value} > {MAX_N_TRIALS}; capped at {MAX_N_TRIALS}")
+ return MAX_N_TRIALS
+ return rounded
+
+
+def sanitize_hyperparameter_payload(
+ payload: Any,
+ param_spec: Mapping[str, Mapping[str, Any]],
+ *,
+ default_n_trials: int = DEFAULT_N_TRIALS,
+) -> tuple[dict[str, Any], list[str]]:
+ """``(clean_payload, notes)``; notes is empty when the payload was already clean."""
+ spec = param_spec
+ notes: list[str] = []
+ if not isinstance(payload, Mapping):
+ notes.append(
+ f"payload was not a JSON object ({type(payload).__name__}); scored with all-default hyperparameters"
+ )
+ return {"search_space": [], "fixed_params": {}, "n_trials": default_n_trials}, notes
+
+ def in_bounds(name: str, value: float) -> bool:
+ return float(spec[name]["low"]) <= value <= float(spec[name]["high"])
+
+ clean_search: list[dict[str, Any]] = []
+ seen: set[str] = set()
+ raw_search = payload.get("search_space")
+ if isinstance(raw_search, list):
+ for index, entry in enumerate(raw_search):
+ where = f"search_space[{index}]"
+ if not isinstance(entry, Mapping):
+ notes.append(f"dropped {where}: not an object")
+ continue
+ name = entry.get("param")
+ if name is None:
+ notes.append(
+ f'dropped {where}: missing the "param" key (keys: {sorted(entry)}) -> that param uses its default'
+ )
+ continue
+ if name not in spec:
+ notes.append(
+ f"dropped {where}: {name!r} is not a tunable parameter -> not passed to the model "
+ "(uses its default)"
+ )
+ continue
+ if name in seen:
+ notes.append(f"dropped {where}: {name!r} already tuned earlier (duplicate)")
+ continue
+ low, high = entry.get("low"), entry.get("high")
+ if not _is_number(low) or not _is_number(high):
+ notes.append(f"dropped {where} ({name!r}): non-numeric low/high -> uses its default")
+ continue
+ if low >= high:
+ notes.append(
+ f"dropped {where} ({name!r}): needs low < high, got low={low}, high={high} -> uses its default"
+ )
+ continue
+ if not in_bounds(name, low) or not in_bounds(name, high):
+ notes.append(
+ f"dropped {where} ({name!r}): range [{low}, {high}] outside its hard bound "
+ f"[{spec[name]['low']}, {spec[name]['high']}] -> uses its default"
+ )
+ continue
+ seen.add(name)
+ clean_search.append({"param": name, "low": low, "high": high})
+ elif raw_search is not None:
+ notes.append(f"ignored search_space: not a list ({type(raw_search).__name__})")
+
+ clean_fixed: dict[str, Any] = {}
+ raw_fixed = payload.get("fixed_params")
+ if isinstance(raw_fixed, Mapping):
+ for name, value in raw_fixed.items():
+ if name not in spec:
+ notes.append(
+ f"dropped fixed_params[{name!r}]: not a settable parameter (harness-fixed or unknown) "
+ "-> the script's own default stands"
+ )
+ continue
+ if name in seen:
+ notes.append(
+ f"dropped fixed_params[{name!r}]: already tuned in search_space (cannot be both) "
+ "-> kept the tuned range"
+ )
+ continue
+ if not _is_number(value):
+ notes.append(f"dropped fixed_params[{name!r}]: non-numeric value {value!r} -> uses its default")
+ continue
+ if not in_bounds(name, value):
+ notes.append(
+ f"dropped fixed_params[{name!r}]={value}: outside its hard bound "
+ f"[{spec[name]['low']}, {spec[name]['high']}] -> uses its default"
+ )
+ continue
+ clean_fixed[name] = value
+ elif raw_fixed is not None:
+ notes.append(f"ignored fixed_params: not an object ({type(raw_fixed).__name__})")
+
+ n_trials = _sanitize_n_trials(payload.get("n_trials"), notes, default_n_trials)
+ if not clean_search and n_trials > 1:
+ notes.append(f"no tunable parameters survived; n_trials reduced {n_trials} -> 1")
+ n_trials = 1
+ return {"search_space": clean_search, "fixed_params": clean_fixed, "n_trials": n_trials}, notes
+
+
+def parse_param_spec(param_spec_json: str) -> dict[str, dict[str, Any]]:
+ """Validate a ``param_spec_json`` argument; raises ``ValueError`` naming the problem."""
+ spec = _loads_object(param_spec_json, "param_spec_json")
+ for name, bounds in spec.items():
+ if not isinstance(bounds, Mapping) or not _is_number(bounds.get("low")) or not _is_number(bounds.get("high")):
+ raise ValueError(f"param_spec_json[{name!r}] needs numeric 'low' and 'high'")
+ if bounds["low"] > bounds["high"]:
+ raise ValueError(f"param_spec_json[{name!r}] has low > high")
+ return {str(name): dict(bounds) for name, bounds in spec.items()}
+
+
+def _loads_object(text: str, what: str) -> dict[str, Any]:
+ try:
+ value = json.loads(text)
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"{what} is not valid JSON: {exc}") from exc
+ if not isinstance(value, dict):
+ raise ValueError(f"{what} must be a JSON object, got {type(value).__name__}")
+ return value
diff --git a/mcp-servers/asaree-sklearn-model/src/asaree_sklearn_model/server.py b/mcp-servers/asaree-sklearn-model/src/asaree_sklearn_model/server.py
index 47bb59cd..a09deb1c 100644
--- a/mcp-servers/asaree-sklearn-model/src/asaree_sklearn_model/server.py
+++ b/mcp-servers/asaree-sklearn-model/src/asaree_sklearn_model/server.py
@@ -34,6 +34,7 @@
from mcp.server.fastmcp import Context
from asaree_sklearn_core import model, provenance
+from asaree_sklearn_core.hyperparams import DEFAULT_N_TRIALS, parse_param_spec, sanitize_hyperparameter_payload
INSTRUCTIONS = """\
Fit a classifier from your own script and score it on a held-out split.
@@ -158,6 +159,8 @@ def run_model_script(
payload_json: str = "",
workspace_id: str = "",
slot: str = "",
+ param_spec_json: str = "",
+ default_n_trials: int = DEFAULT_N_TRIALS,
ctx: Context | None = None,
) -> str:
"""Execute an approved modeling script that NEVER sees the test split.
@@ -189,7 +192,19 @@ def run_model_script(
selection_metric: scores permutation importance ('average_precision' -> PR-AUC
drop; otherwise ROC-AUC drop).
payload_json: optional typed hyperparameter payload; bound as ``hp`` (its
- SHA-256 is returned as payload_sha256).
+ SHA-256 -- of the string exactly as received -- is returned as
+ payload_sha256).
+ param_spec_json: optional closed vocabulary of tunable parameters and
+ their hard bounds, ``{"param": {"low": num, "high": num}, ...}``.
+ When given, payload_json is read as the ``{search_space,
+ fixed_params, n_trials}`` contract and every suggestion outside the
+ spec is dropped (the script's default stands) before binding ``hp``
+ -- so a malformed suggestion still scores. What was dropped comes
+ back as ``sanitize_notes`` / ``n_sanitize_notes``, and the payload
+ actually bound as ``sanitized_payload`` / ``sanitized_payload_sha256``.
+ A missing or non-object payload then scores with all defaults
+ instead of failing.
+ default_n_trials: n_trials used when a sanitized payload gives none.
workspace_id: Cell workspace id; when omitted, resolved from ambient _meta.
The matrices are read from the accepted HEAD version on disk.
slot: Which dataset slot of the cell's workspace to read. Optional --
@@ -208,8 +223,29 @@ def run_model_script(
hp: dict[str, Any] | None = None
payload_sha256 = ""
+ sanitized: dict[str, Any] = {}
if payload_json:
payload_sha256 = hashlib.sha256(payload_json.encode("utf-8")).hexdigest()
+ if param_spec_json:
+ try:
+ spec = parse_param_spec(param_spec_json)
+ except ValueError as e:
+ return json.dumps({"error": str(e), "code_sha256": code_sha256, "payload_sha256": payload_sha256})
+ try:
+ raw_hp: Any = json.loads(payload_json) if payload_json else None
+ except json.JSONDecodeError:
+ raw_hp = None
+ hp, notes = sanitize_hyperparameter_payload(raw_hp, spec, default_n_trials=default_n_trials)
+ # Hashed the way callers serialize a payload (sorted keys, compact), so
+ # the hash of what was actually bound is comparable across runs.
+ canonical = json.dumps(hp, sort_keys=True, separators=(",", ":"))
+ sanitized = {
+ "sanitized_payload": hp,
+ "sanitized_payload_sha256": hashlib.sha256(canonical.encode("utf-8")).hexdigest(),
+ "sanitize_notes": notes,
+ "n_sanitize_notes": len(notes),
+ }
+ elif payload_json:
try:
hp = json.loads(payload_json)
except json.JSONDecodeError as e:
@@ -307,6 +343,7 @@ def run_model_script(
"executed_code": code,
"code_sha256": code_sha256,
"payload_sha256": payload_sha256,
+ **sanitized,
"n_train": int(len(X_train)),
"n_test": int(len(X_test)),
"n_features": int(X_train.shape[1]),
diff --git a/publications/BDM/README.md b/publications/BDM/README.md
index 24ee0649..ee0c3d0a 100644
--- a/publications/BDM/README.md
+++ b/publications/BDM/README.md
@@ -1,9 +1,10 @@
# Myocardial infarction use case
-A complete, runnable ASAREE experiment: five agents in series
-(**DC → FTE → FS → MLM → Score**) build a binary classifier for chronic heart
-failure after a myocardial infarction, each one handing a versioned dataset
-workspace to the next, each (except Score) behind an optional critic gate.
+A complete, runnable ASAREE experiment: four agents in series
+(**DC → FTE → FS → MLM**) build a binary classifier for chronic heart failure
+after a myocardial infarction, each one handing a versioned dataset workspace
+to the next behind an optional critic gate, and a deterministic **Score** Tool
+Step evaluates MLM's approved hyperparameter payload on the held-out split.
It's the public counterpart of the spinal-surgery use case in the paper — same
protocol shape, on a dataset anyone can download.
@@ -12,9 +13,9 @@ protocol shape, on a dataset anyone can download.
| File | |
| --- | --- |
-| `myocardial-azure-foundry-v0.8.0.json` | The experiment, wired to **Azure Foundry** — what `import_use_case.py` imports by default |
-| `myocardial-anthropic-v0.8.0.json` | The same, wired to **Anthropic** |
-| `myocardial-openai-v0.8.0.json` | The same, wired to **OpenAI** |
+| `myocardial-azure-foundry.json` | The experiment, wired to **Azure Foundry** — what `import_use_case.py` imports by default |
+| `myocardial-anthropic.json` | The same, wired to **Anthropic** |
+| `myocardial-openai.json` | The same, wired to **OpenAI** |
| `mi_ZSN.csv` | The dataset — 1700 admissions × 111 features, target `mi_ZSN` |
| `dict_ZSN.json` | The data dictionary for those 111 columns |
| `stats/` | The paper's analysis scripts and outputs for the spinal runs (not part of this walkthrough) |
@@ -27,11 +28,11 @@ have:
| File | Model factor | Effort factor | Design size |
| --- | --- | --- | --- |
-| `myocardial-anthropic-v0.8.0.json` | `claude-sonnet-5`, `claude-opus-5` | `medium`, `xhigh` | 8 cells / 80 replicates |
-| `myocardial-openai-v0.8.0.json` | `gpt-5-mini`, `gpt-5` | `medium`, `high` | 8 cells / 80 replicates |
-| `myocardial-azure-foundry-v0.8.0.json` | `claude-sonnet-5`, `claude-opus-5` | `medium`, `xhigh` | 8 cells / 80 replicates |
+| `myocardial-anthropic.json` | `claude-sonnet-5`, `claude-opus-5` | `medium`, `xhigh` | 8 cells / 160 replicates |
+| `myocardial-openai.json` | `gpt-5-mini`, `gpt-5` | `medium`, `high` | 8 cells / 160 replicates |
+| `myocardial-azure-foundry.json` | `claude-sonnet-5`, `claude-opus-5` | `medium`, `xhigh` | 8 cells / 160 replicates |
-All three are 2 × 2 × 2 designs (model × effort × critic on/off) at 10
+All three are 2 × 2 × 2 designs (model × effort × critic on/off) at 20
replicates, with the smaller/larger model of a family at the middle and top of
its provider's effort ladder. The two ladders aren't the same length: OpenAI's
`reasoning_effort` stops at `high`, so `high` is that variant's counterpart to
@@ -64,21 +65,69 @@ critic gates, with these v0.8.0 execution details:
workspace before the agent's first turn, and a wired Script reaches
`run_model_script` as a path in ambient `_meta` — so DC/FTE/FS call a bare
`open_workspace(stage=...)` instead of passing `experiment_id`/`cell_label`/
- `name`, and Score calls `run_model_script` with **no** `code` argument
- instead of retyping the wired script.
+ `name`.
+- **Scoring is a Tool Step, not an agent.** Like the paper's notebook
+ (`score_payload`), nothing about the scoring call is left to a model: the
+ generic `tool-step-score` node calls `run_model_script` directly, mapping
+ each argument to a source -- the wired Script's code, MLM's approved payload
+ (as canonical JSON), the replicate's workspace, and fixed values
+ (`random_seed=20260705`, `selection_metric=average_precision`, and the
+ XGBoost search space as `param_spec_json`). `run_model_script` itself runs
+ the notebook's `sanitize_payload` port against that spec (every
+ out-of-vocabulary or out-of-bound suggestion is dropped and noted, so a
+ malformed payload still scores instead of crashing). The step fails unless
+ the tool reports the exact `code_sha256` and `payload_sha256` of what it
+ sent, and what it sent is recorded on the replicate *before* the call.
+- **Iteration budget.** Every Reason + Act pattern allows 12 iterations, the
+ notebook's `max_iterations`.
- **Published execution.** The import helper publishes the localized graph as
an immutable protocol revision before generating or running replicates.
- **Visible output contracts.** Each agent's declared output shape lives in a
connected Output Parser node rather than the legacy hidden
`config.output_contract` field. The contracts and runtime behavior are
unchanged; the canvas now exposes where each structured payload is defined.
-- **Declared measurements.** The complete held-out scoring response is retained
- as an opaque provenance record, while typed projections expose PR-AUC (the
- primary metric), ROC-AUC, Brier and operating-point diagnostics, pipeline
- feature counts, Optuna/XGBoost decisions, SHA-256 guards, and the built-in
- runtime metrics as analysis-ready Results and CSV columns. Agent-stage values
- come from their connected Output Parser payloads; scoring values come from
- MI-Score's exact `asaree-sklearn-model.run_model_script` call.
+- **Declared measurements.** Each scoring value is its own numeric metric,
+ read by exact dotted path from the Tool Step's result
+ (`asaree.tool_step` — e.g. `test_metrics.average_precision`,
+ `test_metrics.metrics_at_0.5.f1`): PR-AUC, ROC-AUC, Brier and
+ operating-point diagnostics, Optuna/XGBoost decisions, the SHA-256 guards,
+ and `n_schema_violations` (entries the sanitizer dropped, from the tool's
+ `n_sanitize_notes`). Feature counts
+ (`n_features_after_dc/fte/fs`, `n_features_created`,
+ `n_engineered_features_selected`, `frac_created_selected`) are computed by
+ `asaree.feature_pipeline` from the stage payloads and the raw dataset's
+ columns, the way the notebook's `process_metrics` does, rather than trusted
+ from an agent's self-report. The same producer reports the FTE recipe's
+ `n_recipe_ops`, `recipe_depth` and `recipe_hash`. The notebook's critic and
+ control-flow columns come from the built-in runtime producer: critic
+ invocations, rejections split by partial vs. full scope (an unscoped
+ rejection counts as full), revision rounds, and Reason+Act runs that hit
+ their iteration ceiling (`react_capped_runs`). It also reports
+ `n_agent_runs` (`agent_runs`) and the Reason+Act loop's own counters
+ `react_runs`, `react_turns` and `react_tool_calls`, read from each run's
+ `reason_act_state`; the last two are unavailable when no Reason+Act run took
+ part, as the notebook leaves them null. The same producer reports `prompt_sha256`: a hash of
+ the published revision's stage prompts, critic system prompts (which carry
+ the review criteria) and Output Parser contracts, with factor values applied.
+ The notebook's per-stage `tokens_` and `n_turns_` (dc, fte,
+ fs, mlm, critic) come from `asaree.node_runtime`: one binding per stage, each
+ listing the canvas nodes that make it up in `config.node_ids` (the critic
+ stage lists all four gates), summing every run those nodes launched,
+ revisions included. The remaining agent-stage values come from their
+ Output Parser payloads.
+- **MLM brief and critic task_brief.** The notebook's `summarize_for_mlm`
+ brief is rebuilt in two parts. The edge from Critic (FS) into the MLM is set
+ to **Selected fields** (`edge.data.handoff`, on the edge's hover toolbar):
+ `selected_features`, `n_features_out`, `observed_class_distribution`,
+ `class_balance_check`, `notes_for_mlm`. The FS prose report doesn't pass.
+ The FTE/DC parts, which come from further upstream, are field references in
+ the MLM goal. They're narrowed the same way the notebook narrows them:
+ `{{node:agent-fte.engineering_recipe[name]}}` passes only the step names,
+ `{{node:agent-fte.encoding_map[feature, encoding]}}` passes only those two
+ keys, and DC passes `notes_for_fte`. If FS extracted none of the selected fields, the edge
+ falls back to the full FS output. Each Critic Gate's system prompt includes
+ the `task_brief`, because the canvas sends a critic only the output it
+ reviews.
That `open_workspace(stage=...)` call is deliberately kept: seeding the
workspace materializes `v0_raw`, but *not* a stage's `.scratch` input, which is
@@ -125,7 +174,7 @@ provider file that matches the credential you will use:
```bash
uv run --with ./sdk python publications/BDM/import_use_case.py \
- publications/BDM/myocardial-openai-v0.8.0.json
+ publications/BDM/myocardial-openai.json
```
The helper registers and splits the dataset, maps deployment-specific dataset
@@ -180,8 +229,8 @@ built from the train half; the test half is only touched by the final model
script. Re-splitting at a different seed overwrites rather than accumulating.
**7. Inspect the generated design.** The **Design** and **Cells** tabs show
-2 × 2 × 2 factor combinations = **8 cells**, with 10 replicates per cell
-(**80 replicates** total). The imported canvas is already published.
+2 × 2 × 2 factor combinations = **8 cells**, with 20 replicates per cell
+(**160 replicates** total). The imported canvas is already published.
**8. Run.** Two ways:
@@ -194,8 +243,8 @@ results land in **Cells** and **Results**.
## Cost
-Generating the grid is free — cells and replicates are rows until a run starts — but 80 runs
-of a five-agent pipeline, half of them at the top of the effort ladder, is a
+Generating the grid is free — cells and replicates are rows until a run starts — but 160 runs
+of a four-agent pipeline, half of them at the top of the effort ladder, is a
real bill. Run one cell
first. If you only want a smoke test, lower **Replicates** on the Design tab and
regenerate before running; ASAREE opens a new design revision when the change
diff --git a/publications/BDM/import_use_case.py b/publications/BDM/import_use_case.py
index 0d9ee6c8..4b44c57c 100644
--- a/publications/BDM/import_use_case.py
+++ b/publications/BDM/import_use_case.py
@@ -3,10 +3,10 @@
ASAREE_BASE_URL=http://localhost:8000 ASAREE_API_KEY=... \
uv run --with ./sdk python publications/BDM/import_use_case.py \
- [myocardial-anthropic-v0.8.0.json]
+ [myocardial-anthropic.json]
The API-side equivalent of README.md's GUI walkthrough, for rebuilding the
-experiment from scratch repeatedly. It accepts the v0.8.0 provider variants
+experiment from scratch repeatedly. It accepts the provider variants
and defaults to Azure Foundry.
Idempotent: run it again and it updates the existing experiment rather than
creating a second copy.
@@ -19,7 +19,7 @@
4. Atomically import the experiment and its canvas (or update an existing one),
then attach the dataset.
5. Publish an immutable protocol revision.
-6. Apply the design and materialize its eight cells and 80 replicates.
+6. Apply the design and materialize its eight cells and 160 replicates.
"""
from __future__ import annotations
@@ -35,7 +35,7 @@
HERE = Path(__file__).resolve().parent
-DEFAULT_USE_CASE_FILE = HERE / "myocardial-azure-foundry-v0.8.0.json"
+DEFAULT_USE_CASE_FILE = HERE / "myocardial-azure-foundry.json"
DATA_FILE = HERE / "mi_ZSN.csv"
DICTIONARY_FILE = HERE / "dict_ZSN.json"
diff --git a/publications/BDM/myocardial-anthropic-v0.8.0.json b/publications/BDM/myocardial-anthropic.json
similarity index 65%
rename from publications/BDM/myocardial-anthropic-v0.8.0.json
rename to publications/BDM/myocardial-anthropic.json
index 4ce99ce5..8ee980b6 100644
--- a/publications/BDM/myocardial-anthropic-v0.8.0.json
+++ b/publications/BDM/myocardial-anthropic.json
@@ -12,7 +12,7 @@
"primary_scientific_outcome": "average precision on a held-out stratified test split"
},
"name": "Myocardial Infarction Use Case (Anthropic)",
- "description": "MI-DC -> MI-FTE -> MI-FS -> MI-MLM -> MI-Score: five agents in series on the UCI Myocardial Infarction Complications (ZSN) dataset, each handing a versioned dataset workspace to the next, the first four behind a Critic Gate (MI-Score is intentionally ungated -- it only executes a fixed scoring script, so there is no design decision left to review). The pipeline's methodology (cleaning, feature engineering, selection, hyperparameter planning, scoring) and the critics' review criteria are dataset-agnostic; everything dataset-specific lives in each worker's own task_brief block. Three facts are stated there rather than left for an agent to discover cold: (1) the dataset carries an explicit ID column (role=id, 1..1700, all-unique) that must be dropped as a non-feature record identifier; (2) every feature is RAW -- a mix of integer/real/binary/ordinal/categorical types with real missingness -- so DC's and FTE's inspect-first instructions do real work here; (3) the column names are short Russian-derived codes (nr11, np01, zab_leg_01), not descriptive English, so every column's meaning must be resolved via get_data_dictionary rather than guessed from its name. expected_positive_rate (0.232) is 394 positive of 1700. See README.md in this directory for how to import and run this.",
+ "description": "MI-DC -> MI-FTE -> MI-FS -> MI-MLM -> Score: four agents in series on the UCI Myocardial Infarction Complications (ZSN) dataset, each handing a versioned dataset workspace to the next behind a Critic Gate, then a deterministic Score Tool Step that sanitizes MI-MLM's approved payload and runs the fixed scoring script directly (no model in the call, SHA-256 guarded -- the notebook's score_payload). The pipeline's methodology (cleaning, feature engineering, selection, hyperparameter planning, scoring) and the critics' review criteria are dataset-agnostic; everything dataset-specific lives in each worker's own task_brief block. Three facts are stated there rather than left for an agent to discover cold: (1) the dataset carries an explicit ID column (role=id, 1..1700, all-unique) that must be dropped as a non-feature record identifier; (2) every feature is RAW -- a mix of integer/real/binary/ordinal/categorical types with real missingness -- so DC's and FTE's inspect-first instructions do real work here; (3) the column names are short Russian-derived codes (nr11, np01, zab_leg_01), not descriptive English, so every column's meaning must be resolved via get_data_dictionary rather than guessed from its name. expected_positive_rate (0.232) is 394 positive of 1700. See README.md in this directory for how to import and run this.",
"graph": {
"nodes": [
{
@@ -89,7 +89,7 @@
"name": "dc-critic",
"goal": "Review MI-DC's cleaning decisions for this stage only.",
"description": "Shared Critic reviewing the DC stage -- approves or requests revision with feedback and a partial/full rejection scope.",
- "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the DC (data cleaning) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no task_brief and no dynamically-injected checklist -- so this stage's own checklist is baked in below instead of arriving with each run:\n\n- no leakage: every fit (domain rules, IQR bounds, imputation statistics) is computed on the training split only; the test split is never touched\n- domain/type validation is run on ALL columns (continuous included), and values violating the column type or domain (wrong type, out-of-set category, impossible sign/range) are coerced to NaN with the rule stated\n- a missingness threshold is set WITH a rationale and gates RAW missingness only; columns above it are dropped citing the measured missingness\n- outlier detection (IQR) is run on continuous columns only and is treated as a review flag, not an automatic delete: physiologically plausible but statistically extreme values are kept; deletions are reserved for implausible values and each deletion states why\n- imputation is appropriate and train-only: mode for binary/categorical, mean or median for continuous with the mean-vs-median choice justified by skew; ALL surviving NaNs are imputed, including those created by domain fixes and outlier removal\n- the cleaned matrices are committed to the workspace as v1_dc by apply_coercions and drop_and_impute (the cell workspace identity is ambient; persistence is automatic, not a save_artifact step); the committed output must have zero remaining missing values and identical train/test columns\n- cleaning is proportionate: the agent did not over-clean (deleting real signal) or under-clean (leaving integrity errors); judged on the stated reasons, not on a count\n- text-typed numeric columns handled: any column that is semantically continuous but loaded as text/object because a MINORITY of cells hold non-numeric tokens (censored \"x\" values, unit/flag suffixes, or free-text lab comments) is NOT left as text or treated as categorical — those tokens are coerced to NaN so the column parses as numeric, with the rule and coerced count stated\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: IQR deletion of physiologically plausible extremes (real clinical signal\n thrown away); domain/type validation skipped on continuous columns; the\n missingness threshold re-triggering on outlier-induced NaNs instead of raw\n missingness; NaNs left unimputed after domain fixes or outlier removal; a\n semantically-continuous column left as text or mis-typed as categorical because\n a few non-numeric tokens (censored \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
+ "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the DC (data cleaning) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no dynamically-injected task_brief or checklist -- so the task_brief and this stage's own checklist are baked in below instead of arriving with each run.\n\ntask_brief: {\"domain\": \"acute myocardial infarction admissions, UCI Myocardial Infarction Complications (ZSN) dataset\", \"prediction_task\": \"binary\", \"target_column\": \"mi_ZSN\", \"positive_class\": 1, \"expected_positive_rate\": 0.232, \"selection_metric\": \"average_precision\", \"num_classes\": 2, \"group_column\": null, \"notes\": \"Predict development of chronic heart failure (ZSN) as a post-MI complication. Dataset: 1700 admissions x 113 raw columns (1 ID + 111 features + target mi_ZSN); target is binary (0=no ZSN, 1=ZSN), observed positive rate 23.2%. Column `ID` (role=id, values 1..1700, all unique) is a non-feature record identifier -- exclude it from the training matrix entirely; it must never be imputed, engineered, selected, or scored as a feature. Unlike some ASAREE datasets, features here are RAW, not pre-encoded: types are a mix of integer, real, binary, ordinal, and categorical, and several are known to have real missingness (this is the original UCI dataset, not a cleaned derivative) -- confirm actual missingness yourself via inspect_columns rather than assuming any column is complete. Column names are NOT descriptive English words; they are short Russian-derived codes (e.g. `nr11`, `np01`, `zab_leg_01`, `S_AD_KBRIG`) -- do not infer meaning from the name alone, always confirm via get_data_dictionary, which carries a description and, for categorical/ordinal columns, its code mapping. FEATURE FAMILIES: demographics (AGE, SEX); cardiac history (INF_ANAM prior-MI count, STENOK_AN/FK_STENOK angina, GB/SIM_GIPERT/DLIT_AG hypertension, ZSN_A prior heart failure, IBS_POST/IBS_NASL coronary disease, nr*/np* prior arrhythmia and conduction-block history); comorbidities (endocr_* diabetes/obesity/thyroid, zab_leg_* chronic pulmonary disease); admission vitals and ICU-admission complications (S_AD_KBRIG/D_AD_KBRIG/S_AD_ORIT/D_AD_ORIT blood pressure, O_L_POST/K_SH_POST/MP_TP_POST/SVT_POST/GT_POST/FIB_G_POST); infarct localization (ant_im/lat_im/inf_im/post_im/IM_PG_P); admission ECG rhythm and conduction findings (ritm_ecg_p_*, n_r_ecg_p_*, n_p_ecg_p_*); labs (K_BLOOD, Na_BLOOD, ALT_BLOOD, AST_BLOOD, KFK_BLOOD, L_BLOOD, ROE) with their derived abnormal-value flags (GIPO_K, GIPER_Na); and treatment given before/during ICU admission (fibr_ter_* fibrinolytics, NA_KB/NA_R_*_n opioids, NOT_NA_*_n/NOT_NA_KB NSAIDs, LID_KB/LID_S_n lidocaine, B_BLOK_S_n beta-blockers, ANT_CA_S_n calcium-channel blockers, GEPAR_S_n heparin, ASP_S_n aspirin, TIKL_S_n/TRENT_S_n antiplatelets, NITR_S nitrates). A per-column data dictionary (description + codes) is available via get_data_dictionary.\"}\n\nChecklist:\n\n- no leakage: every fit (domain rules, IQR bounds, imputation statistics) is computed on the training split only; the test split is never touched\n- domain/type validation is run on ALL columns (continuous included), and values violating the column type or domain (wrong type, out-of-set category, impossible sign/range) are coerced to NaN with the rule stated\n- a missingness threshold is set WITH a rationale and gates RAW missingness only; columns above it are dropped citing the measured missingness\n- outlier detection (IQR) is run on continuous columns only and is treated as a review flag, not an automatic delete: physiologically plausible but statistically extreme values are kept; deletions are reserved for implausible values and each deletion states why\n- imputation is appropriate and train-only: mode for binary/categorical, mean or median for continuous with the mean-vs-median choice justified by skew; ALL surviving NaNs are imputed, including those created by domain fixes and outlier removal\n- the cleaned matrices are committed to the workspace as v1_dc by apply_coercions and drop_and_impute (the cell workspace identity is ambient; persistence is automatic, not a save_artifact step); the committed output must have zero remaining missing values and identical train/test columns\n- cleaning is proportionate: the agent did not over-clean (deleting real signal) or under-clean (leaving integrity errors); judged on the stated reasons, not on a count\n- text-typed numeric columns handled: any column that is semantically continuous but loaded as text/object because a MINORITY of cells hold non-numeric tokens (censored \"x\" values, unit/flag suffixes, or free-text lab comments) is NOT left as text or treated as categorical — those tokens are coerced to NaN so the column parses as numeric, with the rule and coerced count stated\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: IQR deletion of physiologically plausible extremes (real clinical signal\n thrown away); domain/type validation skipped on continuous columns; the\n missingness threshold re-triggering on outlier-induced NaNs instead of raw\n missingness; NaNs left unimputed after domain fixes or outlier removal; a\n semantically-continuous column left as text or mis-typed as categorical because\n a few non-numeric tokens (censored \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
"enabled": true,
"max_revisions": 1
},
@@ -108,7 +108,7 @@
"data": {
"label": "",
"config": {
- "max_iterations": 15,
+ "max_iterations": 12,
"include_scratchpad": true,
"scratchpad_window": 10,
"observation_format": "raw"
@@ -193,7 +193,7 @@
"name": "fte-critic",
"goal": "Review MI-FTE's engineering/encoding decisions for this stage only.",
"description": "Shared Critic reviewing the FTE stage -- approves or requests revision with feedback and a partial/full rejection scope.",
- "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the FTE (feature transformation/engineering) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no task_brief and no dynamically-injected checklist -- so this stage's own checklist is baked in below instead of arriving with each run:\n\n- no leakage: every fit (encoders, and any statistic used by an engineered feature such as a group median or bin edge) is computed on the training split only; the test split is never touched\n- engineered features are emitted as a TYPED, REPLAYABLE recipe (each entry has an op, its inputs, and an explicit deterministic output name), not as one-shot mutations of the training frame\n- every train-derived statistic an engineered feature depends on (bin edges, group median, training prevalence) is FROZEN as an explicit recipe param computed on the training fold, so the same value is applied to test without recomputing (this is the train/test leakage + skew guard)\n- engineered output names are explicit and deterministic so the FS selector and the test partition align on the same column names; group aggregates declare a fallback for group values unseen at fit time\n- encoding appropriateness: each column has an encoding decision (binarize, ordinal with a stated order, one-hot, leave-numeric, or bin) justified from column semantics OR data spread, not a blanket rule\n- every engineered feature carries a one-line rationale naming a specific reason it should help THIS target, not a generic statement\n- engineering is proportionate: the agent engineered as much as is justified and no more; the critic flags both bloat (features with no specific hypothesis) and under-engineering (obvious, well-supported features left out). Judged on rationale quality, not a count\n- interactions (multiplicative or logical) are individually reasoned, not a blanket multiply of all pairs\n- clinical meta-features, if any, are clinically coherent and non-leaky; any composite or index does NOT silently discard component signal (components are retained alongside the composite unless their removal is justified)\n- the engineered + encoded matrix is committed to the workspace as v2_fte, fully numeric and model-ready: build_features (recipe) then fit_preprocessor (encoding_map) are BOTH called, so the encoded matrix is the committed output the FS stage and the runner read (workspace identity is ambient; persistence is automatic, not a save_artifact step)\n- the per-column encoding_map was passed to fit_preprocessor via encoding_map_json (not just described): the map the agent reports equals the map it fit, so test is encoded with the same per-column decisions and handle_unknown protects against unseen test categories\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: engineering emitted as one-shot frame mutations instead of a typed,\n replayable recipe; a train-derived statistic (bin edge, group median,\n prevalence) recomputed at transform time instead of frozen into the recipe;\n non-deterministic engineered names that would desync the FS selector on test; a\n group aggregate with no unseen-group fallback; lossy composites that discard\n component signal without justification; leaky meta-features computed downstream\n of the outcome; generic rationales; bloat or under-engineering; build_features\n or fit_preprocessor did not commit the encoded v2_fte matrix, or the encoding_map\n only described but not passed via encoding_map_json.\n\nDO NOT BE TRIGGER-HAPPY. Enforce a revision ONLY when something is genuinely\nnon-optimal, clinically irrelevant, leaky, or incorrect. Do not fail a stage over\nstylistic preferences, defensible judgment calls, or choices you would merely\nhave made differently. On a true tie, approve. Do not invent problems.\n\nYour ENTIRE response is this verdict -- the FIRST character you output MUST be `{`.\nDo NOT write any preamble, plan, or meta-commentary about the task (e.g. \"this is\na review task...\", \"the reviewer should...\"); render the verdict itself, not a\ndescription of how you would review. Return EXACTLY ONE JSON object (followed only\nby the machine-readable verdict line described in the Rules) and nothing else:\n{\n \"stage\": \"fte\",\n \"approved\": true,\n \"criteria_results\": [\n {\"criterion\": \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
+ "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the FTE (feature transformation/engineering) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no dynamically-injected task_brief or checklist -- so the task_brief and this stage's own checklist are baked in below instead of arriving with each run.\n\ntask_brief: {\"domain\": \"acute myocardial infarction admissions, UCI Myocardial Infarction Complications (ZSN) dataset\", \"prediction_task\": \"binary\", \"target_column\": \"mi_ZSN\", \"positive_class\": 1, \"expected_positive_rate\": 0.232, \"selection_metric\": \"average_precision\", \"num_classes\": 2, \"group_column\": null, \"notes\": \"Predict development of chronic heart failure (ZSN) as a post-MI complication. Dataset: 1700 admissions x 113 raw columns (1 ID + 111 features + target mi_ZSN); target is binary (0=no ZSN, 1=ZSN), observed positive rate 23.2%. Column `ID` (role=id, values 1..1700, all unique) is a non-feature record identifier -- exclude it from the training matrix entirely; it must never be imputed, engineered, selected, or scored as a feature. Unlike some ASAREE datasets, features here are RAW, not pre-encoded: types are a mix of integer, real, binary, ordinal, and categorical, and several are known to have real missingness (this is the original UCI dataset, not a cleaned derivative) -- confirm actual missingness yourself via inspect_columns rather than assuming any column is complete. Column names are NOT descriptive English words; they are short Russian-derived codes (e.g. `nr11`, `np01`, `zab_leg_01`, `S_AD_KBRIG`) -- do not infer meaning from the name alone, always confirm via get_data_dictionary, which carries a description and, for categorical/ordinal columns, its code mapping. FEATURE FAMILIES: demographics (AGE, SEX); cardiac history (INF_ANAM prior-MI count, STENOK_AN/FK_STENOK angina, GB/SIM_GIPERT/DLIT_AG hypertension, ZSN_A prior heart failure, IBS_POST/IBS_NASL coronary disease, nr*/np* prior arrhythmia and conduction-block history); comorbidities (endocr_* diabetes/obesity/thyroid, zab_leg_* chronic pulmonary disease); admission vitals and ICU-admission complications (S_AD_KBRIG/D_AD_KBRIG/S_AD_ORIT/D_AD_ORIT blood pressure, O_L_POST/K_SH_POST/MP_TP_POST/SVT_POST/GT_POST/FIB_G_POST); infarct localization (ant_im/lat_im/inf_im/post_im/IM_PG_P); admission ECG rhythm and conduction findings (ritm_ecg_p_*, n_r_ecg_p_*, n_p_ecg_p_*); labs (K_BLOOD, Na_BLOOD, ALT_BLOOD, AST_BLOOD, KFK_BLOOD, L_BLOOD, ROE) with their derived abnormal-value flags (GIPO_K, GIPER_Na); and treatment given before/during ICU admission (fibr_ter_* fibrinolytics, NA_KB/NA_R_*_n opioids, NOT_NA_*_n/NOT_NA_KB NSAIDs, LID_KB/LID_S_n lidocaine, B_BLOK_S_n beta-blockers, ANT_CA_S_n calcium-channel blockers, GEPAR_S_n heparin, ASP_S_n aspirin, TIKL_S_n/TRENT_S_n antiplatelets, NITR_S nitrates). A per-column data dictionary (description + codes) is available via get_data_dictionary.\"}\n\nChecklist:\n\n- no leakage: every fit (encoders, and any statistic used by an engineered feature such as a group median or bin edge) is computed on the training split only; the test split is never touched\n- engineered features are emitted as a TYPED, REPLAYABLE recipe (each entry has an op, its inputs, and an explicit deterministic output name), not as one-shot mutations of the training frame\n- every train-derived statistic an engineered feature depends on (bin edges, group median, training prevalence) is FROZEN as an explicit recipe param computed on the training fold, so the same value is applied to test without recomputing (this is the train/test leakage + skew guard)\n- engineered output names are explicit and deterministic so the FS selector and the test partition align on the same column names; group aggregates declare a fallback for group values unseen at fit time\n- encoding appropriateness: each column has an encoding decision (binarize, ordinal with a stated order, one-hot, leave-numeric, or bin) justified from column semantics OR data spread, not a blanket rule\n- every engineered feature carries a one-line rationale naming a specific reason it should help THIS target, not a generic statement\n- engineering is proportionate: the agent engineered as much as is justified and no more; the critic flags both bloat (features with no specific hypothesis) and under-engineering (obvious, well-supported features left out). Judged on rationale quality, not a count\n- interactions (multiplicative or logical) are individually reasoned, not a blanket multiply of all pairs\n- clinical meta-features, if any, are clinically coherent and non-leaky; any composite or index does NOT silently discard component signal (components are retained alongside the composite unless their removal is justified)\n- the engineered + encoded matrix is committed to the workspace as v2_fte, fully numeric and model-ready: build_features (recipe) then fit_preprocessor (encoding_map) are BOTH called, so the encoded matrix is the committed output the FS stage and the runner read (workspace identity is ambient; persistence is automatic, not a save_artifact step)\n- the per-column encoding_map was passed to fit_preprocessor via encoding_map_json (not just described): the map the agent reports equals the map it fit, so test is encoded with the same per-column decisions and handle_unknown protects against unseen test categories\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: engineering emitted as one-shot frame mutations instead of a typed,\n replayable recipe; a train-derived statistic (bin edge, group median,\n prevalence) recomputed at transform time instead of frozen into the recipe;\n non-deterministic engineered names that would desync the FS selector on test; a\n group aggregate with no unseen-group fallback; lossy composites that discard\n component signal without justification; leaky meta-features computed downstream\n of the outcome; generic rationales; bloat or under-engineering; build_features\n or fit_preprocessor did not commit the encoded v2_fte matrix, or the encoding_map\n only described but not passed via encoding_map_json.\n\nDO NOT BE TRIGGER-HAPPY. Enforce a revision ONLY when something is genuinely\nnon-optimal, clinically irrelevant, leaky, or incorrect. Do not fail a stage over\nstylistic preferences, defensible judgment calls, or choices you would merely\nhave made differently. On a true tie, approve. Do not invent problems.\n\nYour ENTIRE response is this verdict -- the FIRST character you output MUST be `{`.\nDo NOT write any preamble, plan, or meta-commentary about the task (e.g. \"this is\na review task...\", \"the reviewer should...\"); render the verdict itself, not a\ndescription of how you would review. Return EXACTLY ONE JSON object (followed only\nby the machine-readable verdict line described in the Rules) and nothing else:\n{\n \"stage\": \"fte\",\n \"approved\": true,\n \"criteria_results\": [\n {\"criterion\": \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
"enabled": true,
"max_revisions": 1
},
@@ -212,7 +212,7 @@
"data": {
"label": "",
"config": {
- "max_iterations": 15,
+ "max_iterations": 12,
"include_scratchpad": true,
"scratchpad_window": 10,
"observation_format": "raw"
@@ -275,7 +275,7 @@
"prompt": "",
"goal": "Prune the feature space; commit v3_fs. After your final filter/balance-check tool call succeeds, call accept_stage(stage=\"fs\") yourself to promote your work to workspace HEAD before finishing -- this run's Critic Gate reviews your report, not this promotion, so call it on every attempt (a later revision's call simply supersedes an earlier one).\n\nRandom seed: 42\n\ntask_brief: {\"domain\": \"acute myocardial infarction admissions, UCI Myocardial Infarction Complications (ZSN) dataset\", \"prediction_task\": \"binary\", \"target_column\": \"mi_ZSN\", \"positive_class\": 1, \"expected_positive_rate\": 0.232, \"selection_metric\": \"average_precision\", \"num_classes\": 2, \"group_column\": null, \"notes\": \"Predict development of chronic heart failure (ZSN) as a post-MI complication. Dataset: 1700 admissions x 113 raw columns (1 ID + 111 features + target mi_ZSN); target is binary (0=no ZSN, 1=ZSN), observed positive rate 23.2%. Column `ID` (role=id, values 1..1700, all unique) is a non-feature record identifier -- exclude it from the training matrix entirely; it must never be imputed, engineered, selected, or scored as a feature. Unlike some ASAREE datasets, features here are RAW, not pre-encoded: types are a mix of integer, real, binary, ordinal, and categorical, and several are known to have real missingness (this is the original UCI dataset, not a cleaned derivative) -- confirm actual missingness yourself via inspect_columns rather than assuming any column is complete. Column names are NOT descriptive English words; they are short Russian-derived codes (e.g. `nr11`, `np01`, `zab_leg_01`, `S_AD_KBRIG`) -- do not infer meaning from the name alone, always confirm via get_data_dictionary, which carries a description and, for categorical/ordinal columns, its code mapping. FEATURE FAMILIES: demographics (AGE, SEX); cardiac history (INF_ANAM prior-MI count, STENOK_AN/FK_STENOK angina, GB/SIM_GIPERT/DLIT_AG hypertension, ZSN_A prior heart failure, IBS_POST/IBS_NASL coronary disease, nr*/np* prior arrhythmia and conduction-block history); comorbidities (endocr_* diabetes/obesity/thyroid, zab_leg_* chronic pulmonary disease); admission vitals and ICU-admission complications (S_AD_KBRIG/D_AD_KBRIG/S_AD_ORIT/D_AD_ORIT blood pressure, O_L_POST/K_SH_POST/MP_TP_POST/SVT_POST/GT_POST/FIB_G_POST); infarct localization (ant_im/lat_im/inf_im/post_im/IM_PG_P); admission ECG rhythm and conduction findings (ritm_ecg_p_*, n_r_ecg_p_*, n_p_ecg_p_*); labs (K_BLOOD, Na_BLOOD, ALT_BLOOD, AST_BLOOD, KFK_BLOOD, L_BLOOD, ROE) with their derived abnormal-value flags (GIPO_K, GIPER_Na); and treatment given before/during ICU admission (fibr_ter_* fibrinolytics, NA_KB/NA_R_*_n opioids, NOT_NA_*_n/NOT_NA_KB NSAIDs, LID_KB/LID_S_n lidocaine, B_BLOK_S_n beta-blockers, ANT_CA_S_n calcium-channel blockers, GEPAR_S_n heparin, ASP_S_n aspirin, TIKL_S_n/TRENT_S_n antiplatelets, NITR_S nitrates). A per-column data dictionary (description + codes) is available via get_data_dictionary.\"}",
"description": "Filters by variance/correlation/supervised signal and checks class balance on top of v2_fte, commits the result as workspace version v3_fs.",
- "system_prompt": "\nYou are a feature SELECTION agent for biomedical tabular data. You run THIRD, on\nthe engineered + encoded dataset from FTE (already clean, complete, and fully\nnumeric; missingness, engineering, and encoding were handled upstream). Your job\nis to profile the feature space and prune it through unsupervised filters plus one\nsupervised filter, over the FULL space of original survivors and engineered\nmeta-features alike. You do NOT clean, impute, engineer, or model. You do NOT\ntouch the test split.\n\nWORKSPACE MODEL (read this first): the FTE stage already committed the encoded,\nmodel-ready train/test matrix (v2_fte). Opening the workspace loads that encoded\nTRAINING matrix for you — you do NOT re-apply any preprocessor; the input is\nalready the space the runner will score. A selection tool computes on the\ntraining split only and writes this stage's dataset version (v3_fs) = the input\nrestricted to the columns you keep, on BOTH splits; the cell's workspace identity\ntravels with every tool call automatically, so you never pass a workspace id or a\ndataset id. There is no save_artifact step and there are no artifact keys.\n\nRUN CONTEXT\nYou will receive:\n- task_brief (same schema as DC's).\n- The approved FTE recipe report (context; the encoded data is already on disk).\n- The registered dataset name and random seed.\n- Your cell's workspace is ALREADY OPEN, seeded from the wired dataset before your\n first turn -- your run context says so. What that does NOT do is materialize this\n stage's scratch input, which the selection tools read, so you still make one\n argument-free-except-stage open_workspace(stage=\"fs\") call. You never pass a\n workspace id, an experiment id, a cell label or a dataset id to anything: the\n workspace identity travels with every tool call as ambient run context.\n The FINAL selection call commits the selected columns as v3_fs.\n\nReproduce the same random_seed in every tool call. Every drop must cite a\nspecific, measurable criterion.\n\nSTEPS\n1. Call open_workspace(stage=\"fs\") -- that argument and nothing else; which\n workspace and which dataset both arrive as ambient run context.\n It loads the ENCODED, model-ready TRAINING matrix (the FTE output) into your\n scratch input. Do NOT re-apply a preprocessor; selection runs directly on this\n already-encoded space, so the selected names align with what the runner scores.\n2. Profile every column (get_feature_distributions()): dtype, n_unique,\n summary stats. (Data is already complete and numeric; this is selection context.)\n3. Variance filter: variance_filter(threshold=)\n (wraps sklearn VarianceThreshold): set and STATE a threshold; drop low /\n near-zero-variance features. State your scaling assumption, since the threshold\n interacts with scale.\n4. Correlation filter (compute_correlations(method='spearman')): find\n pairs with |rho| above a stated threshold (e.g. 0.9). For each pair remove ONE\n member using an UNSUPERVISED tie-break: keep the lower-missingness member, then\n the higher-variance member; drop whichever is harder to interpret on a true\n tie. Do not use the label here.\n5. Supervised filter: choose the survivors of steps 3-4 and call\n supervised_filter(method=\"f_classif\" | \"mutual_info_classif\",\n k=, features_json=).\n This is the FINAL selection call: it commits the selected\n columns as this stage's output (v3_fs). The committed columns must be a subset\n of the v2_fte columns.\n6. Call check_class_balance() and verify the observed rate matches\n expected_positive_rate (binary) or the class count matches num_classes.\n\n7. Compare selected_features with the output names in the upstream FTE\n engineering_recipe. Report their intersection count as\n n_engineered_features_selected and 100 times that count divided by\n n_features_out as pct_engineered_features_selected.\n\nCONCISENESS: report only what you changed or flagged. Summarize untouched columns\nby COUNT — do NOT enumerate all columns. Keep every rationale to one line. The\ncommitted dataset version carries the full detail; the runner reads it.\n\nOUTPUT - return ONE JSON dossier:\n{\n \"n_features_in\": ..., \"n_features_out\": ...,\n \"n_engineered_features_selected\": ...,\n \"pct_engineered_features_selected\": ...,\n \"variance_threshold\": 0.0,\n \"correlation\": {\"method\": \"spearman\", \"rho_threshold\": 0.9,\n \"pairs_removed\": [[\"kept\", \"dropped\", 0.95], ...]},\n \"supervised_filter\": {\"method\": \"f_classif|mutual_info_classif\", \"k\": 0,\n \"top_scores\": [[\"feature\", score], ...]},\n \"dropped_features\": [{\"feature\": \"...\", \"reason\": \"criterion + measured evidence\"}],\n \"selected_features\": [\"...\"],\n \"observed_class_distribution\": {\"\": 0.0, ...},\n \"class_balance_check\": \"matches task_brief\" | \"diverges: ...\",\n \"notes_for_mlm\": \"caveats, dtype quirks, anything MLM should know.\"\n}\n",
+ "system_prompt": "\nYou are a feature SELECTION agent for biomedical tabular data. You run THIRD, on\nthe engineered + encoded dataset from FTE (already clean, complete, and fully\nnumeric; missingness, engineering, and encoding were handled upstream). Your job\nis to profile the feature space and prune it through unsupervised filters plus one\nsupervised filter, over the FULL space of original survivors and engineered\nmeta-features alike. You do NOT clean, impute, engineer, or model. You do NOT\ntouch the test split.\n\nWORKSPACE MODEL (read this first): the FTE stage already committed the encoded,\nmodel-ready train/test matrix (v2_fte). Opening the workspace loads that encoded\nTRAINING matrix for you — you do NOT re-apply any preprocessor; the input is\nalready the space the runner will score. A selection tool computes on the\ntraining split only and writes this stage's dataset version (v3_fs) = the input\nrestricted to the columns you keep, on BOTH splits; the cell's workspace identity\ntravels with every tool call automatically, so you never pass a workspace id or a\ndataset id. There is no save_artifact step and there are no artifact keys.\n\nRUN CONTEXT\nYou will receive:\n- task_brief (same schema as DC's).\n- The approved FTE recipe report (context; the encoded data is already on disk).\n- The registered dataset name and random seed.\n- Your cell's workspace is ALREADY OPEN, seeded from the wired dataset before your\n first turn -- your run context says so. What that does NOT do is materialize this\n stage's scratch input, which the selection tools read, so you still make one\n argument-free-except-stage open_workspace(stage=\"fs\") call. You never pass a\n workspace id, an experiment id, a cell label or a dataset id to anything: the\n workspace identity travels with every tool call as ambient run context.\n The FINAL selection call commits the selected columns as v3_fs.\n\nReproduce the same random_seed in every tool call. Every drop must cite a\nspecific, measurable criterion.\n\nSTEPS\n1. Call open_workspace(stage=\"fs\") -- that argument and nothing else; which\n workspace and which dataset both arrive as ambient run context.\n It loads the ENCODED, model-ready TRAINING matrix (the FTE output) into your\n scratch input. Do NOT re-apply a preprocessor; selection runs directly on this\n already-encoded space, so the selected names align with what the runner scores.\n2. Profile every column (get_feature_distributions()): dtype, n_unique,\n summary stats. (Data is already complete and numeric; this is selection context.)\n3. Variance filter: variance_filter(threshold=)\n (wraps sklearn VarianceThreshold): set and STATE a threshold; drop low /\n near-zero-variance features. State your scaling assumption, since the threshold\n interacts with scale.\n4. Correlation filter (compute_correlations(method='spearman')): find\n pairs with |rho| above a stated threshold (e.g. 0.9). For each pair remove ONE\n member using an UNSUPERVISED tie-break: keep the lower-missingness member, then\n the higher-variance member; drop whichever is harder to interpret on a true\n tie. Do not use the label here.\n5. Supervised filter: choose the survivors of steps 3-4 and call\n supervised_filter(method=\"f_classif\" | \"mutual_info_classif\",\n k=, features_json=).\n This is the FINAL selection call: it commits the selected\n columns as this stage's output (v3_fs). The committed columns must be a subset\n of the v2_fte columns.\n6. Call check_class_balance() and verify the observed rate matches\n expected_positive_rate (binary) or the class count matches num_classes.\n\nCONCISENESS: report only what you changed or flagged. Summarize untouched columns\nby COUNT — do NOT enumerate all columns. Keep every rationale to one line. The\ncommitted dataset version carries the full detail; the runner reads it.\n\nOUTPUT - return ONE JSON dossier:\n{\n \"n_features_in\": ..., \"n_features_out\": ...,\n \"variance_threshold\": 0.0,\n \"correlation\": {\"method\": \"spearman\", \"rho_threshold\": 0.9,\n \"pairs_removed\": [[\"kept\", \"dropped\", 0.95], ...]},\n \"supervised_filter\": {\"method\": \"f_classif|mutual_info_classif\", \"k\": 0,\n \"top_scores\": [[\"feature\", score], ...]},\n \"dropped_features\": [{\"feature\": \"...\", \"reason\": \"criterion + measured evidence\"}],\n \"selected_features\": [\"...\"],\n \"observed_class_distribution\": {\"\": 0.0, ...},\n \"class_balance_check\": \"matches task_brief\" | \"diverges: ...\",\n \"notes_for_mlm\": \"caveats, dtype quirks, anything MLM should know.\"\n}\n",
"output_contract": null,
"budget_limit_usd": 200.0,
"max_run_duration_seconds": 86400,
@@ -297,7 +297,7 @@
"name": "fs-critic",
"goal": "Review MI-FS's feature-selection decisions for this stage only.",
"description": "Shared Critic reviewing the FS stage -- approves or requests revision with feedback and a partial/full rejection scope.",
- "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the FS (feature selection) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no task_brief and no dynamically-injected checklist -- so this stage's own checklist is baked in below instead of arriving with each run:\n\n- profiling completeness: every retained column is profiled with dtype, n_unique, and summary stats (data arrives clean and already encoded from DC/FTE; missingness and encoding are NOT this stage's job)\n- selection runs on the encoded feature space the workspace already holds (v2_fte), so the selected names align with the matrix the runner scores; no preprocessor is re-applied here (the input is already model-ready)\n- variance filter: a variance threshold (and the method) is stated and low/near-zero-variance features are removed against it\n- correlation filter: highly correlated pairs are found (method + |rho| threshold stated) and one of each pair is removed with a defensible UNSUPERVISED tie-break (e.g. lower missingness then higher variance)\n- supervised filter is CORRECT for the task: f_classif or mutual_info_classif for classification (NOT f_regression, which assumes a continuous target), and is rank-based / conservative (keep top-k) rather than an aggressive p-value cut that could discard features carrying signal only in interaction\n- selection covers the FULL expanded space: original survivors and FTE meta-features are filtered on equal footing; nothing bypasses selection\n- observed class distribution is checked against task_brief (expected_positive_rate for binary, num_classes for multiclass)\n- no leakage: all fits (variance, correlation, supervised filter) are computed on the training split only; the test split is never touched\n- the selected features are committed to the workspace as v3_fs by the final selection call (workspace identity is ambient; persistence is automatic, not a save_artifact step); the committed columns must be a subset of the v2_fte columns\n- selection is proportionate: it did not strip features the downstream tree model could plausibly exploit, nor retain obvious redundancy\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: a cut so aggressive it likely discards interaction-only signal the tree\n could use; a correlation tie-break that leaks the label; meta-features exempted\n from selection; unstated thresholds; the final selection call did not commit\n v3_fs; any fit touching the test split.\n\nDO NOT BE TRIGGER-HAPPY. Enforce a revision ONLY when something is genuinely\nnon-optimal, clinically irrelevant, leaky, or incorrect. Do not fail a stage over\nstylistic preferences, defensible judgment calls, or choices you would merely\nhave made differently. On a true tie, approve. Do not invent problems.\n\nYour ENTIRE response is this verdict -- the FIRST character you output MUST be `{`.\nDo NOT write any preamble, plan, or meta-commentary about the task (e.g. \"this is\na review task...\", \"the reviewer should...\"); render the verdict itself, not a\ndescription of how you would review. Return EXACTLY ONE JSON object (followed only\nby the machine-readable verdict line described in the Rules) and nothing else:\n{\n \"stage\": \"fs\",\n \"approved\": true,\n \"criteria_results\": [\n {\"criterion\": \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
+ "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the FS (feature selection) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no dynamically-injected task_brief or checklist -- so the task_brief and this stage's own checklist are baked in below instead of arriving with each run.\n\ntask_brief: {\"domain\": \"acute myocardial infarction admissions, UCI Myocardial Infarction Complications (ZSN) dataset\", \"prediction_task\": \"binary\", \"target_column\": \"mi_ZSN\", \"positive_class\": 1, \"expected_positive_rate\": 0.232, \"selection_metric\": \"average_precision\", \"num_classes\": 2, \"group_column\": null, \"notes\": \"Predict development of chronic heart failure (ZSN) as a post-MI complication. Dataset: 1700 admissions x 113 raw columns (1 ID + 111 features + target mi_ZSN); target is binary (0=no ZSN, 1=ZSN), observed positive rate 23.2%. Column `ID` (role=id, values 1..1700, all unique) is a non-feature record identifier -- exclude it from the training matrix entirely; it must never be imputed, engineered, selected, or scored as a feature. Unlike some ASAREE datasets, features here are RAW, not pre-encoded: types are a mix of integer, real, binary, ordinal, and categorical, and several are known to have real missingness (this is the original UCI dataset, not a cleaned derivative) -- confirm actual missingness yourself via inspect_columns rather than assuming any column is complete. Column names are NOT descriptive English words; they are short Russian-derived codes (e.g. `nr11`, `np01`, `zab_leg_01`, `S_AD_KBRIG`) -- do not infer meaning from the name alone, always confirm via get_data_dictionary, which carries a description and, for categorical/ordinal columns, its code mapping. FEATURE FAMILIES: demographics (AGE, SEX); cardiac history (INF_ANAM prior-MI count, STENOK_AN/FK_STENOK angina, GB/SIM_GIPERT/DLIT_AG hypertension, ZSN_A prior heart failure, IBS_POST/IBS_NASL coronary disease, nr*/np* prior arrhythmia and conduction-block history); comorbidities (endocr_* diabetes/obesity/thyroid, zab_leg_* chronic pulmonary disease); admission vitals and ICU-admission complications (S_AD_KBRIG/D_AD_KBRIG/S_AD_ORIT/D_AD_ORIT blood pressure, O_L_POST/K_SH_POST/MP_TP_POST/SVT_POST/GT_POST/FIB_G_POST); infarct localization (ant_im/lat_im/inf_im/post_im/IM_PG_P); admission ECG rhythm and conduction findings (ritm_ecg_p_*, n_r_ecg_p_*, n_p_ecg_p_*); labs (K_BLOOD, Na_BLOOD, ALT_BLOOD, AST_BLOOD, KFK_BLOOD, L_BLOOD, ROE) with their derived abnormal-value flags (GIPO_K, GIPER_Na); and treatment given before/during ICU admission (fibr_ter_* fibrinolytics, NA_KB/NA_R_*_n opioids, NOT_NA_*_n/NOT_NA_KB NSAIDs, LID_KB/LID_S_n lidocaine, B_BLOK_S_n beta-blockers, ANT_CA_S_n calcium-channel blockers, GEPAR_S_n heparin, ASP_S_n aspirin, TIKL_S_n/TRENT_S_n antiplatelets, NITR_S nitrates). A per-column data dictionary (description + codes) is available via get_data_dictionary.\"}\n\nChecklist:\n\n- profiling completeness: every retained column is profiled with dtype, n_unique, and summary stats (data arrives clean and already encoded from DC/FTE; missingness and encoding are NOT this stage's job)\n- selection runs on the encoded feature space the workspace already holds (v2_fte), so the selected names align with the matrix the runner scores; no preprocessor is re-applied here (the input is already model-ready)\n- variance filter: a variance threshold (and the method) is stated and low/near-zero-variance features are removed against it\n- correlation filter: highly correlated pairs are found (method + |rho| threshold stated) and one of each pair is removed with a defensible UNSUPERVISED tie-break (e.g. lower missingness then higher variance)\n- supervised filter is CORRECT for the task: f_classif or mutual_info_classif for classification (NOT f_regression, which assumes a continuous target), and is rank-based / conservative (keep top-k) rather than an aggressive p-value cut that could discard features carrying signal only in interaction\n- selection covers the FULL expanded space: original survivors and FTE meta-features are filtered on equal footing; nothing bypasses selection\n- observed class distribution is checked against task_brief (expected_positive_rate for binary, num_classes for multiclass)\n- no leakage: all fits (variance, correlation, supervised filter) are computed on the training split only; the test split is never touched\n- the selected features are committed to the workspace as v3_fs by the final selection call (workspace identity is ambient; persistence is automatic, not a save_artifact step); the committed columns must be a subset of the v2_fte columns\n- selection is proportionate: it did not strip features the downstream tree model could plausibly exploit, nor retain obvious redundancy\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: a cut so aggressive it likely discards interaction-only signal the tree\n could use; a correlation tie-break that leaks the label; meta-features exempted\n from selection; unstated thresholds; the final selection call did not commit\n v3_fs; any fit touching the test split.\n\nDO NOT BE TRIGGER-HAPPY. Enforce a revision ONLY when something is genuinely\nnon-optimal, clinically irrelevant, leaky, or incorrect. Do not fail a stage over\nstylistic preferences, defensible judgment calls, or choices you would merely\nhave made differently. On a true tie, approve. Do not invent problems.\n\nYour ENTIRE response is this verdict -- the FIRST character you output MUST be `{`.\nDo NOT write any preamble, plan, or meta-commentary about the task (e.g. \"this is\na review task...\", \"the reviewer should...\"); render the verdict itself, not a\ndescription of how you would review. Return EXACTLY ONE JSON object (followed only\nby the machine-readable verdict line described in the Rules) and nothing else:\n{\n \"stage\": \"fs\",\n \"approved\": true,\n \"criteria_results\": [\n {\"criterion\": \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
"enabled": true,
"max_revisions": 1
},
@@ -316,7 +316,7 @@
"data": {
"label": "",
"config": {
- "max_iterations": 15,
+ "max_iterations": 12,
"include_scratchpad": true,
"scratchpad_window": 10,
"observation_format": "raw"
@@ -378,7 +378,7 @@
"label": "MI-MLM",
"config": {
"prompt": "",
- "goal": "Emit an XGBoost hyperparameter search payload only -- no tools, no code.\n\nRandom seed: 42\n\ntask_brief: {\"domain\": \"acute myocardial infarction admissions, UCI Myocardial Infarction Complications (ZSN) dataset\", \"prediction_task\": \"binary\", \"target_column\": \"mi_ZSN\", \"positive_class\": 1, \"expected_positive_rate\": 0.232, \"selection_metric\": \"average_precision\", \"num_classes\": 2, \"group_column\": null, \"notes\": \"Predict development of chronic heart failure (ZSN) as a post-MI complication. Dataset: 1700 admissions x 113 raw columns (1 ID + 111 features + target mi_ZSN); target is binary (0=no ZSN, 1=ZSN), observed positive rate 23.2%. Column `ID` (role=id, values 1..1700, all unique) is a non-feature record identifier -- exclude it from the training matrix entirely; it must never be imputed, engineered, selected, or scored as a feature. Unlike some ASAREE datasets, features here are RAW, not pre-encoded: types are a mix of integer, real, binary, ordinal, and categorical, and several are known to have real missingness (this is the original UCI dataset, not a cleaned derivative) -- confirm actual missingness yourself via inspect_columns rather than assuming any column is complete. Column names are NOT descriptive English words; they are short Russian-derived codes (e.g. `nr11`, `np01`, `zab_leg_01`, `S_AD_KBRIG`) -- do not infer meaning from the name alone, always confirm via get_data_dictionary, which carries a description and, for categorical/ordinal columns, its code mapping. FEATURE FAMILIES: demographics (AGE, SEX); cardiac history (INF_ANAM prior-MI count, STENOK_AN/FK_STENOK angina, GB/SIM_GIPERT/DLIT_AG hypertension, ZSN_A prior heart failure, IBS_POST/IBS_NASL coronary disease, nr*/np* prior arrhythmia and conduction-block history); comorbidities (endocr_* diabetes/obesity/thyroid, zab_leg_* chronic pulmonary disease); admission vitals and ICU-admission complications (S_AD_KBRIG/D_AD_KBRIG/S_AD_ORIT/D_AD_ORIT blood pressure, O_L_POST/K_SH_POST/MP_TP_POST/SVT_POST/GT_POST/FIB_G_POST); infarct localization (ant_im/lat_im/inf_im/post_im/IM_PG_P); admission ECG rhythm and conduction findings (ritm_ecg_p_*, n_r_ecg_p_*, n_p_ecg_p_*); labs (K_BLOOD, Na_BLOOD, ALT_BLOOD, AST_BLOOD, KFK_BLOOD, L_BLOOD, ROE) with their derived abnormal-value flags (GIPO_K, GIPER_Na); and treatment given before/during ICU admission (fibr_ter_* fibrinolytics, NA_KB/NA_R_*_n opioids, NOT_NA_*_n/NOT_NA_KB NSAIDs, LID_KB/LID_S_n lidocaine, B_BLOK_S_n beta-blockers, ANT_CA_S_n calcium-channel blockers, GEPAR_S_n heparin, ASP_S_n aspirin, TIKL_S_n/TRENT_S_n antiplatelets, NITR_S nitrates). A per-column data dictionary (description + codes) is available via get_data_dictionary.\"}",
+ "goal": "Emit an XGBoost hyperparameter search payload only -- no tools, no code.\n\nRandom seed: 42\n\ntask_brief: {\"domain\": \"acute myocardial infarction admissions, UCI Myocardial Infarction Complications (ZSN) dataset\", \"prediction_task\": \"binary\", \"target_column\": \"mi_ZSN\", \"positive_class\": 1, \"expected_positive_rate\": 0.232, \"selection_metric\": \"average_precision\", \"num_classes\": 2, \"group_column\": null, \"notes\": \"Predict development of chronic heart failure (ZSN) as a post-MI complication. Dataset: 1700 admissions x 113 raw columns (1 ID + 111 features + target mi_ZSN); target is binary (0=no ZSN, 1=ZSN), observed positive rate 23.2%. Column `ID` (role=id, values 1..1700, all unique) is a non-feature record identifier -- exclude it from the training matrix entirely; it must never be imputed, engineered, selected, or scored as a feature. Unlike some ASAREE datasets, features here are RAW, not pre-encoded: types are a mix of integer, real, binary, ordinal, and categorical, and several are known to have real missingness (this is the original UCI dataset, not a cleaned derivative) -- confirm actual missingness yourself via inspect_columns rather than assuming any column is complete. Column names are NOT descriptive English words; they are short Russian-derived codes (e.g. `nr11`, `np01`, `zab_leg_01`, `S_AD_KBRIG`) -- do not infer meaning from the name alone, always confirm via get_data_dictionary, which carries a description and, for categorical/ordinal columns, its code mapping. FEATURE FAMILIES: demographics (AGE, SEX); cardiac history (INF_ANAM prior-MI count, STENOK_AN/FK_STENOK angina, GB/SIM_GIPERT/DLIT_AG hypertension, ZSN_A prior heart failure, IBS_POST/IBS_NASL coronary disease, nr*/np* prior arrhythmia and conduction-block history); comorbidities (endocr_* diabetes/obesity/thyroid, zab_leg_* chronic pulmonary disease); admission vitals and ICU-admission complications (S_AD_KBRIG/D_AD_KBRIG/S_AD_ORIT/D_AD_ORIT blood pressure, O_L_POST/K_SH_POST/MP_TP_POST/SVT_POST/GT_POST/FIB_G_POST); infarct localization (ant_im/lat_im/inf_im/post_im/IM_PG_P); admission ECG rhythm and conduction findings (ritm_ecg_p_*, n_r_ecg_p_*, n_p_ecg_p_*); labs (K_BLOOD, Na_BLOOD, ALT_BLOOD, AST_BLOOD, KFK_BLOOD, L_BLOOD, ROE) with their derived abnormal-value flags (GIPO_K, GIPER_Na); and treatment given before/during ICU admission (fibr_ter_* fibrinolytics, NA_KB/NA_R_*_n opioids, NOT_NA_*_n/NOT_NA_KB NSAIDs, LID_KB/LID_S_n lidocaine, B_BLOK_S_n beta-blockers, ANT_CA_S_n calcium-channel blockers, GEPAR_S_n heparin, ASP_S_n aspirin, TIKL_S_n/TRENT_S_n antiplatelets, NITR_S nitrates). A per-column data dictionary (description + codes) is available via get_data_dictionary.\"}\n\nPipeline brief (derived from the approved DC/FTE/FS stages; the FS fields arrive below, from the edge out of Critic (FS)):\n\nengineered_features: {{node:agent-fte.engineering_recipe[name]}}\nencodings: {{node:agent-fte.encoding_map[feature, encoding]}}\nnotes_from_dc: {{node:agent-dc.notes_for_fte}}",
"description": "Reads v3_fs's report and proposes a bounded hyperparameter search space plus fixed params for the deterministic downstream scoring script; never executes anything itself.",
"system_prompt": "\nCRITICAL -- READ FIRST:\nYour ENTIRE response is a single JSON object and nothing else. You are NOT writing\na modeling script. The harness already owns the one fixed script; you only choose\nits hyperparameters. If your output contains ANY code -- a ```python block,\n`import`, `def`, `class`, XGBClassifier(...), an Optuna objective, or a\nload/split/fit call -- it is discarded and your stage FAILS. Output only the typed\npayload described under OUTPUT FORMAT.\n\nYou are a HYPERPARAMETER PLANNER for biomedical tabular prediction. You do NOT\nwrite code. The harness owns a single fixed XGBoost modeling script; your only\njob is to choose its hyperparameter search and return it as ONE typed JSON\npayload. A reviewer gates your payload; if rejected you revise it. After approval\nthe fixed script runs with your payload bound as `hp`, on the training data only.\n\nWHAT IS FIXED BY THE HARNESS (you do NOT control these; do not fight them):\n- The model is XGBoost, objective branched on the task (binary:logistic here).\n- Inner tuning is 5-fold stratified cross-validation on the TRAINING data only,\n with a fixed global fold assignment. You never see or affect the held-out test.\n- The tuning objective is PR-AUC (average precision), fixed on clinical grounds\n for this imbalanced minority-class target.\n- scale_pos_weight is computed by the harness as n_neg/n_pos on the fitted rows.\n- Calibration (isotonic), the operating threshold (argmax F1 on a held-out\n training fold), and the Brier score are computed by the harness and are\n DESCRIPTIVE ONLY: isotonic is monotonic, so it does not change PR-AUC or\n ROC-AUC. Do not try to set them.\n- All seeds are fixed by the harness, so the run is deterministic given your\n payload.\n\nWHAT YOU CONTROL - the typed payload:\n- search_space: a list of at least 3 DISTINCT parameters to tune, each an object\n {\"param\": , \"low\": , \"high\": }. Require low < high and a\n range that lies inside the hard bounds below.\n- fixed_params: an object mapping every parameter you are NOT tuning to a single\n fixed value inside its hard bounds. Keys may ONLY be names drawn from the nine\n parameters listed below. NEVER include objective, eval_metric, tree_method,\n n_jobs, random_state/seed, scale_pos_weight, cv, calibration, or threshold --\n those are harness-fixed; any such key is silently DROPPED and the harness\n value stands (you never override them).\n- n_trials: an integer in [1, 100] - the Optuna trial budget.\n- rationale: a short string with one specific reason per non-default choice, tied\n to this roughly, imbalanced, PR-AUC-optimized target.\n\nCOMPLETENESS RULE: account for every one of the nine parameters below EXACTLY\nONCE -- either in search_space (tuned) or in fixed_params (held constant), and\nnever in both. Aim to omit none: an omitted parameter is not failed, but it\nsilently reverts to the fixed script's default and you lose all control of it, so\na complete payload is the only way to fully determine the model. The reviewer\nstill holds you to this even though it is no longer enforced by failure.\n\nTHE NINE XGBOOST PARAMETERS AND THEIR HARD BOUNDS (do not exceed):\n n_estimators integer [100, 1000]\n max_depth integer [3, 10]\n learning_rate float [1e-3, 0.3] (searched on a log scale)\n min_child_weight float [1, 20]\n gamma float [0, 5]\n subsample float [0.5, 1.0]\n colsample_bytree float [0.5, 1.0]\n reg_lambda float [1e-3, 10] (searched on a log scale)\n reg_alpha float [1e-3, 10] (searched on a log scale)\nThe harness applies the integer and log-scale handling; you only give low/high\nfor tuned params or a fixed value within the bounds. n_estimators and max_depth\ntake integer fixed values. The three log-scale params (learning_rate, reg_lambda,\nreg_alpha) must have BOTH low and high strictly positive (>= 1e-3): a low of 0 is\ninvalid on a log scale.\n\nRUN CONTEXT (informs your CHOICES; not bound to the script):\n- task_brief (target, prediction_task, selection_metric, num_classes, notes).\n- A derived pipeline brief: the final selected feature set, engineered features,\n encodings, observed class balance, and DC/FS caveats. Use the feature count and\n class balance to size the search (how deep, how much regularization, how many\n trials).\n\nMake the search purposeful: tune the parameters that matter most for an\nimbalanced tree model (commonly depth, learning_rate, and regularization or\nsubsampling), give them sensible ranges rather than the full hard bound on\neverything, fix the rest at defensible values, and pick n_trials appropriate to\nthe number of tuned dimensions. Do NOT collapse a tuned range to a single point.\n\nENFORCEMENT: a deterministic pre-flight check sanitizes your payload BEFORE it\nruns -- it does NOT fail the stage. Any suggestion that breaks a rule above -- an\nunknown or harness-fixed key (e.g. objective), a search entry missing \"param\" or\nkeyed \"name\", a range outside the hard bounds (including a log-scale low of 0),\nor a param in both blocks -- is DROPPED, and that parameter silently falls back\nto the fixed script's default, so you lose all control of it. n_trials is\nclamped into [1, 100]. A dropped suggestion means your intent is discarded and\nthe reviewer still sees the mistake -- so get every rule right the first time.\n\nOUTPUT FORMAT -- the FIRST character you output MUST be `{` and the LAST `}`.\nEmit one raw JSON object: no code fence, no prose, no preamble or plan. It is\nparsed verbatim as your payload. Example (structure only -- choose your own\nvalues, do NOT copy these):\n\n{\"search_space\": [{\"param\": \"max_depth\", \"low\": 3, \"high\": 8}, {\"param\": \"learning_rate\", \"low\": 0.01, \"high\": 0.3}, {\"param\": \"reg_lambda\", \"low\": 0.1, \"high\": 10.0}], \"fixed_params\": {\"n_estimators\": 500, \"min_child_weight\": 5.0, \"gamma\": 0.0, \"subsample\": 0.9, \"colsample_bytree\": 0.9, \"reg_alpha\": 0.001}, \"n_trials\": 40, \"rationale\": \"...\"}\n\nBEFORE YOU RESPOND, verify ALL of these (anything you get wrong is dropped and that param falls back to its default, so your intent is lost):\n1. Output is a single JSON object -- no code, no fence, no prose.\n2. Exactly four keys: search_space, fixed_params, n_trials, rationale.\n3. Each of the nine params appears EXACTLY once (search_space or fixed_params,\n never both, never omitted).\n4. Every low/high and every fixed value is INSIDE its hard bound -- re-check each\n against the table above (e.g. max_depth low >= 3 and high <= 10).\n5. n_trials is an integer in [1, 100].\n6. Every search_space entry is keyed \"param\" (never \"name\"), with numeric low\n and high; the log-scale params (learning_rate, reg_lambda, reg_alpha) keep\n low >= 1e-3.\n",
"output_contract": null,
@@ -402,7 +402,7 @@
"name": "mlm-critic",
"goal": "Review MI-MLM's proposed hyperparameter payload for this stage only.",
"description": "Shared Critic reviewing the MLM stage -- approves or requests revision with feedback and a partial/full rejection scope.",
- "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the MLM (hyperparameter planning) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no task_brief and no dynamically-injected checklist -- so this stage's own checklist is baked in below instead of arriving with each run:\n\n- the payload is ONE JSON object with exactly the keys search_space, fixed_params, n_trials, and rationale; it is not code and does not attempt to set any harness-fixed policy (CV folds, seeds, objective metric, calibration, threshold, or scale_pos_weight)\n- schema validity: search_space tunes at least 3 DISTINCT params drawn from the nine allowed XGBoost params, each with low < high and [low, high] inside that param's hard bounds; fixed_params values are inside their hard bounds; every one of the nine params appears exactly once across search_space and fixed_params (none unspecified, none in both); n_trials is an integer in [1, 100]\n- the choices are defensible for class-imbalanced target optimized on PR-AUC: the search is not degenerate (ranges not collapsed to a point, not the full hard bound on everything), depth and regularization are not pinned to values that force gross over- or under-fitting, and n_trials is adequate for the number of tuned dimensions\n- every non-default choice carries a specific one-line rationale tied to THIS imbalanced PR-AUC task, not a generic statement\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: the output is not a single valid JSON payload; search_space tunes fewer\n than 3 params or names a param outside the nine allowed; a range falls\n outside its hard bounds or has low >= high; a param is unspecified or appears\n in both search_space and fixed_params; n_trials outside [1, 100]; the agent\n tries to set harness-fixed policy (CV folds, seeds, objective metric,\n calibration, threshold, scale_pos_weight); a degenerate search (point ranges\n or full-bound-on-all) or rationales generic rather than tied to the\n imbalanced PR-AUC target.\n\nDO NOT BE TRIGGER-HAPPY. Enforce a revision ONLY when something is genuinely\nnon-optimal, clinically irrelevant, leaky, or incorrect. Do not fail a stage over\nstylistic preferences, defensible judgment calls, or choices you would merely\nhave made differently. On a true tie, approve. Do not invent problems.\n\nYour ENTIRE response is this verdict -- the FIRST character you output MUST be `{`.\nDo NOT write any preamble, plan, or meta-commentary about the task (e.g. \"this is\na review task...\", \"the reviewer should...\"); render the verdict itself, not a\ndescription of how you would review. Return EXACTLY ONE JSON object (followed only\nby the machine-readable verdict line described in the Rules) and nothing else:\n{\n \"stage\": \"mlm\",\n \"approved\": true,\n \"criteria_results\": [\n {\"criterion\": \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
+ "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the MLM (hyperparameter planning) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no dynamically-injected task_brief or checklist -- so the task_brief and this stage's own checklist are baked in below instead of arriving with each run.\n\ntask_brief: {\"domain\": \"acute myocardial infarction admissions, UCI Myocardial Infarction Complications (ZSN) dataset\", \"prediction_task\": \"binary\", \"target_column\": \"mi_ZSN\", \"positive_class\": 1, \"expected_positive_rate\": 0.232, \"selection_metric\": \"average_precision\", \"num_classes\": 2, \"group_column\": null, \"notes\": \"Predict development of chronic heart failure (ZSN) as a post-MI complication. Dataset: 1700 admissions x 113 raw columns (1 ID + 111 features + target mi_ZSN); target is binary (0=no ZSN, 1=ZSN), observed positive rate 23.2%. Column `ID` (role=id, values 1..1700, all unique) is a non-feature record identifier -- exclude it from the training matrix entirely; it must never be imputed, engineered, selected, or scored as a feature. Unlike some ASAREE datasets, features here are RAW, not pre-encoded: types are a mix of integer, real, binary, ordinal, and categorical, and several are known to have real missingness (this is the original UCI dataset, not a cleaned derivative) -- confirm actual missingness yourself via inspect_columns rather than assuming any column is complete. Column names are NOT descriptive English words; they are short Russian-derived codes (e.g. `nr11`, `np01`, `zab_leg_01`, `S_AD_KBRIG`) -- do not infer meaning from the name alone, always confirm via get_data_dictionary, which carries a description and, for categorical/ordinal columns, its code mapping. FEATURE FAMILIES: demographics (AGE, SEX); cardiac history (INF_ANAM prior-MI count, STENOK_AN/FK_STENOK angina, GB/SIM_GIPERT/DLIT_AG hypertension, ZSN_A prior heart failure, IBS_POST/IBS_NASL coronary disease, nr*/np* prior arrhythmia and conduction-block history); comorbidities (endocr_* diabetes/obesity/thyroid, zab_leg_* chronic pulmonary disease); admission vitals and ICU-admission complications (S_AD_KBRIG/D_AD_KBRIG/S_AD_ORIT/D_AD_ORIT blood pressure, O_L_POST/K_SH_POST/MP_TP_POST/SVT_POST/GT_POST/FIB_G_POST); infarct localization (ant_im/lat_im/inf_im/post_im/IM_PG_P); admission ECG rhythm and conduction findings (ritm_ecg_p_*, n_r_ecg_p_*, n_p_ecg_p_*); labs (K_BLOOD, Na_BLOOD, ALT_BLOOD, AST_BLOOD, KFK_BLOOD, L_BLOOD, ROE) with their derived abnormal-value flags (GIPO_K, GIPER_Na); and treatment given before/during ICU admission (fibr_ter_* fibrinolytics, NA_KB/NA_R_*_n opioids, NOT_NA_*_n/NOT_NA_KB NSAIDs, LID_KB/LID_S_n lidocaine, B_BLOK_S_n beta-blockers, ANT_CA_S_n calcium-channel blockers, GEPAR_S_n heparin, ASP_S_n aspirin, TIKL_S_n/TRENT_S_n antiplatelets, NITR_S nitrates). A per-column data dictionary (description + codes) is available via get_data_dictionary.\"}\n\nChecklist:\n\n- the payload is ONE JSON object with exactly the keys search_space, fixed_params, n_trials, and rationale; it is not code and does not attempt to set any harness-fixed policy (CV folds, seeds, objective metric, calibration, threshold, or scale_pos_weight)\n- schema validity: search_space tunes at least 3 DISTINCT params drawn from the nine allowed XGBoost params, each with low < high and [low, high] inside that param's hard bounds; fixed_params values are inside their hard bounds; every one of the nine params appears exactly once across search_space and fixed_params (none unspecified, none in both); n_trials is an integer in [1, 100]\n- the choices are defensible for class-imbalanced target optimized on PR-AUC: the search is not degenerate (ranges not collapsed to a point, not the full hard bound on everything), depth and regularization are not pinned to values that force gross over- or under-fitting, and n_trials is adequate for the number of tuned dimensions\n- every non-default choice carries a specific one-line rationale tied to THIS imbalanced PR-AUC task, not a generic statement\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: the output is not a single valid JSON payload; search_space tunes fewer\n than 3 params or names a param outside the nine allowed; a range falls\n outside its hard bounds or has low >= high; a param is unspecified or appears\n in both search_space and fixed_params; n_trials outside [1, 100]; the agent\n tries to set harness-fixed policy (CV folds, seeds, objective metric,\n calibration, threshold, scale_pos_weight); a degenerate search (point ranges\n or full-bound-on-all) or rationales generic rather than tied to the\n imbalanced PR-AUC target.\n\nDO NOT BE TRIGGER-HAPPY. Enforce a revision ONLY when something is genuinely\nnon-optimal, clinically irrelevant, leaky, or incorrect. Do not fail a stage over\nstylistic preferences, defensible judgment calls, or choices you would merely\nhave made differently. On a true tie, approve. Do not invent problems.\n\nYour ENTIRE response is this verdict -- the FIRST character you output MUST be `{`.\nDo NOT write any preamble, plan, or meta-commentary about the task (e.g. \"this is\na review task...\", \"the reviewer should...\"); render the verdict itself, not a\ndescription of how you would review. Return EXACTLY ONE JSON object (followed only\nby the machine-readable verdict line described in the Rules) and nothing else:\n{\n \"stage\": \"mlm\",\n \"approved\": true,\n \"criteria_results\": [\n {\"criterion\": \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
"enabled": true,
"max_revisions": 1
},
@@ -427,51 +427,11 @@
"factor_bindings": {}
}
},
- {
- "id": "agent-score",
- "type": "agent",
- "position": {
- "x": 2240,
- "y": 0
- },
- "data": {
- "label": "MI-Score",
- "config": {
- "prompt": "",
- "goal": "Score MI-MLM's approved hyperparameter payload against the held-out test split. Call run_model_script with NO `code` argument -- the wired Script node reaches the tool as a path in ambient run context and executes byte-for-byte as written; retyping it is the one thing that could corrupt it. Pass only payload_json, set to the approved payload (as compact JSON) from the upstream context -- do not alter it. Report the returned test_metrics and the code_sha256/payload_sha256 guards verbatim; never compute or estimate a metric yourself.",
- "description": "Runs the harness-fixed XGBoost+Optuna scoring script against MI-MLM's approved payload via run_model_script (the script arrives as an ambient path, never pasted into the prompt) and reports the held-out test metrics. Unlike the real notebook (a direct, non-agent call with SHA guards), this is an ASAREE agent so the canvas can demonstrate the Script node end-to-end; the same SHA guards are still enforced server-side and reported back verbatim.",
- "system_prompt": "",
- "output_contract": null,
- "budget_limit_usd": 200.0,
- "max_run_duration_seconds": 86400,
- "require_output_parser": true
- },
- "factor_bindings": {}
- }
- },
- {
- "id": "pattern-score",
- "type": "pattern_reason_act",
- "position": {
- "x": 2248,
- "y": -220
- },
- "data": {
- "label": "",
- "config": {
- "max_iterations": 15,
- "include_scratchpad": true,
- "scratchpad_window": 10,
- "observation_format": "raw"
- },
- "factor_bindings": {}
- }
- },
{
"id": "tool-model-score",
"type": "mcp_tool",
"position": {
- "x": 2314,
+ "x": 2354,
"y": 240
},
"data": {
@@ -491,7 +451,7 @@
"id": "script-model",
"type": "script",
"position": {
- "x": 2494,
+ "x": 2534,
"y": 240
},
"data": {
@@ -499,7 +459,7 @@
"config": {
"name": "fixed_model_script",
"language": "python",
- "code": "CV_FOLDS = 5\nCV_FOLD_SEED = 20260701\nOPTUNA_SAMPLER_SEED = 20260702\nXGB_SEED = 20260703\nVAL_SPLIT_SEED = 20260704\nVAL_FRACTION = 0.15\n\n# ==================================================================\n# ASAREE fixed modeling script (harness-owned; the MLM agent does NOT\n# write this). The agent supplies only the typed payload, bound as hp.\n# Everything here is deterministic given hp and the seeds baked above,\n# so replicate variance reflects LLM stochasticity in the payload only.\n#\n# Pre-bound by run_model_script: hp, X_train, y_train, np, pd. The test\n# split is NEVER bound; this script defines predict_proba(X) and\n# chosen_threshold, and the runner scores the held-out 30 itself.\n#\n# Harness-fixed policy (not agent-controllable):\n# inner CV 5-fold StratifiedKFold, fixed global fold assignment\n# objective PR-AUC (average_precision) on the training folds only\n# pos weighting scale_pos_weight = n_neg/n_pos on the fitted rows\n# calibration isotonic on one held-out training val fold\n# threshold argmax-F1 on the val fold; Brier reported on the val fold\n# no early stopping\n# Collaborator note: isotonic is monotonic, so calibration cannot change\n# PR-AUC or ROC-AUC (both rank-based); it only moves Brier and the\n# threshold-dependent secondaries, none of which is a tested outcome. The\n# scored model is fit on train-minus-val, so its PR-AUC/ROC-AUC equal the\n# base model ranking and the descriptive-only claim holds exactly.\n# ==================================================================\nimport optuna\nfrom optuna.samplers import TPESampler\nfrom sklearn.model_selection import StratifiedKFold, train_test_split\nfrom sklearn.metrics import average_precision_score, f1_score, brier_score_loss\nfrom sklearn.isotonic import IsotonicRegression\nfrom xgboost import XGBClassifier\n\noptuna.logging.set_verbosity(optuna.logging.WARNING)\n\n_INT_PARAMS = {\"n_estimators\", \"max_depth\"}\n_LOG_PARAMS = {\"learning_rate\", \"reg_lambda\", \"reg_alpha\"}\n\n_search = {}\nfor _e in hp[\"search_space\"]:\n _search[_e[\"param\"]] = (float(_e[\"low\"]), float(_e[\"high\"]))\n_fixed = dict(hp[\"fixed_params\"])\nfor _p in _INT_PARAMS:\n if _p in _fixed:\n _fixed[_p] = int(round(float(_fixed[_p])))\n_n_trials = int(hp[\"n_trials\"])\n\n_Xtr = X_train.reset_index(drop=True)\n_ytr = np.asarray(y_train).astype(int).ravel()\n\n\ndef _spw(y):\n pos = int((y == 1).sum())\n neg = int((y == 0).sum())\n return (neg / pos) if pos > 0 else 1.0\n\n\ndef _suggest(trial):\n params = dict(_fixed)\n for name, bounds in _search.items():\n lo, hi = bounds\n if name in _INT_PARAMS:\n params[name] = trial.suggest_int(name, int(round(lo)), int(round(hi)))\n elif name in _LOG_PARAMS:\n params[name] = trial.suggest_float(name, lo, hi, log=True)\n else:\n params[name] = trial.suggest_float(name, lo, hi)\n return params\n\n\ndef _make_model(params, spw):\n return XGBClassifier(\n objective=\"binary:logistic\",\n eval_metric=\"logloss\",\n tree_method=\"hist\",\n n_jobs=1,\n random_state=XGB_SEED,\n scale_pos_weight=spw,\n **params,\n )\n\n\n_cv = StratifiedKFold(n_splits=CV_FOLDS, shuffle=True, random_state=CV_FOLD_SEED)\n\n\ndef _objective(trial):\n params = _suggest(trial)\n scores = []\n for tr_idx, va_idx in _cv.split(_Xtr, _ytr):\n Xt = _Xtr.iloc[tr_idx]\n Xv = _Xtr.iloc[va_idx]\n yt = _ytr[tr_idx]\n yv = _ytr[va_idx]\n model = _make_model(params, _spw(yt))\n model.fit(Xt, yt)\n proba = model.predict_proba(Xv)[:, 1]\n scores.append(average_precision_score(yv, proba))\n return float(np.mean(scores))\n\n\n_study = optuna.create_study(direction=\"maximize\", sampler=TPESampler(seed=OPTUNA_SAMPLER_SEED))\n_study.optimize(_objective, n_trials=_n_trials, show_progress_bar=False)\n\n_best = dict(_fixed)\n_best.update(_study.best_params)\nfor _p in _INT_PARAMS:\n if _p in _best:\n _best[_p] = int(round(float(_best[_p])))\n\n_idx = np.arange(len(_Xtr))\n_fit_idx, _val_idx = train_test_split(\n _idx, test_size=VAL_FRACTION, stratify=_ytr, random_state=VAL_SPLIT_SEED\n)\n_Xfit = _Xtr.iloc[_fit_idx]\n_Xval = _Xtr.iloc[_val_idx]\n_yfit = _ytr[_fit_idx]\n_yval = _ytr[_val_idx]\n\n_base = _make_model(_best, _spw(_yfit))\n_base.fit(_Xfit, _yfit)\n\n_val_raw = _base.predict_proba(_Xval)[:, 1]\n_iso = IsotonicRegression(out_of_bounds=\"clip\")\n_iso.fit(_val_raw, _yval)\n\n\ndef predict_proba(X):\n raw = _base.predict_proba(X)[:, 1]\n return _iso.predict(raw)\n\n\n_val_cal = _iso.predict(_val_raw)\n_grid = np.unique(np.concatenate([np.array([0.0, 1.0]), _val_cal]))\n_best_t, _best_f1 = 0.5, -1.0\nfor _t in _grid:\n _f1 = f1_score(_yval, (_val_cal >= _t).astype(int), zero_division=0)\n if _f1 > _best_f1:\n _best_f1, _best_t = _f1, float(_t)\nchosen_threshold = float(_best_t)\n\n_check = predict_proba(_Xtr.head(5))\nassert _check.shape[0] == 5\n\nresult = {\n \"best_hyperparameters\": {k: (int(v) if k in _INT_PARAMS else float(v)) for k, v in _best.items()},\n \"tuned_params\": sorted(_search.keys()),\n \"n_trials\": _n_trials,\n \"optuna_best_value\": float(_study.best_value),\n \"cv_metric\": \"average_precision\",\n \"cv_folds\": CV_FOLDS,\n \"calibration\": \"isotonic\",\n \"calibration_brier_val\": float(brier_score_loss(_yval, _val_cal)),\n \"val_fraction\": VAL_FRACTION,\n \"chosen_threshold\": chosen_threshold,\n \"chosen_threshold_val_f1\": float(_best_f1),\n \"scale_pos_weight_fit\": float(_spw(_yfit)),\n \"n_train_fit\": int(len(_fit_idx)),\n \"n_val\": int(len(_val_idx)),\n}\n"
+ "code": "CV_FOLDS = 5\nCV_FOLD_SEED = 20260701\nOPTUNA_SAMPLER_SEED = 20260702\nXGB_SEED = 20260703\nVAL_SPLIT_SEED = 20260704\nVAL_FRACTION = 0.15\n\n# ==================================================================\n# ASAREE fixed modeling script (harness-owned; the MLM agent does NOT\n# write this). The agent supplies only the typed payload, bound as hp.\n# Everything here is deterministic given hp and the seeds baked above,\n# so replicate variance reflects LLM stochasticity in the payload only.\n#\n# Pre-bound by run_model_script: hp, X_train, y_train, np, pd. The test\n# split is NEVER bound; this script defines predict_proba(X) and\n# chosen_threshold, and the runner scores the held-out 30 itself.\n#\n# Harness-fixed policy (not agent-controllable):\n# inner CV 5-fold StratifiedKFold, fixed global fold assignment\n# objective PR-AUC (average_precision) on the training folds only\n# pos weighting scale_pos_weight = n_neg/n_pos on the fitted rows\n# calibration isotonic on one held-out training val fold\n# threshold argmax-F1 on the val fold; Brier reported on the val fold\n# no early stopping\n# Collaborator note: isotonic is monotonic, so calibration cannot change\n# PR-AUC or ROC-AUC (both rank-based); it only moves Brier and the\n# threshold-dependent secondaries, none of which is a tested outcome. The\n# scored model is fit on train-minus-val, so its PR-AUC/ROC-AUC equal the\n# base model ranking and the descriptive-only claim holds exactly.\n# ==================================================================\nimport optuna\nfrom optuna.samplers import TPESampler\nfrom sklearn.model_selection import StratifiedKFold, train_test_split\nfrom sklearn.metrics import average_precision_score, f1_score, brier_score_loss\nfrom sklearn.isotonic import IsotonicRegression\nfrom xgboost import XGBClassifier\n\noptuna.logging.set_verbosity(optuna.logging.WARNING)\n\n_INT_PARAMS = {\"n_estimators\", \"max_depth\"}\n_LOG_PARAMS = {\"learning_rate\", \"reg_lambda\", \"reg_alpha\"}\n\n_search = {}\nfor _e in hp[\"search_space\"]:\n _search[_e[\"param\"]] = (float(_e[\"low\"]), float(_e[\"high\"]))\n_fixed = {\n \"n_estimators\": 100,\n \"max_depth\": 6,\n \"learning_rate\": 0.3,\n \"min_child_weight\": 1.0,\n \"gamma\": 0.0,\n \"subsample\": 1.0,\n \"colsample_bytree\": 1.0,\n \"reg_lambda\": 1.0,\n \"reg_alpha\": 0.0,\n}\n_fixed.update(hp[\"fixed_params\"])\nfor _p in _INT_PARAMS:\n if _p in _fixed:\n _fixed[_p] = int(round(float(_fixed[_p])))\n_n_trials = int(hp[\"n_trials\"])\n\n_Xtr = X_train.reset_index(drop=True)\n_ytr = np.asarray(y_train).astype(int).ravel()\n\n\ndef _spw(y):\n pos = int((y == 1).sum())\n neg = int((y == 0).sum())\n return (neg / pos) if pos > 0 else 1.0\n\n\ndef _suggest(trial):\n params = dict(_fixed)\n for name, bounds in _search.items():\n lo, hi = bounds\n if name in _INT_PARAMS:\n params[name] = trial.suggest_int(name, int(round(lo)), int(round(hi)))\n elif name in _LOG_PARAMS:\n params[name] = trial.suggest_float(name, lo, hi, log=True)\n else:\n params[name] = trial.suggest_float(name, lo, hi)\n return params\n\n\ndef _make_model(params, spw):\n return XGBClassifier(\n objective=\"binary:logistic\",\n eval_metric=\"logloss\",\n tree_method=\"hist\",\n n_jobs=1,\n random_state=XGB_SEED,\n scale_pos_weight=spw,\n **params,\n )\n\n\n_cv = StratifiedKFold(n_splits=CV_FOLDS, shuffle=True, random_state=CV_FOLD_SEED)\n\n\ndef _objective(trial):\n params = _suggest(trial)\n scores = []\n for tr_idx, va_idx in _cv.split(_Xtr, _ytr):\n Xt = _Xtr.iloc[tr_idx]\n Xv = _Xtr.iloc[va_idx]\n yt = _ytr[tr_idx]\n yv = _ytr[va_idx]\n model = _make_model(params, _spw(yt))\n model.fit(Xt, yt)\n proba = model.predict_proba(Xv)[:, 1]\n scores.append(average_precision_score(yv, proba))\n return float(np.mean(scores))\n\n\n_study = optuna.create_study(direction=\"maximize\", sampler=TPESampler(seed=OPTUNA_SAMPLER_SEED))\n_study.optimize(_objective, n_trials=_n_trials, show_progress_bar=False)\n\n_best = dict(_fixed)\n_best.update(_study.best_params)\nfor _p in _INT_PARAMS:\n if _p in _best:\n _best[_p] = int(round(float(_best[_p])))\n\n_idx = np.arange(len(_Xtr))\n_fit_idx, _val_idx = train_test_split(\n _idx, test_size=VAL_FRACTION, stratify=_ytr, random_state=VAL_SPLIT_SEED\n)\n_Xfit = _Xtr.iloc[_fit_idx]\n_Xval = _Xtr.iloc[_val_idx]\n_yfit = _ytr[_fit_idx]\n_yval = _ytr[_val_idx]\n\n_base = _make_model(_best, _spw(_yfit))\n_base.fit(_Xfit, _yfit)\n\n_val_raw = _base.predict_proba(_Xval)[:, 1]\n_iso = IsotonicRegression(out_of_bounds=\"clip\")\n_iso.fit(_val_raw, _yval)\n\n\ndef predict_proba(X):\n raw = _base.predict_proba(X)[:, 1]\n return _iso.predict(raw)\n\n\n_val_cal = _iso.predict(_val_raw)\n_grid = np.unique(np.concatenate([np.array([0.0, 1.0]), _val_cal]))\n_best_t, _best_f1 = 0.5, -1.0\nfor _t in _grid:\n _f1 = f1_score(_yval, (_val_cal >= _t).astype(int), zero_division=0)\n if _f1 > _best_f1:\n _best_f1, _best_t = _f1, float(_t)\nchosen_threshold = float(_best_t)\n\n_check = predict_proba(_Xtr.head(5))\nassert _check.shape[0] == 5\n\nresult = {\n \"best_hyperparameters\": {k: (int(v) if k in _INT_PARAMS else float(v)) for k, v in _best.items()},\n \"tuned_params\": sorted(_search.keys()),\n \"n_trials\": _n_trials,\n \"optuna_best_value\": float(_study.best_value),\n \"cv_metric\": \"average_precision\",\n \"cv_folds\": CV_FOLDS,\n \"calibration\": \"isotonic\",\n \"calibration_brier_val\": float(brier_score_loss(_yval, _val_cal)),\n \"val_fraction\": VAL_FRACTION,\n \"chosen_threshold\": chosen_threshold,\n \"chosen_threshold_val_f1\": float(_best_f1),\n \"scale_pos_weight_fit\": float(_spw(_yfit)),\n \"n_train_fit\": int(len(_fit_idx)),\n \"n_val\": int(len(_val_idx)),\n}\n"
},
"factor_bindings": {}
}
@@ -535,7 +495,7 @@
{
"name": "domain_violations",
"type": "array",
- "description": "Domain-rule violations found and corrected"
+ "description": "list of {feature, rule, n_coerced}"
},
{
"name": "missingness_threshold",
@@ -545,17 +505,17 @@
{
"name": "dropped_for_missingness",
"type": "array",
- "description": "Columns dropped for exceeding the missingness threshold"
+ "description": "list of [feature, raw_missing_pct]"
},
{
"name": "outliers",
"type": "array",
- "description": "Outlier handling summary per column"
+ "description": "list of {feature, method, n_flagged, n_deleted, reason}"
},
{
"name": "imputation",
"type": "array",
- "description": "Imputation strategy used per column"
+ "description": "list of {feature_or_group, dtype, strategy, reason}"
},
{
"name": "notes_for_fte",
@@ -585,17 +545,17 @@
{
"name": "engineering_recipe",
"type": "array",
- "description": "Feature-engineering steps applied"
+ "description": "list of {name, op, inputs, params, rationale, components_retained}"
},
{
"name": "encoding_map",
- "type": "object",
- "description": "Categorical encoding scheme per column"
+ "type": "array",
+ "description": "list of {feature, encoding, order_or_bins, reason}"
},
{
"name": "n_features_out",
"type": "integer",
- "description": "Feature count after engineering/encoding"
+ "description": "number of columns in the committed v2_fte matrix"
},
{
"name": "notes_for_fs",
@@ -632,16 +592,6 @@
"type": "integer",
"description": "Feature count after selection"
},
- {
- "name": "n_engineered_features_selected",
- "type": "integer",
- "description": "Selected features created by the upstream engineering recipe"
- },
- {
- "name": "pct_engineered_features_selected",
- "type": "number",
- "description": "Percentage of selected features created by the upstream engineering recipe"
- },
{
"name": "variance_threshold",
"type": "number",
@@ -660,7 +610,7 @@
{
"name": "dropped_features",
"type": "array",
- "description": "Features dropped during selection"
+ "description": "list of {feature, reason}"
},
{
"name": "selected_features",
@@ -705,22 +655,22 @@
{
"name": "search_space",
"type": "array",
- "description": "Hyperparameter search space proposal"
+ "description": "list of {param, low, high}: the XGBoost params to tune and their bounds; at least 3 distinct params"
},
{
"name": "fixed_params",
"type": "object",
- "description": "Fixed (non-searched) XGBoost params"
+ "description": "map of param -> constant value for every param NOT in search_space"
},
{
"name": "n_trials",
"type": "integer",
- "description": "Proposed Optuna trial count"
+ "description": "number of Optuna trials to run, 1-100"
},
{
"name": "rationale",
"type": "string",
- "description": "Rationale for the proposed search space"
+ "description": "one specific reason per non-default search_space/fixed_params choice, tied to the imbalanced PR-AUC task"
}
]
},
@@ -730,43 +680,58 @@
}
},
{
- "id": "output-parser-score",
- "type": "output_parser",
+ "id": "tool-step-score",
+ "type": "tool_step",
"position": {
- "x": 2476,
- "y": 160
+ "x": 2240,
+ "y": 0
},
"data": {
- "label": "MI-Score Output Parser",
+ "label": "Score (run_model_script)",
+ "active": true,
"config": {
- "output_contract": {
- "name": "ScoreReport",
- "fields": [
- {
- "name": "test_metrics",
- "type": "object",
- "description": "Held-out test metrics computed server-side"
- },
- {
- "name": "code_sha256",
- "type": "string",
- "description": "Hash of the executed script, for the verbatim-execution guard"
- },
- {
- "name": "payload_sha256",
- "type": "string",
- "description": "Hash of the scored payload, for the approved-payload guard"
- },
- {
- "name": "model_decisions",
- "type": "object",
- "description": "Train-side decisions the script itself reported (tuning, calibration, threshold)"
- }
- ]
+ "tool_name": "run_model_script",
+ "arguments": {
+ "code": {
+ "source": "script_code"
+ },
+ "payload_json": {
+ "source": "upstream_payload",
+ "format": "json_string"
+ },
+ "workspace_id": {
+ "source": "workspace_id"
+ },
+ "random_seed": {
+ "source": "value",
+ "value": 20260705
+ },
+ "task_type": {
+ "source": "value",
+ "value": "binary"
+ },
+ "positive_label": {
+ "source": "value",
+ "value": "1"
+ },
+ "selection_metric": {
+ "source": "value",
+ "value": "average_precision"
+ },
+ "param_spec_json": {
+ "source": "value",
+ "value": "{\"n_estimators\":{\"type\":\"int\",\"log\":false,\"low\":100,\"high\":1000},\"max_depth\":{\"type\":\"int\",\"log\":false,\"low\":3,\"high\":10},\"learning_rate\":{\"type\":\"float\",\"log\":true,\"low\":0.001,\"high\":0.3},\"min_child_weight\":{\"type\":\"float\",\"log\":false,\"low\":1,\"high\":20},\"gamma\":{\"type\":\"float\",\"log\":false,\"low\":0,\"high\":5},\"subsample\":{\"type\":\"float\",\"log\":false,\"low\":0.5,\"high\":1.0},\"colsample_bytree\":{\"type\":\"float\",\"log\":false,\"low\":0.5,\"high\":1.0},\"reg_lambda\":{\"type\":\"float\",\"log\":true,\"low\":0.001,\"high\":10},\"reg_alpha\":{\"type\":\"float\",\"log\":true,\"low\":0.001,\"high\":10}}"
+ },
+ "default_n_trials": {
+ "source": "value",
+ "value": 30
+ }
},
- "enabled": true
- },
- "factor_bindings": {}
+ "hash_checks": {
+ "code_sha256": "code",
+ "payload_sha256": "payload_json"
+ }
+ }
}
}
],
@@ -799,7 +764,19 @@
{
"id": "e-gatefs-to-mlm",
"source": "gate-fs",
- "target": "agent-mlm"
+ "target": "agent-mlm",
+ "data": {
+ "handoff": {
+ "mode": "selected",
+ "fields": [
+ {"name": "selected_features"},
+ {"name": "n_features_out"},
+ {"name": "observed_class_distribution"},
+ {"name": "class_balance_check"},
+ {"name": "notes_for_mlm"}
+ ]
+ }
+ }
},
{
"id": "e-mlm-to-gatemlm",
@@ -953,39 +930,6 @@
"sourceHandle": "dataset",
"targetHandle": "dataset"
},
- {
- "id": "e-gatemlm-to-score",
- "source": "gate-mlm",
- "target": "agent-score"
- },
- {
- "id": "e-llm-score",
- "source": "llm-shared",
- "target": "agent-score",
- "sourceHandle": "model",
- "targetHandle": "model"
- },
- {
- "id": "e-pattern-score",
- "source": "pattern-score",
- "target": "agent-score",
- "sourceHandle": "architectural_pattern",
- "targetHandle": "architectural_pattern"
- },
- {
- "id": "e-tool-model-score",
- "source": "tool-model-score",
- "target": "agent-score",
- "sourceHandle": "tool",
- "targetHandle": "tool"
- },
- {
- "id": "e-script-model-score",
- "source": "script-model",
- "target": "agent-score",
- "sourceHandle": "tool",
- "targetHandle": "tool"
- },
{
"id": "e-output-parser-dc",
"source": "output-parser-dc",
@@ -1015,11 +959,23 @@
"targetHandle": "output_parser"
},
{
- "id": "e-output-parser-score",
- "source": "output-parser-score",
- "target": "agent-score",
- "sourceHandle": "output_parser",
- "targetHandle": "output_parser"
+ "id": "e-gatemlm-to-score",
+ "source": "gate-mlm",
+ "target": "tool-step-score"
+ },
+ {
+ "id": "e-tool-model-score",
+ "source": "tool-model-score",
+ "target": "tool-step-score",
+ "sourceHandle": "tool",
+ "targetHandle": "tool"
+ },
+ {
+ "id": "e-script-model-score",
+ "source": "script-model",
+ "target": "tool-step-score",
+ "sourceHandle": "tool",
+ "targetHandle": "tool"
}
]
},
@@ -1031,7 +987,11 @@
"claude-sonnet-5",
"claude-opus-5"
],
- "level_type": "string"
+ "level_type": "string",
+ "level_labels": [
+ "claude-sonnet-5",
+ "claude-opus-5"
+ ]
},
{
"name": "Anthropic:Effort",
@@ -1039,7 +999,11 @@
"medium",
"xhigh"
],
- "level_type": "string"
+ "level_type": "string",
+ "level_labels": [
+ "medium",
+ "xhigh"
+ ]
},
{
"name": "Critic enabled",
@@ -1176,16 +1140,6 @@
"aggregation": "sum",
"unit": "reviews"
},
- {
- "id": "model-evaluation",
- "name": "model_evaluation",
- "description": "Complete held-out scoring response from run_model_script.",
- "kind": "custom",
- "valueType": "opaque",
- "primary": false,
- "direction": "neutral",
- "aggregation": "none"
- },
{
"id": "pr-auc",
"name": "pr_auc",
@@ -1500,6 +1454,17 @@
"aggregation": "mean",
"unit": "features"
},
+ {
+ "id": "n-features-created",
+ "name": "n_features_created",
+ "description": "Features created by FTE: n_features_after_fte - n_features_after_dc, computed from the stage payloads.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "neutral",
+ "aggregation": "mean",
+ "unit": "features"
+ },
{
"id": "n-features-after-fs",
"name": "n_features_after_fs",
@@ -1514,7 +1479,7 @@
{
"id": "n-engineered-features-selected",
"name": "n_engineered_features_selected",
- "description": "Engineered features surviving feature selection.",
+ "description": "Selected features that FTE created (not raw columns), computed from FS's selected_features.",
"kind": "custom",
"valueType": "number",
"primary": false,
@@ -1523,15 +1488,15 @@
"unit": "features"
},
{
- "id": "pct-engineered-features-selected",
- "name": "pct_engineered_features_selected",
- "description": "Engineered features as a percentage of selected features.",
+ "id": "frac-created-selected",
+ "name": "frac_created_selected",
+ "description": "Fraction of selected features that FTE created (not raw columns), computed from FS's selected_features.",
"kind": "custom",
"valueType": "number",
"primary": false,
"direction": "neutral",
"aggregation": "mean",
- "unit": "percent"
+ "unit": "fraction"
},
{
"id": "variance-threshold",
@@ -1705,127 +1670,400 @@
"primary": false,
"direction": "neutral",
"aggregation": "none"
- }
- ],
- "coordination_strategy": {
- "slug": "critic_gate"
- }
- },
- "measurement_plan": {
- "metrics": [
+ },
{
- "id": "runtime-cost",
- "name": "Cost",
- "value_type": "number",
+ "id": "n-schema-violations",
+ "name": "n_schema_violations",
+ "description": "MLM payload entries run_model_script dropped against the Tool Step's param_spec_json before fitting (0 = the payload was already clean).",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
"direction": "minimize",
- "aggregation": "sum",
+ "aggregation": "mean",
+ "unit": "entries"
+ },
+ {
+ "id": "threshold-prevalence",
+ "name": "threshold_prevalence",
+ "description": "Decision threshold equal to the observed test-set positive rate.",
+ "kind": "custom",
+ "valueType": "number",
"primary": false,
- "description": "Estimated provider cost for the run.",
- "unit": "USD"
+ "direction": "neutral",
+ "aggregation": "mean"
},
{
- "id": "runtime-duration",
- "name": "Duration",
- "value_type": "number",
- "direction": "minimize",
- "aggregation": "sum",
+ "id": "runtime-critic-invocations",
+ "catalogKey": "critic_invocations",
+ "name": "Critic invocations",
+ "description": "Critic-gate reviews run during the run.",
+ "kind": "runtime",
+ "valueType": "number",
"primary": false,
- "description": "Wall-clock duration of the run.",
- "unit": "seconds"
+ "direction": "neutral",
+ "aggregation": "sum",
+ "unit": "reviews"
},
{
- "id": "runtime-input-tokens",
- "name": "Input tokens",
- "value_type": "number",
+ "id": "runtime-critic-rejections-partial",
+ "catalogKey": "critic_rejections_partial",
+ "name": "Partial critic rejections",
+ "description": "Rejections scoped to a targeted correction.",
+ "kind": "runtime",
+ "valueType": "number",
+ "primary": false,
"direction": "minimize",
"aggregation": "sum",
- "primary": false,
- "description": "Tokens sent to the model during the run.",
- "unit": "tokens"
+ "unit": "reviews"
},
{
- "id": "runtime-output-tokens",
- "name": "Output tokens",
- "value_type": "number",
+ "id": "runtime-critic-rejections-full",
+ "catalogKey": "critic_rejections_full",
+ "name": "Full critic rejections",
+ "description": "Rejections asking the stage to reconsider its approach (includes unscoped ones).",
+ "kind": "runtime",
+ "valueType": "number",
+ "primary": false,
"direction": "minimize",
"aggregation": "sum",
- "primary": false,
- "description": "Tokens generated by the model during the run.",
- "unit": "tokens"
+ "unit": "reviews"
},
{
- "id": "runtime-total-tokens",
- "name": "Total tokens",
- "value_type": "number",
+ "id": "runtime-revision-rounds",
+ "catalogKey": "revision_rounds",
+ "name": "Revision rounds",
+ "description": "Worker reruns triggered by critic rejections.",
+ "kind": "runtime",
+ "valueType": "number",
+ "primary": false,
"direction": "minimize",
"aggregation": "sum",
- "primary": false,
- "description": "Combined input and output tokens for the run.",
- "unit": "tokens"
+ "unit": "rounds"
},
{
- "id": "runtime-tool-calls",
- "name": "Tool calls",
- "value_type": "number",
+ "id": "runtime-capped-agent-runs",
+ "catalogKey": "capped_agent_runs",
+ "name": "Capped agent runs",
+ "description": "Reason+Act runs cut off by their iteration ceiling.",
+ "kind": "runtime",
+ "valueType": "number",
+ "primary": false,
"direction": "minimize",
"aggregation": "sum",
- "primary": false,
- "description": "Recorded tool-call attempts across attributed Agent runs.",
- "unit": "calls"
+ "unit": "runs"
},
{
- "id": "runtime-tool-error-rate",
- "name": "Tool error rate",
- "value_type": "number",
- "direction": "minimize",
- "aggregation": "mean",
+ "id": "runtime-agent-runs",
+ "catalogKey": "agent_runs",
+ "name": "Agent runs",
+ "description": "Every agent and critic run the attempt launched, revisions included.",
+ "kind": "runtime",
+ "valueType": "number",
"primary": false,
- "description": "Failed tool calls divided by all tool-call attempts.",
- "unit": "rate"
+ "direction": "neutral",
+ "aggregation": "sum",
+ "unit": "runs"
},
{
- "id": "runtime-agent-loop-iterations",
- "name": "Agent-loop iterations",
- "value_type": "number",
- "direction": "minimize",
- "aggregation": "sum",
+ "id": "runtime-react-runs",
+ "catalogKey": "react_runs",
+ "name": "Reason+Act runs",
+ "description": "Runs that went through a Reason+Act loop.",
+ "kind": "runtime",
+ "valueType": "number",
"primary": false,
- "description": "Distinct Agent reasoning-loop iterations in the run.",
- "unit": "iterations"
+ "direction": "neutral",
+ "aggregation": "sum",
+ "unit": "runs"
},
{
- "id": "runtime-critic-rejections",
- "name": "Critic rejections",
- "value_type": "number",
+ "id": "runtime-react-turns",
+ "catalogKey": "react_turns",
+ "name": "Reason+Act turns",
+ "description": "Loop iterations reported by Reason+Act runs.",
+ "kind": "runtime",
+ "valueType": "number",
+ "primary": false,
"direction": "minimize",
"aggregation": "sum",
- "primary": false,
- "description": "Rejected critic-gate reviews during the run.",
- "unit": "reviews"
+ "unit": "turns"
},
{
- "id": "runtime-critic-approvals",
- "name": "Critic approvals",
- "value_type": "number",
+ "id": "runtime-react-tool-calls",
+ "catalogKey": "react_tool_calls",
+ "name": "Reason+Act tool calls",
+ "description": "Tool calls counted by Reason+Act loops.",
+ "kind": "runtime",
+ "valueType": "number",
+ "primary": false,
"direction": "neutral",
"aggregation": "sum",
- "primary": false,
- "description": "Critic gates ending in an actual approval.",
- "unit": "reviews"
+ "unit": "calls"
},
{
- "id": "model-evaluation",
- "name": "model_evaluation",
- "value_type": "opaque",
- "direction": "neutral",
- "aggregation": "none",
+ "id": "runtime-prompt-sha256",
+ "catalogKey": "prompt_sha256",
+ "name": "Prompt SHA-256",
+ "description": "Hash of the published stage prompts, critic criteria and output contracts.",
+ "kind": "runtime",
+ "valueType": "opaque",
"primary": false,
- "description": "Complete held-out scoring response from run_model_script."
+ "direction": "neutral",
+ "aggregation": "none"
},
{
- "id": "pr-auc",
- "name": "pr_auc",
- "value_type": "number",
+ "id": "tokens-dc",
+ "name": "tokens_dc",
+ "description": "Input plus output tokens across every run of the DC stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-dc",
+ "name": "n_turns_dc",
+ "description": "Agent-loop turns across every run of the DC stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-fte",
+ "name": "tokens_fte",
+ "description": "Input plus output tokens across every run of the FTE stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-fte",
+ "name": "n_turns_fte",
+ "description": "Agent-loop turns across every run of the FTE stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-fs",
+ "name": "tokens_fs",
+ "description": "Input plus output tokens across every run of the FS stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-fs",
+ "name": "n_turns_fs",
+ "description": "Agent-loop turns across every run of the FS stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-mlm",
+ "name": "tokens_mlm",
+ "description": "Input plus output tokens across every run of the MLM stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-mlm",
+ "name": "n_turns_mlm",
+ "description": "Agent-loop turns across every run of the MLM stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-critic",
+ "name": "tokens_critic",
+ "description": "Input plus output tokens across every run of the four critic gates, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-critic",
+ "name": "n_turns_critic",
+ "description": "Agent-loop turns across every run of the four critic gates, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "turns"
+ },
+ {
+ "id": "n-recipe-ops",
+ "name": "n_recipe_ops",
+ "description": "Entries in the FTE engineering_recipe.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "neutral",
+ "aggregation": "mean",
+ "unit": "ops"
+ },
+ {
+ "id": "recipe-depth",
+ "name": "recipe_depth",
+ "description": "Longest derived-from-derived chain in the FTE recipe (1 by construction when the recipe is non-empty).",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "neutral",
+ "aggregation": "mean",
+ "unit": "levels"
+ },
+ {
+ "id": "recipe-hash",
+ "name": "recipe_hash",
+ "description": "SHA-256 of the FTE recipe structure (name/op/inputs/params), independent of entry order; groups cells that engineered the same features.",
+ "kind": "custom",
+ "valueType": "opaque",
+ "primary": false,
+ "direction": "neutral",
+ "aggregation": "none"
+ }
+ ],
+ "coordination_strategy": {
+ "slug": "critic_gate"
+ }
+ },
+ "measurement_plan": {
+ "metrics": [
+ {
+ "id": "runtime-cost",
+ "name": "Cost",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Estimated provider cost for the run.",
+ "unit": "USD"
+ },
+ {
+ "id": "runtime-duration",
+ "name": "Duration",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Wall-clock duration of the run.",
+ "unit": "seconds"
+ },
+ {
+ "id": "runtime-input-tokens",
+ "name": "Input tokens",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Tokens sent to the model during the run.",
+ "unit": "tokens"
+ },
+ {
+ "id": "runtime-output-tokens",
+ "name": "Output tokens",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Tokens generated by the model during the run.",
+ "unit": "tokens"
+ },
+ {
+ "id": "runtime-total-tokens",
+ "name": "Total tokens",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Combined input and output tokens for the run.",
+ "unit": "tokens"
+ },
+ {
+ "id": "runtime-tool-calls",
+ "name": "Tool calls",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Recorded tool-call attempts across attributed Agent runs.",
+ "unit": "calls"
+ },
+ {
+ "id": "runtime-tool-error-rate",
+ "name": "Tool error rate",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "mean",
+ "primary": false,
+ "description": "Failed tool calls divided by all tool-call attempts.",
+ "unit": "rate"
+ },
+ {
+ "id": "runtime-agent-loop-iterations",
+ "name": "Agent-loop iterations",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Distinct Agent reasoning-loop iterations in the run.",
+ "unit": "iterations"
+ },
+ {
+ "id": "runtime-critic-rejections",
+ "name": "Critic rejections",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Rejected critic-gate reviews during the run.",
+ "unit": "reviews"
+ },
+ {
+ "id": "runtime-critic-approvals",
+ "name": "Critic approvals",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Critic gates ending in an actual approval.",
+ "unit": "reviews"
+ },
+ {
+ "id": "pr-auc",
+ "name": "pr_auc",
+ "value_type": "number",
"direction": "maximize",
"aggregation": "mean",
"primary": true,
@@ -2106,6 +2344,16 @@
"description": "Feature count after engineering and encoding.",
"unit": "features"
},
+ {
+ "id": "n-features-created",
+ "name": "n_features_created",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "mean",
+ "primary": false,
+ "description": "Features created by FTE: n_features_after_fte - n_features_after_dc, computed from the stage payloads.",
+ "unit": "features"
+ },
{
"id": "n-features-after-fs",
"name": "n_features_after_fs",
@@ -2123,18 +2371,18 @@
"direction": "neutral",
"aggregation": "mean",
"primary": false,
- "description": "Engineered features surviving feature selection.",
+ "description": "Selected features that FTE created (not raw columns), computed from FS's selected_features.",
"unit": "features"
},
{
- "id": "pct-engineered-features-selected",
- "name": "pct_engineered_features_selected",
+ "id": "frac-created-selected",
+ "name": "frac_created_selected",
"value_type": "number",
"direction": "neutral",
"aggregation": "mean",
"primary": false,
- "description": "Engineered features as a percentage of selected features.",
- "unit": "percent"
+ "description": "Fraction of selected features that FTE created (not raw columns), computed from FS's selected_features.",
+ "unit": "fraction"
},
{
"id": "variance-threshold",
@@ -2291,6 +2539,253 @@
"aggregation": "none",
"primary": false,
"description": "SHA-256 provenance hash for the scored data."
+ },
+ {
+ "id": "n-schema-violations",
+ "name": "n_schema_violations",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "mean",
+ "primary": false,
+ "description": "MLM payload entries run_model_script dropped against the Tool Step's param_spec_json before fitting (0 = the payload was already clean).",
+ "unit": "entries"
+ },
+ {
+ "id": "threshold-prevalence",
+ "name": "threshold_prevalence",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "mean",
+ "primary": false,
+ "description": "Decision threshold equal to the observed test-set positive rate."
+ },
+ {
+ "id": "runtime-critic-invocations",
+ "name": "Critic invocations",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Critic-gate reviews run during the run.",
+ "unit": "reviews"
+ },
+ {
+ "id": "runtime-critic-rejections-partial",
+ "name": "Partial critic rejections",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Rejections scoped to a targeted correction.",
+ "unit": "reviews"
+ },
+ {
+ "id": "runtime-critic-rejections-full",
+ "name": "Full critic rejections",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Rejections asking the stage to reconsider its approach (includes unscoped ones).",
+ "unit": "reviews"
+ },
+ {
+ "id": "runtime-revision-rounds",
+ "name": "Revision rounds",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Worker reruns triggered by critic rejections.",
+ "unit": "rounds"
+ },
+ {
+ "id": "runtime-capped-agent-runs",
+ "name": "Capped agent runs",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Reason+Act runs cut off by their iteration ceiling.",
+ "unit": "runs"
+ },
+ {
+ "id": "runtime-agent-runs",
+ "name": "Agent runs",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Every agent and critic run the attempt launched, revisions included.",
+ "unit": "runs"
+ },
+ {
+ "id": "runtime-react-runs",
+ "name": "Reason+Act runs",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Runs that went through a Reason+Act loop.",
+ "unit": "runs"
+ },
+ {
+ "id": "runtime-react-turns",
+ "name": "Reason+Act turns",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Loop iterations reported by Reason+Act runs.",
+ "unit": "turns"
+ },
+ {
+ "id": "runtime-react-tool-calls",
+ "name": "Reason+Act tool calls",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Tool calls counted by Reason+Act loops.",
+ "unit": "calls"
+ },
+ {
+ "id": "runtime-prompt-sha256",
+ "name": "Prompt SHA-256",
+ "value_type": "opaque",
+ "direction": "neutral",
+ "aggregation": "none",
+ "primary": false,
+ "description": "Hash of the published stage prompts, critic criteria and output contracts."
+ },
+ {
+ "id": "tokens-dc",
+ "name": "tokens_dc",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Input plus output tokens across every run of the DC stage, revisions included.",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-dc",
+ "name": "n_turns_dc",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Agent-loop turns across every run of the DC stage, revisions included.",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-fte",
+ "name": "tokens_fte",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Input plus output tokens across every run of the FTE stage, revisions included.",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-fte",
+ "name": "n_turns_fte",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Agent-loop turns across every run of the FTE stage, revisions included.",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-fs",
+ "name": "tokens_fs",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Input plus output tokens across every run of the FS stage, revisions included.",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-fs",
+ "name": "n_turns_fs",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Agent-loop turns across every run of the FS stage, revisions included.",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-mlm",
+ "name": "tokens_mlm",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Input plus output tokens across every run of the MLM stage, revisions included.",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-mlm",
+ "name": "n_turns_mlm",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Agent-loop turns across every run of the MLM stage, revisions included.",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-critic",
+ "name": "tokens_critic",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Input plus output tokens across every run of the four critic gates, revisions included.",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-critic",
+ "name": "n_turns_critic",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Agent-loop turns across every run of the four critic gates, revisions included.",
+ "unit": "turns"
+ },
+ {
+ "id": "n-recipe-ops",
+ "name": "n_recipe_ops",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "mean",
+ "primary": false,
+ "description": "Entries in the FTE engineering_recipe.",
+ "unit": "ops"
+ },
+ {
+ "id": "recipe-depth",
+ "name": "recipe_depth",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "mean",
+ "primary": false,
+ "description": "Longest derived-from-derived chain in the FTE recipe (1 by construction when the recipe is non-empty).",
+ "unit": "levels"
+ },
+ {
+ "id": "recipe-hash",
+ "name": "recipe_hash",
+ "value_type": "opaque",
+ "direction": "neutral",
+ "aggregation": "none",
+ "primary": false,
+ "description": "SHA-256 of the FTE recipe structure (name/op/inputs/params), independent of entry order; groups cells that engineered the same features."
}
],
"producers": [
@@ -2308,17 +2803,104 @@
"tool_error_rate": "runtime-tool-error-rate",
"agent_loop_iterations": "runtime-agent-loop-iterations",
"critic_rejections": "runtime-critic-rejections",
- "critic_approvals": "runtime-critic-approvals"
+ "critic_approvals": "runtime-critic-approvals",
+ "critic_invocations": "runtime-critic-invocations",
+ "critic_rejections_partial": "runtime-critic-rejections-partial",
+ "critic_rejections_full": "runtime-critic-rejections-full",
+ "revision_rounds": "runtime-revision-rounds",
+ "capped_agent_runs": "runtime-capped-agent-runs",
+ "agent_runs": "runtime-agent-runs",
+ "react_runs": "runtime-react-runs",
+ "react_turns": "runtime-react-turns",
+ "react_tool_calls": "runtime-react-tool-calls",
+ "prompt_sha256": "runtime-prompt-sha256"
},
"artifacts": [],
"config": {}
},
+ {
+ "id": "runtime-dc",
+ "producer_id": "asaree.node_runtime",
+ "kind": "runtime",
+ "outputs": {
+ "total_tokens": "tokens-dc",
+ "agent_loop_iterations": "n-turns-dc"
+ },
+ "artifacts": [],
+ "config": {
+ "node_ids": [
+ "agent-dc"
+ ]
+ }
+ },
+ {
+ "id": "runtime-fte",
+ "producer_id": "asaree.node_runtime",
+ "kind": "runtime",
+ "outputs": {
+ "total_tokens": "tokens-fte",
+ "agent_loop_iterations": "n-turns-fte"
+ },
+ "artifacts": [],
+ "config": {
+ "node_ids": [
+ "agent-fte"
+ ]
+ }
+ },
+ {
+ "id": "runtime-fs",
+ "producer_id": "asaree.node_runtime",
+ "kind": "runtime",
+ "outputs": {
+ "total_tokens": "tokens-fs",
+ "agent_loop_iterations": "n-turns-fs"
+ },
+ "artifacts": [],
+ "config": {
+ "node_ids": [
+ "agent-fs"
+ ]
+ }
+ },
+ {
+ "id": "runtime-mlm",
+ "producer_id": "asaree.node_runtime",
+ "kind": "runtime",
+ "outputs": {
+ "total_tokens": "tokens-mlm",
+ "agent_loop_iterations": "n-turns-mlm"
+ },
+ "artifacts": [],
+ "config": {
+ "node_ids": [
+ "agent-mlm"
+ ]
+ }
+ },
+ {
+ "id": "runtime-critic",
+ "producer_id": "asaree.node_runtime",
+ "kind": "runtime",
+ "outputs": {
+ "total_tokens": "tokens-critic",
+ "agent_loop_iterations": "n-turns-critic"
+ },
+ "artifacts": [],
+ "config": {
+ "node_ids": [
+ "gate-dc",
+ "gate-fte",
+ "gate-fs",
+ "gate-mlm"
+ ]
+ }
+ },
{
"id": "dc-report-source",
"producer_id": "asaree.agent_output",
"kind": "reported",
"outputs": {
- "n_features_after_dc": "n-features-after-dc",
"missingness_threshold": "missingness-threshold",
"n_dropped_for_missingness": "n-dropped-for-missingness"
},
@@ -2326,9 +2908,6 @@
"config": {
"agent_node_id": "agent-dc",
"projections": {
- "n_features_after_dc": {
- "path": "n_cols_out"
- },
"missingness_threshold": {
"path": "missingness_threshold"
},
@@ -2344,8 +2923,7 @@
"producer_id": "asaree.agent_output",
"kind": "reported",
"outputs": {
- "n_engineered_features": "n-engineered-features",
- "n_features_after_fte": "n-features-after-fte"
+ "n_engineered_features": "n-engineered-features"
},
"artifacts": [],
"config": {
@@ -2354,9 +2932,6 @@
"n_engineered_features": {
"path": "engineering_recipe",
"transform": "length"
- },
- "n_features_after_fte": {
- "path": "n_features_out"
}
}
}
@@ -2366,9 +2941,6 @@
"producer_id": "asaree.agent_output",
"kind": "reported",
"outputs": {
- "n_features_after_fs": "n-features-after-fs",
- "n_engineered_features_selected": "n-engineered-features-selected",
- "pct_engineered_features_selected": "pct-engineered-features-selected",
"variance_threshold": "variance-threshold",
"selected_features": "selected-features"
},
@@ -2376,15 +2948,6 @@
"config": {
"agent_node_id": "agent-fs",
"projections": {
- "n_features_after_fs": {
- "path": "n_features_out"
- },
- "n_engineered_features_selected": {
- "path": "n_engineered_features_selected"
- },
- "pct_engineered_features_selected": {
- "path": "pct_engineered_features_selected"
- },
"variance_threshold": {
"path": "variance_threshold"
},
@@ -2394,12 +2957,34 @@
}
}
},
+ {
+ "id": "feature-pipeline-source",
+ "producer_id": "asaree.feature_pipeline",
+ "kind": "reported",
+ "outputs": {
+ "n_features_after_dc": "n-features-after-dc",
+ "n_features_after_fte": "n-features-after-fte",
+ "n_features_after_fs": "n-features-after-fs",
+ "n_features_created": "n-features-created",
+ "n_created_selected": "n-engineered-features-selected",
+ "frac_created_selected": "frac-created-selected",
+ "n_recipe_ops": "n-recipe-ops",
+ "recipe_depth": "recipe-depth",
+ "recipe_hash": "recipe-hash"
+ },
+ "artifacts": [],
+ "config": {
+ "dc_node_id": "agent-dc",
+ "fte_node_id": "agent-fte",
+ "fs_node_id": "agent-fs",
+ "dataset_node_id": "dataset-mi"
+ }
+ },
{
"id": "model-evaluation-source",
- "producer_id": "asaree.mcp_tool",
+ "producer_id": "asaree.tool_step",
"kind": "reported",
"outputs": {
- "model_evaluation": "model-evaluation",
"pr_auc": "pr-auc",
"roc_auc": "roc-auc",
"brier_score": "brier-score",
@@ -2439,14 +3024,13 @@
"hp_colsample_bytree": "hp-colsample-bytree",
"hp_reg_lambda": "hp-reg-lambda",
"hp_reg_alpha": "hp-reg-alpha",
- "hp_gamma": "hp-gamma"
+ "hp_gamma": "hp-gamma",
+ "n_schema_violations": "n-schema-violations",
+ "threshold_at_prevalence": "threshold-prevalence"
},
"artifacts": [],
"config": {
- "agent_node_id": "agent-score",
- "mcp_node_id": "tool-model-score",
- "server_id": "b16f01eb-90af-4558-af3e-77c6fdcb7b22",
- "tool_name": "run_model_script",
+ "node_id": "tool-step-score",
"projections": {
"pr_auc": {
"path": "test_metrics.average_precision"
@@ -2470,19 +3054,19 @@
"path": "test_metrics.n_pos_test"
},
"accuracy_at_0_5": {
- "path": "/test_metrics/metrics_at_0.5/accuracy"
+ "path": "test_metrics.metrics_at_0.5.accuracy"
},
"balanced_accuracy_at_0_5": {
- "path": "/test_metrics/metrics_at_0.5/balanced_accuracy"
+ "path": "test_metrics.metrics_at_0.5.balanced_accuracy"
},
"precision_at_0_5": {
- "path": "/test_metrics/metrics_at_0.5/precision"
+ "path": "test_metrics.metrics_at_0.5.precision"
},
"recall_at_0_5": {
- "path": "/test_metrics/metrics_at_0.5/recall"
+ "path": "test_metrics.metrics_at_0.5.recall"
},
"f1_at_0_5": {
- "path": "/test_metrics/metrics_at_0.5/f1"
+ "path": "test_metrics.metrics_at_0.5.f1"
},
"accuracy_at_chosen_threshold": {
"path": "test_metrics.metrics_at_chosen_threshold.accuracy"
@@ -2515,7 +3099,7 @@
"path": "test_metrics.metrics_at_prevalence.f1"
},
"n_pred_pos_at_0_5": {
- "path": "/test_metrics/metrics_at_0.5/n_pred_pos"
+ "path": "test_metrics.metrics_at_0.5.n_pred_pos"
},
"n_pred_pos_at_chosen_threshold": {
"path": "test_metrics.metrics_at_chosen_threshold.n_pred_pos"
@@ -2536,7 +3120,7 @@
"path": "code_sha256"
},
"payload_sha256": {
- "path": "payload_sha256"
+ "path": "sanitized_payload_sha256"
},
"data_sha256": {
"path": "data_sha256"
@@ -2567,6 +3151,12 @@
},
"hp_gamma": {
"path": "model_decisions.best_hyperparameters.gamma"
+ },
+ "n_schema_violations": {
+ "path": "n_sanitize_notes"
+ },
+ "threshold_at_prevalence": {
+ "path": "test_metrics.metrics_at_prevalence.threshold"
}
}
}
@@ -2577,6 +3167,31 @@
"producer_binding_id": "runtime",
"input_key": "facts",
"source_key": "attempt.runtime"
+ },
+ {
+ "producer_binding_id": "runtime-dc",
+ "input_key": "facts",
+ "source_key": "attempt.runtime"
+ },
+ {
+ "producer_binding_id": "runtime-fte",
+ "input_key": "facts",
+ "source_key": "attempt.runtime"
+ },
+ {
+ "producer_binding_id": "runtime-fs",
+ "input_key": "facts",
+ "source_key": "attempt.runtime"
+ },
+ {
+ "producer_binding_id": "runtime-mlm",
+ "input_key": "facts",
+ "source_key": "attempt.runtime"
+ },
+ {
+ "producer_binding_id": "runtime-critic",
+ "input_key": "facts",
+ "source_key": "attempt.runtime"
}
]
}
diff --git a/publications/BDM/myocardial-azure-foundry-v0.8.0.json b/publications/BDM/myocardial-azure-foundry.json
similarity index 66%
rename from publications/BDM/myocardial-azure-foundry-v0.8.0.json
rename to publications/BDM/myocardial-azure-foundry.json
index 8d00b467..1b213a26 100644
--- a/publications/BDM/myocardial-azure-foundry-v0.8.0.json
+++ b/publications/BDM/myocardial-azure-foundry.json
@@ -12,7 +12,7 @@
"primary_scientific_outcome": "average precision on a held-out stratified test split"
},
"name": "Myocardial Infarction Use Case (Azure Foundry)",
- "description": "MI-DC -> MI-FTE -> MI-FS -> MI-MLM -> MI-Score: five agents in series on the UCI Myocardial Infarction Complications (ZSN) dataset, each handing a versioned dataset workspace to the next, the first four behind a Critic Gate (MI-Score is intentionally ungated -- it only executes a fixed scoring script, so there is no design decision left to review). The pipeline's methodology (cleaning, feature engineering, selection, hyperparameter planning, scoring) and the critics' review criteria are dataset-agnostic; everything dataset-specific lives in each worker's own task_brief block. Three facts are stated there rather than left for an agent to discover cold: (1) the dataset carries an explicit ID column (role=id, 1..1700, all-unique) that must be dropped as a non-feature record identifier; (2) every feature is RAW -- a mix of integer/real/binary/ordinal/categorical types with real missingness -- so DC's and FTE's inspect-first instructions do real work here; (3) the column names are short Russian-derived codes (nr11, np01, zab_leg_01), not descriptive English, so every column's meaning must be resolved via get_data_dictionary rather than guessed from its name. expected_positive_rate (0.232) is 394 positive of 1700. See README.md in this directory for how to import and run this.",
+ "description": "MI-DC -> MI-FTE -> MI-FS -> MI-MLM -> Score: four agents in series on the UCI Myocardial Infarction Complications (ZSN) dataset, each handing a versioned dataset workspace to the next behind a Critic Gate, then a deterministic Score Tool Step that sanitizes MI-MLM's approved payload and runs the fixed scoring script directly (no model in the call, SHA-256 guarded -- the notebook's score_payload). The pipeline's methodology (cleaning, feature engineering, selection, hyperparameter planning, scoring) and the critics' review criteria are dataset-agnostic; everything dataset-specific lives in each worker's own task_brief block. Three facts are stated there rather than left for an agent to discover cold: (1) the dataset carries an explicit ID column (role=id, 1..1700, all-unique) that must be dropped as a non-feature record identifier; (2) every feature is RAW -- a mix of integer/real/binary/ordinal/categorical types with real missingness -- so DC's and FTE's inspect-first instructions do real work here; (3) the column names are short Russian-derived codes (nr11, np01, zab_leg_01), not descriptive English, so every column's meaning must be resolved via get_data_dictionary rather than guessed from its name. expected_positive_rate (0.232) is 394 positive of 1700. See README.md in this directory for how to import and run this.",
"graph": {
"nodes": [
{
@@ -89,7 +89,7 @@
"name": "dc-critic",
"goal": "Review MI-DC's cleaning decisions for this stage only.",
"description": "Shared Critic reviewing the DC stage -- approves or requests revision with feedback and a partial/full rejection scope.",
- "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the DC (data cleaning) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no task_brief and no dynamically-injected checklist -- so this stage's own checklist is baked in below instead of arriving with each run:\n\n- no leakage: every fit (domain rules, IQR bounds, imputation statistics) is computed on the training split only; the test split is never touched\n- domain/type validation is run on ALL columns (continuous included), and values violating the column type or domain (wrong type, out-of-set category, impossible sign/range) are coerced to NaN with the rule stated\n- a missingness threshold is set WITH a rationale and gates RAW missingness only; columns above it are dropped citing the measured missingness\n- outlier detection (IQR) is run on continuous columns only and is treated as a review flag, not an automatic delete: physiologically plausible but statistically extreme values are kept; deletions are reserved for implausible values and each deletion states why\n- imputation is appropriate and train-only: mode for binary/categorical, mean or median for continuous with the mean-vs-median choice justified by skew; ALL surviving NaNs are imputed, including those created by domain fixes and outlier removal\n- the cleaned matrices are committed to the workspace as v1_dc by apply_coercions and drop_and_impute (the cell workspace identity is ambient; persistence is automatic, not a save_artifact step); the committed output must have zero remaining missing values and identical train/test columns\n- cleaning is proportionate: the agent did not over-clean (deleting real signal) or under-clean (leaving integrity errors); judged on the stated reasons, not on a count\n- text-typed numeric columns handled: any column that is semantically continuous but loaded as text/object because a MINORITY of cells hold non-numeric tokens (censored \"x\" values, unit/flag suffixes, or free-text lab comments) is NOT left as text or treated as categorical — those tokens are coerced to NaN so the column parses as numeric, with the rule and coerced count stated\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: IQR deletion of physiologically plausible extremes (real clinical signal\n thrown away); domain/type validation skipped on continuous columns; the\n missingness threshold re-triggering on outlier-induced NaNs instead of raw\n missingness; NaNs left unimputed after domain fixes or outlier removal; a\n semantically-continuous column left as text or mis-typed as categorical because\n a few non-numeric tokens (censored \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
+ "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the DC (data cleaning) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no dynamically-injected task_brief or checklist -- so the task_brief and this stage's own checklist are baked in below instead of arriving with each run.\n\ntask_brief: {\"domain\": \"acute myocardial infarction admissions, UCI Myocardial Infarction Complications (ZSN) dataset\", \"prediction_task\": \"binary\", \"target_column\": \"mi_ZSN\", \"positive_class\": 1, \"expected_positive_rate\": 0.232, \"selection_metric\": \"average_precision\", \"num_classes\": 2, \"group_column\": null, \"notes\": \"Predict development of chronic heart failure (ZSN) as a post-MI complication. Dataset: 1700 admissions x 113 raw columns (1 ID + 111 features + target mi_ZSN); target is binary (0=no ZSN, 1=ZSN), observed positive rate 23.2%. Column `ID` (role=id, values 1..1700, all unique) is a non-feature record identifier -- exclude it from the training matrix entirely; it must never be imputed, engineered, selected, or scored as a feature. Unlike some ASAREE datasets, features here are RAW, not pre-encoded: types are a mix of integer, real, binary, ordinal, and categorical, and several are known to have real missingness (this is the original UCI dataset, not a cleaned derivative) -- confirm actual missingness yourself via inspect_columns rather than assuming any column is complete. Column names are NOT descriptive English words; they are short Russian-derived codes (e.g. `nr11`, `np01`, `zab_leg_01`, `S_AD_KBRIG`) -- do not infer meaning from the name alone, always confirm via get_data_dictionary, which carries a description and, for categorical/ordinal columns, its code mapping. FEATURE FAMILIES: demographics (AGE, SEX); cardiac history (INF_ANAM prior-MI count, STENOK_AN/FK_STENOK angina, GB/SIM_GIPERT/DLIT_AG hypertension, ZSN_A prior heart failure, IBS_POST/IBS_NASL coronary disease, nr*/np* prior arrhythmia and conduction-block history); comorbidities (endocr_* diabetes/obesity/thyroid, zab_leg_* chronic pulmonary disease); admission vitals and ICU-admission complications (S_AD_KBRIG/D_AD_KBRIG/S_AD_ORIT/D_AD_ORIT blood pressure, O_L_POST/K_SH_POST/MP_TP_POST/SVT_POST/GT_POST/FIB_G_POST); infarct localization (ant_im/lat_im/inf_im/post_im/IM_PG_P); admission ECG rhythm and conduction findings (ritm_ecg_p_*, n_r_ecg_p_*, n_p_ecg_p_*); labs (K_BLOOD, Na_BLOOD, ALT_BLOOD, AST_BLOOD, KFK_BLOOD, L_BLOOD, ROE) with their derived abnormal-value flags (GIPO_K, GIPER_Na); and treatment given before/during ICU admission (fibr_ter_* fibrinolytics, NA_KB/NA_R_*_n opioids, NOT_NA_*_n/NOT_NA_KB NSAIDs, LID_KB/LID_S_n lidocaine, B_BLOK_S_n beta-blockers, ANT_CA_S_n calcium-channel blockers, GEPAR_S_n heparin, ASP_S_n aspirin, TIKL_S_n/TRENT_S_n antiplatelets, NITR_S nitrates). A per-column data dictionary (description + codes) is available via get_data_dictionary.\"}\n\nChecklist:\n\n- no leakage: every fit (domain rules, IQR bounds, imputation statistics) is computed on the training split only; the test split is never touched\n- domain/type validation is run on ALL columns (continuous included), and values violating the column type or domain (wrong type, out-of-set category, impossible sign/range) are coerced to NaN with the rule stated\n- a missingness threshold is set WITH a rationale and gates RAW missingness only; columns above it are dropped citing the measured missingness\n- outlier detection (IQR) is run on continuous columns only and is treated as a review flag, not an automatic delete: physiologically plausible but statistically extreme values are kept; deletions are reserved for implausible values and each deletion states why\n- imputation is appropriate and train-only: mode for binary/categorical, mean or median for continuous with the mean-vs-median choice justified by skew; ALL surviving NaNs are imputed, including those created by domain fixes and outlier removal\n- the cleaned matrices are committed to the workspace as v1_dc by apply_coercions and drop_and_impute (the cell workspace identity is ambient; persistence is automatic, not a save_artifact step); the committed output must have zero remaining missing values and identical train/test columns\n- cleaning is proportionate: the agent did not over-clean (deleting real signal) or under-clean (leaving integrity errors); judged on the stated reasons, not on a count\n- text-typed numeric columns handled: any column that is semantically continuous but loaded as text/object because a MINORITY of cells hold non-numeric tokens (censored \"x\" values, unit/flag suffixes, or free-text lab comments) is NOT left as text or treated as categorical — those tokens are coerced to NaN so the column parses as numeric, with the rule and coerced count stated\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: IQR deletion of physiologically plausible extremes (real clinical signal\n thrown away); domain/type validation skipped on continuous columns; the\n missingness threshold re-triggering on outlier-induced NaNs instead of raw\n missingness; NaNs left unimputed after domain fixes or outlier removal; a\n semantically-continuous column left as text or mis-typed as categorical because\n a few non-numeric tokens (censored \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
"enabled": true,
"max_revisions": 1
},
@@ -108,7 +108,7 @@
"data": {
"label": "",
"config": {
- "max_iterations": 15,
+ "max_iterations": 12,
"include_scratchpad": true,
"scratchpad_window": 10,
"observation_format": "raw"
@@ -193,7 +193,7 @@
"name": "fte-critic",
"goal": "Review MI-FTE's engineering/encoding decisions for this stage only.",
"description": "Shared Critic reviewing the FTE stage -- approves or requests revision with feedback and a partial/full rejection scope.",
- "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the FTE (feature transformation/engineering) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no task_brief and no dynamically-injected checklist -- so this stage's own checklist is baked in below instead of arriving with each run:\n\n- no leakage: every fit (encoders, and any statistic used by an engineered feature such as a group median or bin edge) is computed on the training split only; the test split is never touched\n- engineered features are emitted as a TYPED, REPLAYABLE recipe (each entry has an op, its inputs, and an explicit deterministic output name), not as one-shot mutations of the training frame\n- every train-derived statistic an engineered feature depends on (bin edges, group median, training prevalence) is FROZEN as an explicit recipe param computed on the training fold, so the same value is applied to test without recomputing (this is the train/test leakage + skew guard)\n- engineered output names are explicit and deterministic so the FS selector and the test partition align on the same column names; group aggregates declare a fallback for group values unseen at fit time\n- encoding appropriateness: each column has an encoding decision (binarize, ordinal with a stated order, one-hot, leave-numeric, or bin) justified from column semantics OR data spread, not a blanket rule\n- every engineered feature carries a one-line rationale naming a specific reason it should help THIS target, not a generic statement\n- engineering is proportionate: the agent engineered as much as is justified and no more; the critic flags both bloat (features with no specific hypothesis) and under-engineering (obvious, well-supported features left out). Judged on rationale quality, not a count\n- interactions (multiplicative or logical) are individually reasoned, not a blanket multiply of all pairs\n- clinical meta-features, if any, are clinically coherent and non-leaky; any composite or index does NOT silently discard component signal (components are retained alongside the composite unless their removal is justified)\n- the engineered + encoded matrix is committed to the workspace as v2_fte, fully numeric and model-ready: build_features (recipe) then fit_preprocessor (encoding_map) are BOTH called, so the encoded matrix is the committed output the FS stage and the runner read (workspace identity is ambient; persistence is automatic, not a save_artifact step)\n- the per-column encoding_map was passed to fit_preprocessor via encoding_map_json (not just described): the map the agent reports equals the map it fit, so test is encoded with the same per-column decisions and handle_unknown protects against unseen test categories\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: engineering emitted as one-shot frame mutations instead of a typed,\n replayable recipe; a train-derived statistic (bin edge, group median,\n prevalence) recomputed at transform time instead of frozen into the recipe;\n non-deterministic engineered names that would desync the FS selector on test; a\n group aggregate with no unseen-group fallback; lossy composites that discard\n component signal without justification; leaky meta-features computed downstream\n of the outcome; generic rationales; bloat or under-engineering; build_features\n or fit_preprocessor did not commit the encoded v2_fte matrix, or the encoding_map\n only described but not passed via encoding_map_json.\n\nDO NOT BE TRIGGER-HAPPY. Enforce a revision ONLY when something is genuinely\nnon-optimal, clinically irrelevant, leaky, or incorrect. Do not fail a stage over\nstylistic preferences, defensible judgment calls, or choices you would merely\nhave made differently. On a true tie, approve. Do not invent problems.\n\nYour ENTIRE response is this verdict -- the FIRST character you output MUST be `{`.\nDo NOT write any preamble, plan, or meta-commentary about the task (e.g. \"this is\na review task...\", \"the reviewer should...\"); render the verdict itself, not a\ndescription of how you would review. Return EXACTLY ONE JSON object (followed only\nby the machine-readable verdict line described in the Rules) and nothing else:\n{\n \"stage\": \"fte\",\n \"approved\": true,\n \"criteria_results\": [\n {\"criterion\": \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
+ "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the FTE (feature transformation/engineering) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no dynamically-injected task_brief or checklist -- so the task_brief and this stage's own checklist are baked in below instead of arriving with each run.\n\ntask_brief: {\"domain\": \"acute myocardial infarction admissions, UCI Myocardial Infarction Complications (ZSN) dataset\", \"prediction_task\": \"binary\", \"target_column\": \"mi_ZSN\", \"positive_class\": 1, \"expected_positive_rate\": 0.232, \"selection_metric\": \"average_precision\", \"num_classes\": 2, \"group_column\": null, \"notes\": \"Predict development of chronic heart failure (ZSN) as a post-MI complication. Dataset: 1700 admissions x 113 raw columns (1 ID + 111 features + target mi_ZSN); target is binary (0=no ZSN, 1=ZSN), observed positive rate 23.2%. Column `ID` (role=id, values 1..1700, all unique) is a non-feature record identifier -- exclude it from the training matrix entirely; it must never be imputed, engineered, selected, or scored as a feature. Unlike some ASAREE datasets, features here are RAW, not pre-encoded: types are a mix of integer, real, binary, ordinal, and categorical, and several are known to have real missingness (this is the original UCI dataset, not a cleaned derivative) -- confirm actual missingness yourself via inspect_columns rather than assuming any column is complete. Column names are NOT descriptive English words; they are short Russian-derived codes (e.g. `nr11`, `np01`, `zab_leg_01`, `S_AD_KBRIG`) -- do not infer meaning from the name alone, always confirm via get_data_dictionary, which carries a description and, for categorical/ordinal columns, its code mapping. FEATURE FAMILIES: demographics (AGE, SEX); cardiac history (INF_ANAM prior-MI count, STENOK_AN/FK_STENOK angina, GB/SIM_GIPERT/DLIT_AG hypertension, ZSN_A prior heart failure, IBS_POST/IBS_NASL coronary disease, nr*/np* prior arrhythmia and conduction-block history); comorbidities (endocr_* diabetes/obesity/thyroid, zab_leg_* chronic pulmonary disease); admission vitals and ICU-admission complications (S_AD_KBRIG/D_AD_KBRIG/S_AD_ORIT/D_AD_ORIT blood pressure, O_L_POST/K_SH_POST/MP_TP_POST/SVT_POST/GT_POST/FIB_G_POST); infarct localization (ant_im/lat_im/inf_im/post_im/IM_PG_P); admission ECG rhythm and conduction findings (ritm_ecg_p_*, n_r_ecg_p_*, n_p_ecg_p_*); labs (K_BLOOD, Na_BLOOD, ALT_BLOOD, AST_BLOOD, KFK_BLOOD, L_BLOOD, ROE) with their derived abnormal-value flags (GIPO_K, GIPER_Na); and treatment given before/during ICU admission (fibr_ter_* fibrinolytics, NA_KB/NA_R_*_n opioids, NOT_NA_*_n/NOT_NA_KB NSAIDs, LID_KB/LID_S_n lidocaine, B_BLOK_S_n beta-blockers, ANT_CA_S_n calcium-channel blockers, GEPAR_S_n heparin, ASP_S_n aspirin, TIKL_S_n/TRENT_S_n antiplatelets, NITR_S nitrates). A per-column data dictionary (description + codes) is available via get_data_dictionary.\"}\n\nChecklist:\n\n- no leakage: every fit (encoders, and any statistic used by an engineered feature such as a group median or bin edge) is computed on the training split only; the test split is never touched\n- engineered features are emitted as a TYPED, REPLAYABLE recipe (each entry has an op, its inputs, and an explicit deterministic output name), not as one-shot mutations of the training frame\n- every train-derived statistic an engineered feature depends on (bin edges, group median, training prevalence) is FROZEN as an explicit recipe param computed on the training fold, so the same value is applied to test without recomputing (this is the train/test leakage + skew guard)\n- engineered output names are explicit and deterministic so the FS selector and the test partition align on the same column names; group aggregates declare a fallback for group values unseen at fit time\n- encoding appropriateness: each column has an encoding decision (binarize, ordinal with a stated order, one-hot, leave-numeric, or bin) justified from column semantics OR data spread, not a blanket rule\n- every engineered feature carries a one-line rationale naming a specific reason it should help THIS target, not a generic statement\n- engineering is proportionate: the agent engineered as much as is justified and no more; the critic flags both bloat (features with no specific hypothesis) and under-engineering (obvious, well-supported features left out). Judged on rationale quality, not a count\n- interactions (multiplicative or logical) are individually reasoned, not a blanket multiply of all pairs\n- clinical meta-features, if any, are clinically coherent and non-leaky; any composite or index does NOT silently discard component signal (components are retained alongside the composite unless their removal is justified)\n- the engineered + encoded matrix is committed to the workspace as v2_fte, fully numeric and model-ready: build_features (recipe) then fit_preprocessor (encoding_map) are BOTH called, so the encoded matrix is the committed output the FS stage and the runner read (workspace identity is ambient; persistence is automatic, not a save_artifact step)\n- the per-column encoding_map was passed to fit_preprocessor via encoding_map_json (not just described): the map the agent reports equals the map it fit, so test is encoded with the same per-column decisions and handle_unknown protects against unseen test categories\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: engineering emitted as one-shot frame mutations instead of a typed,\n replayable recipe; a train-derived statistic (bin edge, group median,\n prevalence) recomputed at transform time instead of frozen into the recipe;\n non-deterministic engineered names that would desync the FS selector on test; a\n group aggregate with no unseen-group fallback; lossy composites that discard\n component signal without justification; leaky meta-features computed downstream\n of the outcome; generic rationales; bloat or under-engineering; build_features\n or fit_preprocessor did not commit the encoded v2_fte matrix, or the encoding_map\n only described but not passed via encoding_map_json.\n\nDO NOT BE TRIGGER-HAPPY. Enforce a revision ONLY when something is genuinely\nnon-optimal, clinically irrelevant, leaky, or incorrect. Do not fail a stage over\nstylistic preferences, defensible judgment calls, or choices you would merely\nhave made differently. On a true tie, approve. Do not invent problems.\n\nYour ENTIRE response is this verdict -- the FIRST character you output MUST be `{`.\nDo NOT write any preamble, plan, or meta-commentary about the task (e.g. \"this is\na review task...\", \"the reviewer should...\"); render the verdict itself, not a\ndescription of how you would review. Return EXACTLY ONE JSON object (followed only\nby the machine-readable verdict line described in the Rules) and nothing else:\n{\n \"stage\": \"fte\",\n \"approved\": true,\n \"criteria_results\": [\n {\"criterion\": \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
"enabled": true,
"max_revisions": 1
},
@@ -212,7 +212,7 @@
"data": {
"label": "",
"config": {
- "max_iterations": 15,
+ "max_iterations": 12,
"include_scratchpad": true,
"scratchpad_window": 10,
"observation_format": "raw"
@@ -275,7 +275,7 @@
"prompt": "",
"goal": "Prune the feature space; commit v3_fs. After your final filter/balance-check tool call succeeds, call accept_stage(stage=\"fs\") yourself to promote your work to workspace HEAD before finishing -- this run's Critic Gate reviews your report, not this promotion, so call it on every attempt (a later revision's call simply supersedes an earlier one).\n\nRandom seed: 42\n\ntask_brief: {\"domain\": \"acute myocardial infarction admissions, UCI Myocardial Infarction Complications (ZSN) dataset\", \"prediction_task\": \"binary\", \"target_column\": \"mi_ZSN\", \"positive_class\": 1, \"expected_positive_rate\": 0.232, \"selection_metric\": \"average_precision\", \"num_classes\": 2, \"group_column\": null, \"notes\": \"Predict development of chronic heart failure (ZSN) as a post-MI complication. Dataset: 1700 admissions x 113 raw columns (1 ID + 111 features + target mi_ZSN); target is binary (0=no ZSN, 1=ZSN), observed positive rate 23.2%. Column `ID` (role=id, values 1..1700, all unique) is a non-feature record identifier -- exclude it from the training matrix entirely; it must never be imputed, engineered, selected, or scored as a feature. Unlike some ASAREE datasets, features here are RAW, not pre-encoded: types are a mix of integer, real, binary, ordinal, and categorical, and several are known to have real missingness (this is the original UCI dataset, not a cleaned derivative) -- confirm actual missingness yourself via inspect_columns rather than assuming any column is complete. Column names are NOT descriptive English words; they are short Russian-derived codes (e.g. `nr11`, `np01`, `zab_leg_01`, `S_AD_KBRIG`) -- do not infer meaning from the name alone, always confirm via get_data_dictionary, which carries a description and, for categorical/ordinal columns, its code mapping. FEATURE FAMILIES: demographics (AGE, SEX); cardiac history (INF_ANAM prior-MI count, STENOK_AN/FK_STENOK angina, GB/SIM_GIPERT/DLIT_AG hypertension, ZSN_A prior heart failure, IBS_POST/IBS_NASL coronary disease, nr*/np* prior arrhythmia and conduction-block history); comorbidities (endocr_* diabetes/obesity/thyroid, zab_leg_* chronic pulmonary disease); admission vitals and ICU-admission complications (S_AD_KBRIG/D_AD_KBRIG/S_AD_ORIT/D_AD_ORIT blood pressure, O_L_POST/K_SH_POST/MP_TP_POST/SVT_POST/GT_POST/FIB_G_POST); infarct localization (ant_im/lat_im/inf_im/post_im/IM_PG_P); admission ECG rhythm and conduction findings (ritm_ecg_p_*, n_r_ecg_p_*, n_p_ecg_p_*); labs (K_BLOOD, Na_BLOOD, ALT_BLOOD, AST_BLOOD, KFK_BLOOD, L_BLOOD, ROE) with their derived abnormal-value flags (GIPO_K, GIPER_Na); and treatment given before/during ICU admission (fibr_ter_* fibrinolytics, NA_KB/NA_R_*_n opioids, NOT_NA_*_n/NOT_NA_KB NSAIDs, LID_KB/LID_S_n lidocaine, B_BLOK_S_n beta-blockers, ANT_CA_S_n calcium-channel blockers, GEPAR_S_n heparin, ASP_S_n aspirin, TIKL_S_n/TRENT_S_n antiplatelets, NITR_S nitrates). A per-column data dictionary (description + codes) is available via get_data_dictionary.\"}",
"description": "Filters by variance/correlation/supervised signal and checks class balance on top of v2_fte, commits the result as workspace version v3_fs.",
- "system_prompt": "\nYou are a feature SELECTION agent for biomedical tabular data. You run THIRD, on\nthe engineered + encoded dataset from FTE (already clean, complete, and fully\nnumeric; missingness, engineering, and encoding were handled upstream). Your job\nis to profile the feature space and prune it through unsupervised filters plus one\nsupervised filter, over the FULL space of original survivors and engineered\nmeta-features alike. You do NOT clean, impute, engineer, or model. You do NOT\ntouch the test split.\n\nWORKSPACE MODEL (read this first): the FTE stage already committed the encoded,\nmodel-ready train/test matrix (v2_fte). Opening the workspace loads that encoded\nTRAINING matrix for you — you do NOT re-apply any preprocessor; the input is\nalready the space the runner will score. A selection tool computes on the\ntraining split only and writes this stage's dataset version (v3_fs) = the input\nrestricted to the columns you keep, on BOTH splits; the cell's workspace identity\ntravels with every tool call automatically, so you never pass a workspace id or a\ndataset id. There is no save_artifact step and there are no artifact keys.\n\nRUN CONTEXT\nYou will receive:\n- task_brief (same schema as DC's).\n- The approved FTE recipe report (context; the encoded data is already on disk).\n- The registered dataset name and random seed.\n- Your cell's workspace is ALREADY OPEN, seeded from the wired dataset before your\n first turn -- your run context says so. What that does NOT do is materialize this\n stage's scratch input, which the selection tools read, so you still make one\n argument-free-except-stage open_workspace(stage=\"fs\") call. You never pass a\n workspace id, an experiment id, a cell label or a dataset id to anything: the\n workspace identity travels with every tool call as ambient run context.\n The FINAL selection call commits the selected columns as v3_fs.\n\nReproduce the same random_seed in every tool call. Every drop must cite a\nspecific, measurable criterion.\n\nSTEPS\n1. Call open_workspace(stage=\"fs\") -- that argument and nothing else; which\n workspace and which dataset both arrive as ambient run context.\n It loads the ENCODED, model-ready TRAINING matrix (the FTE output) into your\n scratch input. Do NOT re-apply a preprocessor; selection runs directly on this\n already-encoded space, so the selected names align with what the runner scores.\n2. Profile every column (get_feature_distributions()): dtype, n_unique,\n summary stats. (Data is already complete and numeric; this is selection context.)\n3. Variance filter: variance_filter(threshold=)\n (wraps sklearn VarianceThreshold): set and STATE a threshold; drop low /\n near-zero-variance features. State your scaling assumption, since the threshold\n interacts with scale.\n4. Correlation filter (compute_correlations(method='spearman')): find\n pairs with |rho| above a stated threshold (e.g. 0.9). For each pair remove ONE\n member using an UNSUPERVISED tie-break: keep the lower-missingness member, then\n the higher-variance member; drop whichever is harder to interpret on a true\n tie. Do not use the label here.\n5. Supervised filter: choose the survivors of steps 3-4 and call\n supervised_filter(method=\"f_classif\" | \"mutual_info_classif\",\n k=, features_json=).\n This is the FINAL selection call: it commits the selected\n columns as this stage's output (v3_fs). The committed columns must be a subset\n of the v2_fte columns.\n6. Call check_class_balance() and verify the observed rate matches\n expected_positive_rate (binary) or the class count matches num_classes.\n\n7. Compare selected_features with the output names in the upstream FTE\n engineering_recipe. Report their intersection count as\n n_engineered_features_selected and 100 times that count divided by\n n_features_out as pct_engineered_features_selected.\n\nCONCISENESS: report only what you changed or flagged. Summarize untouched columns\nby COUNT — do NOT enumerate all columns. Keep every rationale to one line. The\ncommitted dataset version carries the full detail; the runner reads it.\n\nOUTPUT - return ONE JSON dossier:\n{\n \"n_features_in\": ..., \"n_features_out\": ...,\n \"n_engineered_features_selected\": ...,\n \"pct_engineered_features_selected\": ...,\n \"variance_threshold\": 0.0,\n \"correlation\": {\"method\": \"spearman\", \"rho_threshold\": 0.9,\n \"pairs_removed\": [[\"kept\", \"dropped\", 0.95], ...]},\n \"supervised_filter\": {\"method\": \"f_classif|mutual_info_classif\", \"k\": 0,\n \"top_scores\": [[\"feature\", score], ...]},\n \"dropped_features\": [{\"feature\": \"...\", \"reason\": \"criterion + measured evidence\"}],\n \"selected_features\": [\"...\"],\n \"observed_class_distribution\": {\"\": 0.0, ...},\n \"class_balance_check\": \"matches task_brief\" | \"diverges: ...\",\n \"notes_for_mlm\": \"caveats, dtype quirks, anything MLM should know.\"\n}\n",
+ "system_prompt": "\nYou are a feature SELECTION agent for biomedical tabular data. You run THIRD, on\nthe engineered + encoded dataset from FTE (already clean, complete, and fully\nnumeric; missingness, engineering, and encoding were handled upstream). Your job\nis to profile the feature space and prune it through unsupervised filters plus one\nsupervised filter, over the FULL space of original survivors and engineered\nmeta-features alike. You do NOT clean, impute, engineer, or model. You do NOT\ntouch the test split.\n\nWORKSPACE MODEL (read this first): the FTE stage already committed the encoded,\nmodel-ready train/test matrix (v2_fte). Opening the workspace loads that encoded\nTRAINING matrix for you — you do NOT re-apply any preprocessor; the input is\nalready the space the runner will score. A selection tool computes on the\ntraining split only and writes this stage's dataset version (v3_fs) = the input\nrestricted to the columns you keep, on BOTH splits; the cell's workspace identity\ntravels with every tool call automatically, so you never pass a workspace id or a\ndataset id. There is no save_artifact step and there are no artifact keys.\n\nRUN CONTEXT\nYou will receive:\n- task_brief (same schema as DC's).\n- The approved FTE recipe report (context; the encoded data is already on disk).\n- The registered dataset name and random seed.\n- Your cell's workspace is ALREADY OPEN, seeded from the wired dataset before your\n first turn -- your run context says so. What that does NOT do is materialize this\n stage's scratch input, which the selection tools read, so you still make one\n argument-free-except-stage open_workspace(stage=\"fs\") call. You never pass a\n workspace id, an experiment id, a cell label or a dataset id to anything: the\n workspace identity travels with every tool call as ambient run context.\n The FINAL selection call commits the selected columns as v3_fs.\n\nReproduce the same random_seed in every tool call. Every drop must cite a\nspecific, measurable criterion.\n\nSTEPS\n1. Call open_workspace(stage=\"fs\") -- that argument and nothing else; which\n workspace and which dataset both arrive as ambient run context.\n It loads the ENCODED, model-ready TRAINING matrix (the FTE output) into your\n scratch input. Do NOT re-apply a preprocessor; selection runs directly on this\n already-encoded space, so the selected names align with what the runner scores.\n2. Profile every column (get_feature_distributions()): dtype, n_unique,\n summary stats. (Data is already complete and numeric; this is selection context.)\n3. Variance filter: variance_filter(threshold=)\n (wraps sklearn VarianceThreshold): set and STATE a threshold; drop low /\n near-zero-variance features. State your scaling assumption, since the threshold\n interacts with scale.\n4. Correlation filter (compute_correlations(method='spearman')): find\n pairs with |rho| above a stated threshold (e.g. 0.9). For each pair remove ONE\n member using an UNSUPERVISED tie-break: keep the lower-missingness member, then\n the higher-variance member; drop whichever is harder to interpret on a true\n tie. Do not use the label here.\n5. Supervised filter: choose the survivors of steps 3-4 and call\n supervised_filter(method=\"f_classif\" | \"mutual_info_classif\",\n k=, features_json=).\n This is the FINAL selection call: it commits the selected\n columns as this stage's output (v3_fs). The committed columns must be a subset\n of the v2_fte columns.\n6. Call check_class_balance() and verify the observed rate matches\n expected_positive_rate (binary) or the class count matches num_classes.\n\nCONCISENESS: report only what you changed or flagged. Summarize untouched columns\nby COUNT — do NOT enumerate all columns. Keep every rationale to one line. The\ncommitted dataset version carries the full detail; the runner reads it.\n\nOUTPUT - return ONE JSON dossier:\n{\n \"n_features_in\": ..., \"n_features_out\": ...,\n \"variance_threshold\": 0.0,\n \"correlation\": {\"method\": \"spearman\", \"rho_threshold\": 0.9,\n \"pairs_removed\": [[\"kept\", \"dropped\", 0.95], ...]},\n \"supervised_filter\": {\"method\": \"f_classif|mutual_info_classif\", \"k\": 0,\n \"top_scores\": [[\"feature\", score], ...]},\n \"dropped_features\": [{\"feature\": \"...\", \"reason\": \"criterion + measured evidence\"}],\n \"selected_features\": [\"...\"],\n \"observed_class_distribution\": {\"\": 0.0, ...},\n \"class_balance_check\": \"matches task_brief\" | \"diverges: ...\",\n \"notes_for_mlm\": \"caveats, dtype quirks, anything MLM should know.\"\n}\n",
"output_contract": null,
"budget_limit_usd": 200.0,
"max_run_duration_seconds": 86400,
@@ -297,7 +297,7 @@
"name": "fs-critic",
"goal": "Review MI-FS's feature-selection decisions for this stage only.",
"description": "Shared Critic reviewing the FS stage -- approves or requests revision with feedback and a partial/full rejection scope.",
- "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the FS (feature selection) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no task_brief and no dynamically-injected checklist -- so this stage's own checklist is baked in below instead of arriving with each run:\n\n- profiling completeness: every retained column is profiled with dtype, n_unique, and summary stats (data arrives clean and already encoded from DC/FTE; missingness and encoding are NOT this stage's job)\n- selection runs on the encoded feature space the workspace already holds (v2_fte), so the selected names align with the matrix the runner scores; no preprocessor is re-applied here (the input is already model-ready)\n- variance filter: a variance threshold (and the method) is stated and low/near-zero-variance features are removed against it\n- correlation filter: highly correlated pairs are found (method + |rho| threshold stated) and one of each pair is removed with a defensible UNSUPERVISED tie-break (e.g. lower missingness then higher variance)\n- supervised filter is CORRECT for the task: f_classif or mutual_info_classif for classification (NOT f_regression, which assumes a continuous target), and is rank-based / conservative (keep top-k) rather than an aggressive p-value cut that could discard features carrying signal only in interaction\n- selection covers the FULL expanded space: original survivors and FTE meta-features are filtered on equal footing; nothing bypasses selection\n- observed class distribution is checked against task_brief (expected_positive_rate for binary, num_classes for multiclass)\n- no leakage: all fits (variance, correlation, supervised filter) are computed on the training split only; the test split is never touched\n- the selected features are committed to the workspace as v3_fs by the final selection call (workspace identity is ambient; persistence is automatic, not a save_artifact step); the committed columns must be a subset of the v2_fte columns\n- selection is proportionate: it did not strip features the downstream tree model could plausibly exploit, nor retain obvious redundancy\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: a cut so aggressive it likely discards interaction-only signal the tree\n could use; a correlation tie-break that leaks the label; meta-features exempted\n from selection; unstated thresholds; the final selection call did not commit\n v3_fs; any fit touching the test split.\n\nDO NOT BE TRIGGER-HAPPY. Enforce a revision ONLY when something is genuinely\nnon-optimal, clinically irrelevant, leaky, or incorrect. Do not fail a stage over\nstylistic preferences, defensible judgment calls, or choices you would merely\nhave made differently. On a true tie, approve. Do not invent problems.\n\nYour ENTIRE response is this verdict -- the FIRST character you output MUST be `{`.\nDo NOT write any preamble, plan, or meta-commentary about the task (e.g. \"this is\na review task...\", \"the reviewer should...\"); render the verdict itself, not a\ndescription of how you would review. Return EXACTLY ONE JSON object (followed only\nby the machine-readable verdict line described in the Rules) and nothing else:\n{\n \"stage\": \"fs\",\n \"approved\": true,\n \"criteria_results\": [\n {\"criterion\": \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
+ "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the FS (feature selection) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no dynamically-injected task_brief or checklist -- so the task_brief and this stage's own checklist are baked in below instead of arriving with each run.\n\ntask_brief: {\"domain\": \"acute myocardial infarction admissions, UCI Myocardial Infarction Complications (ZSN) dataset\", \"prediction_task\": \"binary\", \"target_column\": \"mi_ZSN\", \"positive_class\": 1, \"expected_positive_rate\": 0.232, \"selection_metric\": \"average_precision\", \"num_classes\": 2, \"group_column\": null, \"notes\": \"Predict development of chronic heart failure (ZSN) as a post-MI complication. Dataset: 1700 admissions x 113 raw columns (1 ID + 111 features + target mi_ZSN); target is binary (0=no ZSN, 1=ZSN), observed positive rate 23.2%. Column `ID` (role=id, values 1..1700, all unique) is a non-feature record identifier -- exclude it from the training matrix entirely; it must never be imputed, engineered, selected, or scored as a feature. Unlike some ASAREE datasets, features here are RAW, not pre-encoded: types are a mix of integer, real, binary, ordinal, and categorical, and several are known to have real missingness (this is the original UCI dataset, not a cleaned derivative) -- confirm actual missingness yourself via inspect_columns rather than assuming any column is complete. Column names are NOT descriptive English words; they are short Russian-derived codes (e.g. `nr11`, `np01`, `zab_leg_01`, `S_AD_KBRIG`) -- do not infer meaning from the name alone, always confirm via get_data_dictionary, which carries a description and, for categorical/ordinal columns, its code mapping. FEATURE FAMILIES: demographics (AGE, SEX); cardiac history (INF_ANAM prior-MI count, STENOK_AN/FK_STENOK angina, GB/SIM_GIPERT/DLIT_AG hypertension, ZSN_A prior heart failure, IBS_POST/IBS_NASL coronary disease, nr*/np* prior arrhythmia and conduction-block history); comorbidities (endocr_* diabetes/obesity/thyroid, zab_leg_* chronic pulmonary disease); admission vitals and ICU-admission complications (S_AD_KBRIG/D_AD_KBRIG/S_AD_ORIT/D_AD_ORIT blood pressure, O_L_POST/K_SH_POST/MP_TP_POST/SVT_POST/GT_POST/FIB_G_POST); infarct localization (ant_im/lat_im/inf_im/post_im/IM_PG_P); admission ECG rhythm and conduction findings (ritm_ecg_p_*, n_r_ecg_p_*, n_p_ecg_p_*); labs (K_BLOOD, Na_BLOOD, ALT_BLOOD, AST_BLOOD, KFK_BLOOD, L_BLOOD, ROE) with their derived abnormal-value flags (GIPO_K, GIPER_Na); and treatment given before/during ICU admission (fibr_ter_* fibrinolytics, NA_KB/NA_R_*_n opioids, NOT_NA_*_n/NOT_NA_KB NSAIDs, LID_KB/LID_S_n lidocaine, B_BLOK_S_n beta-blockers, ANT_CA_S_n calcium-channel blockers, GEPAR_S_n heparin, ASP_S_n aspirin, TIKL_S_n/TRENT_S_n antiplatelets, NITR_S nitrates). A per-column data dictionary (description + codes) is available via get_data_dictionary.\"}\n\nChecklist:\n\n- profiling completeness: every retained column is profiled with dtype, n_unique, and summary stats (data arrives clean and already encoded from DC/FTE; missingness and encoding are NOT this stage's job)\n- selection runs on the encoded feature space the workspace already holds (v2_fte), so the selected names align with the matrix the runner scores; no preprocessor is re-applied here (the input is already model-ready)\n- variance filter: a variance threshold (and the method) is stated and low/near-zero-variance features are removed against it\n- correlation filter: highly correlated pairs are found (method + |rho| threshold stated) and one of each pair is removed with a defensible UNSUPERVISED tie-break (e.g. lower missingness then higher variance)\n- supervised filter is CORRECT for the task: f_classif or mutual_info_classif for classification (NOT f_regression, which assumes a continuous target), and is rank-based / conservative (keep top-k) rather than an aggressive p-value cut that could discard features carrying signal only in interaction\n- selection covers the FULL expanded space: original survivors and FTE meta-features are filtered on equal footing; nothing bypasses selection\n- observed class distribution is checked against task_brief (expected_positive_rate for binary, num_classes for multiclass)\n- no leakage: all fits (variance, correlation, supervised filter) are computed on the training split only; the test split is never touched\n- the selected features are committed to the workspace as v3_fs by the final selection call (workspace identity is ambient; persistence is automatic, not a save_artifact step); the committed columns must be a subset of the v2_fte columns\n- selection is proportionate: it did not strip features the downstream tree model could plausibly exploit, nor retain obvious redundancy\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: a cut so aggressive it likely discards interaction-only signal the tree\n could use; a correlation tie-break that leaks the label; meta-features exempted\n from selection; unstated thresholds; the final selection call did not commit\n v3_fs; any fit touching the test split.\n\nDO NOT BE TRIGGER-HAPPY. Enforce a revision ONLY when something is genuinely\nnon-optimal, clinically irrelevant, leaky, or incorrect. Do not fail a stage over\nstylistic preferences, defensible judgment calls, or choices you would merely\nhave made differently. On a true tie, approve. Do not invent problems.\n\nYour ENTIRE response is this verdict -- the FIRST character you output MUST be `{`.\nDo NOT write any preamble, plan, or meta-commentary about the task (e.g. \"this is\na review task...\", \"the reviewer should...\"); render the verdict itself, not a\ndescription of how you would review. Return EXACTLY ONE JSON object (followed only\nby the machine-readable verdict line described in the Rules) and nothing else:\n{\n \"stage\": \"fs\",\n \"approved\": true,\n \"criteria_results\": [\n {\"criterion\": \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
"enabled": true,
"max_revisions": 1
},
@@ -316,7 +316,7 @@
"data": {
"label": "",
"config": {
- "max_iterations": 15,
+ "max_iterations": 12,
"include_scratchpad": true,
"scratchpad_window": 10,
"observation_format": "raw"
@@ -378,7 +378,7 @@
"label": "MI-MLM",
"config": {
"prompt": "",
- "goal": "Emit an XGBoost hyperparameter search payload only -- no tools, no code.\n\nRandom seed: 42\n\ntask_brief: {\"domain\": \"acute myocardial infarction admissions, UCI Myocardial Infarction Complications (ZSN) dataset\", \"prediction_task\": \"binary\", \"target_column\": \"mi_ZSN\", \"positive_class\": 1, \"expected_positive_rate\": 0.232, \"selection_metric\": \"average_precision\", \"num_classes\": 2, \"group_column\": null, \"notes\": \"Predict development of chronic heart failure (ZSN) as a post-MI complication. Dataset: 1700 admissions x 113 raw columns (1 ID + 111 features + target mi_ZSN); target is binary (0=no ZSN, 1=ZSN), observed positive rate 23.2%. Column `ID` (role=id, values 1..1700, all unique) is a non-feature record identifier -- exclude it from the training matrix entirely; it must never be imputed, engineered, selected, or scored as a feature. Unlike some ASAREE datasets, features here are RAW, not pre-encoded: types are a mix of integer, real, binary, ordinal, and categorical, and several are known to have real missingness (this is the original UCI dataset, not a cleaned derivative) -- confirm actual missingness yourself via inspect_columns rather than assuming any column is complete. Column names are NOT descriptive English words; they are short Russian-derived codes (e.g. `nr11`, `np01`, `zab_leg_01`, `S_AD_KBRIG`) -- do not infer meaning from the name alone, always confirm via get_data_dictionary, which carries a description and, for categorical/ordinal columns, its code mapping. FEATURE FAMILIES: demographics (AGE, SEX); cardiac history (INF_ANAM prior-MI count, STENOK_AN/FK_STENOK angina, GB/SIM_GIPERT/DLIT_AG hypertension, ZSN_A prior heart failure, IBS_POST/IBS_NASL coronary disease, nr*/np* prior arrhythmia and conduction-block history); comorbidities (endocr_* diabetes/obesity/thyroid, zab_leg_* chronic pulmonary disease); admission vitals and ICU-admission complications (S_AD_KBRIG/D_AD_KBRIG/S_AD_ORIT/D_AD_ORIT blood pressure, O_L_POST/K_SH_POST/MP_TP_POST/SVT_POST/GT_POST/FIB_G_POST); infarct localization (ant_im/lat_im/inf_im/post_im/IM_PG_P); admission ECG rhythm and conduction findings (ritm_ecg_p_*, n_r_ecg_p_*, n_p_ecg_p_*); labs (K_BLOOD, Na_BLOOD, ALT_BLOOD, AST_BLOOD, KFK_BLOOD, L_BLOOD, ROE) with their derived abnormal-value flags (GIPO_K, GIPER_Na); and treatment given before/during ICU admission (fibr_ter_* fibrinolytics, NA_KB/NA_R_*_n opioids, NOT_NA_*_n/NOT_NA_KB NSAIDs, LID_KB/LID_S_n lidocaine, B_BLOK_S_n beta-blockers, ANT_CA_S_n calcium-channel blockers, GEPAR_S_n heparin, ASP_S_n aspirin, TIKL_S_n/TRENT_S_n antiplatelets, NITR_S nitrates). A per-column data dictionary (description + codes) is available via get_data_dictionary.\"}",
+ "goal": "Emit an XGBoost hyperparameter search payload only -- no tools, no code.\n\nRandom seed: 42\n\ntask_brief: {\"domain\": \"acute myocardial infarction admissions, UCI Myocardial Infarction Complications (ZSN) dataset\", \"prediction_task\": \"binary\", \"target_column\": \"mi_ZSN\", \"positive_class\": 1, \"expected_positive_rate\": 0.232, \"selection_metric\": \"average_precision\", \"num_classes\": 2, \"group_column\": null, \"notes\": \"Predict development of chronic heart failure (ZSN) as a post-MI complication. Dataset: 1700 admissions x 113 raw columns (1 ID + 111 features + target mi_ZSN); target is binary (0=no ZSN, 1=ZSN), observed positive rate 23.2%. Column `ID` (role=id, values 1..1700, all unique) is a non-feature record identifier -- exclude it from the training matrix entirely; it must never be imputed, engineered, selected, or scored as a feature. Unlike some ASAREE datasets, features here are RAW, not pre-encoded: types are a mix of integer, real, binary, ordinal, and categorical, and several are known to have real missingness (this is the original UCI dataset, not a cleaned derivative) -- confirm actual missingness yourself via inspect_columns rather than assuming any column is complete. Column names are NOT descriptive English words; they are short Russian-derived codes (e.g. `nr11`, `np01`, `zab_leg_01`, `S_AD_KBRIG`) -- do not infer meaning from the name alone, always confirm via get_data_dictionary, which carries a description and, for categorical/ordinal columns, its code mapping. FEATURE FAMILIES: demographics (AGE, SEX); cardiac history (INF_ANAM prior-MI count, STENOK_AN/FK_STENOK angina, GB/SIM_GIPERT/DLIT_AG hypertension, ZSN_A prior heart failure, IBS_POST/IBS_NASL coronary disease, nr*/np* prior arrhythmia and conduction-block history); comorbidities (endocr_* diabetes/obesity/thyroid, zab_leg_* chronic pulmonary disease); admission vitals and ICU-admission complications (S_AD_KBRIG/D_AD_KBRIG/S_AD_ORIT/D_AD_ORIT blood pressure, O_L_POST/K_SH_POST/MP_TP_POST/SVT_POST/GT_POST/FIB_G_POST); infarct localization (ant_im/lat_im/inf_im/post_im/IM_PG_P); admission ECG rhythm and conduction findings (ritm_ecg_p_*, n_r_ecg_p_*, n_p_ecg_p_*); labs (K_BLOOD, Na_BLOOD, ALT_BLOOD, AST_BLOOD, KFK_BLOOD, L_BLOOD, ROE) with their derived abnormal-value flags (GIPO_K, GIPER_Na); and treatment given before/during ICU admission (fibr_ter_* fibrinolytics, NA_KB/NA_R_*_n opioids, NOT_NA_*_n/NOT_NA_KB NSAIDs, LID_KB/LID_S_n lidocaine, B_BLOK_S_n beta-blockers, ANT_CA_S_n calcium-channel blockers, GEPAR_S_n heparin, ASP_S_n aspirin, TIKL_S_n/TRENT_S_n antiplatelets, NITR_S nitrates). A per-column data dictionary (description + codes) is available via get_data_dictionary.\"}\n\nPipeline brief (derived from the approved DC/FTE/FS stages; the FS fields arrive below, from the edge out of Critic (FS)):\n\nengineered_features: {{node:agent-fte.engineering_recipe[name]}}\nencodings: {{node:agent-fte.encoding_map[feature, encoding]}}\nnotes_from_dc: {{node:agent-dc.notes_for_fte}}",
"description": "Reads v3_fs's report and proposes a bounded hyperparameter search space plus fixed params for the deterministic downstream scoring script; never executes anything itself.",
"system_prompt": "\nCRITICAL -- READ FIRST:\nYour ENTIRE response is a single JSON object and nothing else. You are NOT writing\na modeling script. The harness already owns the one fixed script; you only choose\nits hyperparameters. If your output contains ANY code -- a ```python block,\n`import`, `def`, `class`, XGBClassifier(...), an Optuna objective, or a\nload/split/fit call -- it is discarded and your stage FAILS. Output only the typed\npayload described under OUTPUT FORMAT.\n\nYou are a HYPERPARAMETER PLANNER for biomedical tabular prediction. You do NOT\nwrite code. The harness owns a single fixed XGBoost modeling script; your only\njob is to choose its hyperparameter search and return it as ONE typed JSON\npayload. A reviewer gates your payload; if rejected you revise it. After approval\nthe fixed script runs with your payload bound as `hp`, on the training data only.\n\nWHAT IS FIXED BY THE HARNESS (you do NOT control these; do not fight them):\n- The model is XGBoost, objective branched on the task (binary:logistic here).\n- Inner tuning is 5-fold stratified cross-validation on the TRAINING data only,\n with a fixed global fold assignment. You never see or affect the held-out test.\n- The tuning objective is PR-AUC (average precision), fixed on clinical grounds\n for this imbalanced minority-class target.\n- scale_pos_weight is computed by the harness as n_neg/n_pos on the fitted rows.\n- Calibration (isotonic), the operating threshold (argmax F1 on a held-out\n training fold), and the Brier score are computed by the harness and are\n DESCRIPTIVE ONLY: isotonic is monotonic, so it does not change PR-AUC or\n ROC-AUC. Do not try to set them.\n- All seeds are fixed by the harness, so the run is deterministic given your\n payload.\n\nWHAT YOU CONTROL - the typed payload:\n- search_space: a list of at least 3 DISTINCT parameters to tune, each an object\n {\"param\": , \"low\": , \"high\": }. Require low < high and a\n range that lies inside the hard bounds below.\n- fixed_params: an object mapping every parameter you are NOT tuning to a single\n fixed value inside its hard bounds. Keys may ONLY be names drawn from the nine\n parameters listed below. NEVER include objective, eval_metric, tree_method,\n n_jobs, random_state/seed, scale_pos_weight, cv, calibration, or threshold --\n those are harness-fixed; any such key is silently DROPPED and the harness\n value stands (you never override them).\n- n_trials: an integer in [1, 100] - the Optuna trial budget.\n- rationale: a short string with one specific reason per non-default choice, tied\n to this roughly, imbalanced, PR-AUC-optimized target.\n\nCOMPLETENESS RULE: account for every one of the nine parameters below EXACTLY\nONCE -- either in search_space (tuned) or in fixed_params (held constant), and\nnever in both. Aim to omit none: an omitted parameter is not failed, but it\nsilently reverts to the fixed script's default and you lose all control of it, so\na complete payload is the only way to fully determine the model. The reviewer\nstill holds you to this even though it is no longer enforced by failure.\n\nTHE NINE XGBOOST PARAMETERS AND THEIR HARD BOUNDS (do not exceed):\n n_estimators integer [100, 1000]\n max_depth integer [3, 10]\n learning_rate float [1e-3, 0.3] (searched on a log scale)\n min_child_weight float [1, 20]\n gamma float [0, 5]\n subsample float [0.5, 1.0]\n colsample_bytree float [0.5, 1.0]\n reg_lambda float [1e-3, 10] (searched on a log scale)\n reg_alpha float [1e-3, 10] (searched on a log scale)\nThe harness applies the integer and log-scale handling; you only give low/high\nfor tuned params or a fixed value within the bounds. n_estimators and max_depth\ntake integer fixed values. The three log-scale params (learning_rate, reg_lambda,\nreg_alpha) must have BOTH low and high strictly positive (>= 1e-3): a low of 0 is\ninvalid on a log scale.\n\nRUN CONTEXT (informs your CHOICES; not bound to the script):\n- task_brief (target, prediction_task, selection_metric, num_classes, notes).\n- A derived pipeline brief: the final selected feature set, engineered features,\n encodings, observed class balance, and DC/FS caveats. Use the feature count and\n class balance to size the search (how deep, how much regularization, how many\n trials).\n\nMake the search purposeful: tune the parameters that matter most for an\nimbalanced tree model (commonly depth, learning_rate, and regularization or\nsubsampling), give them sensible ranges rather than the full hard bound on\neverything, fix the rest at defensible values, and pick n_trials appropriate to\nthe number of tuned dimensions. Do NOT collapse a tuned range to a single point.\n\nENFORCEMENT: a deterministic pre-flight check sanitizes your payload BEFORE it\nruns -- it does NOT fail the stage. Any suggestion that breaks a rule above -- an\nunknown or harness-fixed key (e.g. objective), a search entry missing \"param\" or\nkeyed \"name\", a range outside the hard bounds (including a log-scale low of 0),\nor a param in both blocks -- is DROPPED, and that parameter silently falls back\nto the fixed script's default, so you lose all control of it. n_trials is\nclamped into [1, 100]. A dropped suggestion means your intent is discarded and\nthe reviewer still sees the mistake -- so get every rule right the first time.\n\nOUTPUT FORMAT -- the FIRST character you output MUST be `{` and the LAST `}`.\nEmit one raw JSON object: no code fence, no prose, no preamble or plan. It is\nparsed verbatim as your payload. Example (structure only -- choose your own\nvalues, do NOT copy these):\n\n{\"search_space\": [{\"param\": \"max_depth\", \"low\": 3, \"high\": 8}, {\"param\": \"learning_rate\", \"low\": 0.01, \"high\": 0.3}, {\"param\": \"reg_lambda\", \"low\": 0.1, \"high\": 10.0}], \"fixed_params\": {\"n_estimators\": 500, \"min_child_weight\": 5.0, \"gamma\": 0.0, \"subsample\": 0.9, \"colsample_bytree\": 0.9, \"reg_alpha\": 0.001}, \"n_trials\": 40, \"rationale\": \"...\"}\n\nBEFORE YOU RESPOND, verify ALL of these (anything you get wrong is dropped and that param falls back to its default, so your intent is lost):\n1. Output is a single JSON object -- no code, no fence, no prose.\n2. Exactly four keys: search_space, fixed_params, n_trials, rationale.\n3. Each of the nine params appears EXACTLY once (search_space or fixed_params,\n never both, never omitted).\n4. Every low/high and every fixed value is INSIDE its hard bound -- re-check each\n against the table above (e.g. max_depth low >= 3 and high <= 10).\n5. n_trials is an integer in [1, 100].\n6. Every search_space entry is keyed \"param\" (never \"name\"), with numeric low\n and high; the log-scale params (learning_rate, reg_lambda, reg_alpha) keep\n low >= 1e-3.\n",
"output_contract": null,
@@ -402,7 +402,7 @@
"name": "mlm-critic",
"goal": "Review MI-MLM's proposed hyperparameter payload for this stage only.",
"description": "Shared Critic reviewing the MLM stage -- approves or requests revision with feedback and a partial/full rejection scope.",
- "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the MLM (hyperparameter planning) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no task_brief and no dynamically-injected checklist -- so this stage's own checklist is baked in below instead of arriving with each run:\n\n- the payload is ONE JSON object with exactly the keys search_space, fixed_params, n_trials, and rationale; it is not code and does not attempt to set any harness-fixed policy (CV folds, seeds, objective metric, calibration, threshold, or scale_pos_weight)\n- schema validity: search_space tunes at least 3 DISTINCT params drawn from the nine allowed XGBoost params, each with low < high and [low, high] inside that param's hard bounds; fixed_params values are inside their hard bounds; every one of the nine params appears exactly once across search_space and fixed_params (none unspecified, none in both); n_trials is an integer in [1, 100]\n- the choices are defensible for class-imbalanced target optimized on PR-AUC: the search is not degenerate (ranges not collapsed to a point, not the full hard bound on everything), depth and regularization are not pinned to values that force gross over- or under-fitting, and n_trials is adequate for the number of tuned dimensions\n- every non-default choice carries a specific one-line rationale tied to THIS imbalanced PR-AUC task, not a generic statement\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: the output is not a single valid JSON payload; search_space tunes fewer\n than 3 params or names a param outside the nine allowed; a range falls\n outside its hard bounds or has low >= high; a param is unspecified or appears\n in both search_space and fixed_params; n_trials outside [1, 100]; the agent\n tries to set harness-fixed policy (CV folds, seeds, objective metric,\n calibration, threshold, scale_pos_weight); a degenerate search (point ranges\n or full-bound-on-all) or rationales generic rather than tied to the\n imbalanced PR-AUC target.\n\nDO NOT BE TRIGGER-HAPPY. Enforce a revision ONLY when something is genuinely\nnon-optimal, clinically irrelevant, leaky, or incorrect. Do not fail a stage over\nstylistic preferences, defensible judgment calls, or choices you would merely\nhave made differently. On a true tie, approve. Do not invent problems.\n\nYour ENTIRE response is this verdict -- the FIRST character you output MUST be `{`.\nDo NOT write any preamble, plan, or meta-commentary about the task (e.g. \"this is\na review task...\", \"the reviewer should...\"); render the verdict itself, not a\ndescription of how you would review. Return EXACTLY ONE JSON object (followed only\nby the machine-readable verdict line described in the Rules) and nothing else:\n{\n \"stage\": \"mlm\",\n \"approved\": true,\n \"criteria_results\": [\n {\"criterion\": \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
+ "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the MLM (hyperparameter planning) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no dynamically-injected task_brief or checklist -- so the task_brief and this stage's own checklist are baked in below instead of arriving with each run.\n\ntask_brief: {\"domain\": \"acute myocardial infarction admissions, UCI Myocardial Infarction Complications (ZSN) dataset\", \"prediction_task\": \"binary\", \"target_column\": \"mi_ZSN\", \"positive_class\": 1, \"expected_positive_rate\": 0.232, \"selection_metric\": \"average_precision\", \"num_classes\": 2, \"group_column\": null, \"notes\": \"Predict development of chronic heart failure (ZSN) as a post-MI complication. Dataset: 1700 admissions x 113 raw columns (1 ID + 111 features + target mi_ZSN); target is binary (0=no ZSN, 1=ZSN), observed positive rate 23.2%. Column `ID` (role=id, values 1..1700, all unique) is a non-feature record identifier -- exclude it from the training matrix entirely; it must never be imputed, engineered, selected, or scored as a feature. Unlike some ASAREE datasets, features here are RAW, not pre-encoded: types are a mix of integer, real, binary, ordinal, and categorical, and several are known to have real missingness (this is the original UCI dataset, not a cleaned derivative) -- confirm actual missingness yourself via inspect_columns rather than assuming any column is complete. Column names are NOT descriptive English words; they are short Russian-derived codes (e.g. `nr11`, `np01`, `zab_leg_01`, `S_AD_KBRIG`) -- do not infer meaning from the name alone, always confirm via get_data_dictionary, which carries a description and, for categorical/ordinal columns, its code mapping. FEATURE FAMILIES: demographics (AGE, SEX); cardiac history (INF_ANAM prior-MI count, STENOK_AN/FK_STENOK angina, GB/SIM_GIPERT/DLIT_AG hypertension, ZSN_A prior heart failure, IBS_POST/IBS_NASL coronary disease, nr*/np* prior arrhythmia and conduction-block history); comorbidities (endocr_* diabetes/obesity/thyroid, zab_leg_* chronic pulmonary disease); admission vitals and ICU-admission complications (S_AD_KBRIG/D_AD_KBRIG/S_AD_ORIT/D_AD_ORIT blood pressure, O_L_POST/K_SH_POST/MP_TP_POST/SVT_POST/GT_POST/FIB_G_POST); infarct localization (ant_im/lat_im/inf_im/post_im/IM_PG_P); admission ECG rhythm and conduction findings (ritm_ecg_p_*, n_r_ecg_p_*, n_p_ecg_p_*); labs (K_BLOOD, Na_BLOOD, ALT_BLOOD, AST_BLOOD, KFK_BLOOD, L_BLOOD, ROE) with their derived abnormal-value flags (GIPO_K, GIPER_Na); and treatment given before/during ICU admission (fibr_ter_* fibrinolytics, NA_KB/NA_R_*_n opioids, NOT_NA_*_n/NOT_NA_KB NSAIDs, LID_KB/LID_S_n lidocaine, B_BLOK_S_n beta-blockers, ANT_CA_S_n calcium-channel blockers, GEPAR_S_n heparin, ASP_S_n aspirin, TIKL_S_n/TRENT_S_n antiplatelets, NITR_S nitrates). A per-column data dictionary (description + codes) is available via get_data_dictionary.\"}\n\nChecklist:\n\n- the payload is ONE JSON object with exactly the keys search_space, fixed_params, n_trials, and rationale; it is not code and does not attempt to set any harness-fixed policy (CV folds, seeds, objective metric, calibration, threshold, or scale_pos_weight)\n- schema validity: search_space tunes at least 3 DISTINCT params drawn from the nine allowed XGBoost params, each with low < high and [low, high] inside that param's hard bounds; fixed_params values are inside their hard bounds; every one of the nine params appears exactly once across search_space and fixed_params (none unspecified, none in both); n_trials is an integer in [1, 100]\n- the choices are defensible for class-imbalanced target optimized on PR-AUC: the search is not degenerate (ranges not collapsed to a point, not the full hard bound on everything), depth and regularization are not pinned to values that force gross over- or under-fitting, and n_trials is adequate for the number of tuned dimensions\n- every non-default choice carries a specific one-line rationale tied to THIS imbalanced PR-AUC task, not a generic statement\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: the output is not a single valid JSON payload; search_space tunes fewer\n than 3 params or names a param outside the nine allowed; a range falls\n outside its hard bounds or has low >= high; a param is unspecified or appears\n in both search_space and fixed_params; n_trials outside [1, 100]; the agent\n tries to set harness-fixed policy (CV folds, seeds, objective metric,\n calibration, threshold, scale_pos_weight); a degenerate search (point ranges\n or full-bound-on-all) or rationales generic rather than tied to the\n imbalanced PR-AUC target.\n\nDO NOT BE TRIGGER-HAPPY. Enforce a revision ONLY when something is genuinely\nnon-optimal, clinically irrelevant, leaky, or incorrect. Do not fail a stage over\nstylistic preferences, defensible judgment calls, or choices you would merely\nhave made differently. On a true tie, approve. Do not invent problems.\n\nYour ENTIRE response is this verdict -- the FIRST character you output MUST be `{`.\nDo NOT write any preamble, plan, or meta-commentary about the task (e.g. \"this is\na review task...\", \"the reviewer should...\"); render the verdict itself, not a\ndescription of how you would review. Return EXACTLY ONE JSON object (followed only\nby the machine-readable verdict line described in the Rules) and nothing else:\n{\n \"stage\": \"mlm\",\n \"approved\": true,\n \"criteria_results\": [\n {\"criterion\": \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
"enabled": true,
"max_revisions": 1
},
@@ -427,51 +427,11 @@
"factor_bindings": {}
}
},
- {
- "id": "agent-score",
- "type": "agent",
- "position": {
- "x": 2240,
- "y": 0
- },
- "data": {
- "label": "MI-Score",
- "config": {
- "prompt": "",
- "goal": "Score MI-MLM's approved hyperparameter payload against the held-out test split. Call run_model_script with NO `code` argument -- the wired Script node reaches the tool as a path in ambient run context and executes byte-for-byte as written; retyping it is the one thing that could corrupt it. Pass only payload_json, set to the approved payload (as compact JSON) from the upstream context -- do not alter it. Report the returned test_metrics and the code_sha256/payload_sha256 guards verbatim; never compute or estimate a metric yourself.",
- "description": "Runs the harness-fixed XGBoost+Optuna scoring script against MI-MLM's approved payload via run_model_script (the script arrives as an ambient path, never pasted into the prompt) and reports the held-out test metrics. Unlike the real notebook (a direct, non-agent call with SHA guards), this is an ASAREE agent so the canvas can demonstrate the Script node end-to-end; the same SHA guards are still enforced server-side and reported back verbatim.",
- "system_prompt": "",
- "output_contract": null,
- "budget_limit_usd": 200.0,
- "max_run_duration_seconds": 86400,
- "require_output_parser": true
- },
- "factor_bindings": {}
- }
- },
- {
- "id": "pattern-score",
- "type": "pattern_reason_act",
- "position": {
- "x": 2248,
- "y": -220
- },
- "data": {
- "label": "",
- "config": {
- "max_iterations": 15,
- "include_scratchpad": true,
- "scratchpad_window": 10,
- "observation_format": "raw"
- },
- "factor_bindings": {}
- }
- },
{
"id": "tool-model-score",
"type": "mcp_tool",
"position": {
- "x": 2314,
+ "x": 2354,
"y": 240
},
"data": {
@@ -491,7 +451,7 @@
"id": "script-model",
"type": "script",
"position": {
- "x": 2494,
+ "x": 2534,
"y": 240
},
"data": {
@@ -499,7 +459,7 @@
"config": {
"name": "fixed_model_script",
"language": "python",
- "code": "CV_FOLDS = 5\nCV_FOLD_SEED = 20260701\nOPTUNA_SAMPLER_SEED = 20260702\nXGB_SEED = 20260703\nVAL_SPLIT_SEED = 20260704\nVAL_FRACTION = 0.15\n\n# ==================================================================\n# ASAREE fixed modeling script (harness-owned; the MLM agent does NOT\n# write this). The agent supplies only the typed payload, bound as hp.\n# Everything here is deterministic given hp and the seeds baked above,\n# so replicate variance reflects LLM stochasticity in the payload only.\n#\n# Pre-bound by run_model_script: hp, X_train, y_train, np, pd. The test\n# split is NEVER bound; this script defines predict_proba(X) and\n# chosen_threshold, and the runner scores the held-out 30 itself.\n#\n# Harness-fixed policy (not agent-controllable):\n# inner CV 5-fold StratifiedKFold, fixed global fold assignment\n# objective PR-AUC (average_precision) on the training folds only\n# pos weighting scale_pos_weight = n_neg/n_pos on the fitted rows\n# calibration isotonic on one held-out training val fold\n# threshold argmax-F1 on the val fold; Brier reported on the val fold\n# no early stopping\n# Collaborator note: isotonic is monotonic, so calibration cannot change\n# PR-AUC or ROC-AUC (both rank-based); it only moves Brier and the\n# threshold-dependent secondaries, none of which is a tested outcome. The\n# scored model is fit on train-minus-val, so its PR-AUC/ROC-AUC equal the\n# base model ranking and the descriptive-only claim holds exactly.\n# ==================================================================\nimport optuna\nfrom optuna.samplers import TPESampler\nfrom sklearn.model_selection import StratifiedKFold, train_test_split\nfrom sklearn.metrics import average_precision_score, f1_score, brier_score_loss\nfrom sklearn.isotonic import IsotonicRegression\nfrom xgboost import XGBClassifier\n\noptuna.logging.set_verbosity(optuna.logging.WARNING)\n\n_INT_PARAMS = {\"n_estimators\", \"max_depth\"}\n_LOG_PARAMS = {\"learning_rate\", \"reg_lambda\", \"reg_alpha\"}\n\n_search = {}\nfor _e in hp[\"search_space\"]:\n _search[_e[\"param\"]] = (float(_e[\"low\"]), float(_e[\"high\"]))\n_fixed = dict(hp[\"fixed_params\"])\nfor _p in _INT_PARAMS:\n if _p in _fixed:\n _fixed[_p] = int(round(float(_fixed[_p])))\n_n_trials = int(hp[\"n_trials\"])\n\n_Xtr = X_train.reset_index(drop=True)\n_ytr = np.asarray(y_train).astype(int).ravel()\n\n\ndef _spw(y):\n pos = int((y == 1).sum())\n neg = int((y == 0).sum())\n return (neg / pos) if pos > 0 else 1.0\n\n\ndef _suggest(trial):\n params = dict(_fixed)\n for name, bounds in _search.items():\n lo, hi = bounds\n if name in _INT_PARAMS:\n params[name] = trial.suggest_int(name, int(round(lo)), int(round(hi)))\n elif name in _LOG_PARAMS:\n params[name] = trial.suggest_float(name, lo, hi, log=True)\n else:\n params[name] = trial.suggest_float(name, lo, hi)\n return params\n\n\ndef _make_model(params, spw):\n return XGBClassifier(\n objective=\"binary:logistic\",\n eval_metric=\"logloss\",\n tree_method=\"hist\",\n n_jobs=1,\n random_state=XGB_SEED,\n scale_pos_weight=spw,\n **params,\n )\n\n\n_cv = StratifiedKFold(n_splits=CV_FOLDS, shuffle=True, random_state=CV_FOLD_SEED)\n\n\ndef _objective(trial):\n params = _suggest(trial)\n scores = []\n for tr_idx, va_idx in _cv.split(_Xtr, _ytr):\n Xt = _Xtr.iloc[tr_idx]\n Xv = _Xtr.iloc[va_idx]\n yt = _ytr[tr_idx]\n yv = _ytr[va_idx]\n model = _make_model(params, _spw(yt))\n model.fit(Xt, yt)\n proba = model.predict_proba(Xv)[:, 1]\n scores.append(average_precision_score(yv, proba))\n return float(np.mean(scores))\n\n\n_study = optuna.create_study(direction=\"maximize\", sampler=TPESampler(seed=OPTUNA_SAMPLER_SEED))\n_study.optimize(_objective, n_trials=_n_trials, show_progress_bar=False)\n\n_best = dict(_fixed)\n_best.update(_study.best_params)\nfor _p in _INT_PARAMS:\n if _p in _best:\n _best[_p] = int(round(float(_best[_p])))\n\n_idx = np.arange(len(_Xtr))\n_fit_idx, _val_idx = train_test_split(\n _idx, test_size=VAL_FRACTION, stratify=_ytr, random_state=VAL_SPLIT_SEED\n)\n_Xfit = _Xtr.iloc[_fit_idx]\n_Xval = _Xtr.iloc[_val_idx]\n_yfit = _ytr[_fit_idx]\n_yval = _ytr[_val_idx]\n\n_base = _make_model(_best, _spw(_yfit))\n_base.fit(_Xfit, _yfit)\n\n_val_raw = _base.predict_proba(_Xval)[:, 1]\n_iso = IsotonicRegression(out_of_bounds=\"clip\")\n_iso.fit(_val_raw, _yval)\n\n\ndef predict_proba(X):\n raw = _base.predict_proba(X)[:, 1]\n return _iso.predict(raw)\n\n\n_val_cal = _iso.predict(_val_raw)\n_grid = np.unique(np.concatenate([np.array([0.0, 1.0]), _val_cal]))\n_best_t, _best_f1 = 0.5, -1.0\nfor _t in _grid:\n _f1 = f1_score(_yval, (_val_cal >= _t).astype(int), zero_division=0)\n if _f1 > _best_f1:\n _best_f1, _best_t = _f1, float(_t)\nchosen_threshold = float(_best_t)\n\n_check = predict_proba(_Xtr.head(5))\nassert _check.shape[0] == 5\n\nresult = {\n \"best_hyperparameters\": {k: (int(v) if k in _INT_PARAMS else float(v)) for k, v in _best.items()},\n \"tuned_params\": sorted(_search.keys()),\n \"n_trials\": _n_trials,\n \"optuna_best_value\": float(_study.best_value),\n \"cv_metric\": \"average_precision\",\n \"cv_folds\": CV_FOLDS,\n \"calibration\": \"isotonic\",\n \"calibration_brier_val\": float(brier_score_loss(_yval, _val_cal)),\n \"val_fraction\": VAL_FRACTION,\n \"chosen_threshold\": chosen_threshold,\n \"chosen_threshold_val_f1\": float(_best_f1),\n \"scale_pos_weight_fit\": float(_spw(_yfit)),\n \"n_train_fit\": int(len(_fit_idx)),\n \"n_val\": int(len(_val_idx)),\n}\n"
+ "code": "CV_FOLDS = 5\nCV_FOLD_SEED = 20260701\nOPTUNA_SAMPLER_SEED = 20260702\nXGB_SEED = 20260703\nVAL_SPLIT_SEED = 20260704\nVAL_FRACTION = 0.15\n\n# ==================================================================\n# ASAREE fixed modeling script (harness-owned; the MLM agent does NOT\n# write this). The agent supplies only the typed payload, bound as hp.\n# Everything here is deterministic given hp and the seeds baked above,\n# so replicate variance reflects LLM stochasticity in the payload only.\n#\n# Pre-bound by run_model_script: hp, X_train, y_train, np, pd. The test\n# split is NEVER bound; this script defines predict_proba(X) and\n# chosen_threshold, and the runner scores the held-out 30 itself.\n#\n# Harness-fixed policy (not agent-controllable):\n# inner CV 5-fold StratifiedKFold, fixed global fold assignment\n# objective PR-AUC (average_precision) on the training folds only\n# pos weighting scale_pos_weight = n_neg/n_pos on the fitted rows\n# calibration isotonic on one held-out training val fold\n# threshold argmax-F1 on the val fold; Brier reported on the val fold\n# no early stopping\n# Collaborator note: isotonic is monotonic, so calibration cannot change\n# PR-AUC or ROC-AUC (both rank-based); it only moves Brier and the\n# threshold-dependent secondaries, none of which is a tested outcome. The\n# scored model is fit on train-minus-val, so its PR-AUC/ROC-AUC equal the\n# base model ranking and the descriptive-only claim holds exactly.\n# ==================================================================\nimport optuna\nfrom optuna.samplers import TPESampler\nfrom sklearn.model_selection import StratifiedKFold, train_test_split\nfrom sklearn.metrics import average_precision_score, f1_score, brier_score_loss\nfrom sklearn.isotonic import IsotonicRegression\nfrom xgboost import XGBClassifier\n\noptuna.logging.set_verbosity(optuna.logging.WARNING)\n\n_INT_PARAMS = {\"n_estimators\", \"max_depth\"}\n_LOG_PARAMS = {\"learning_rate\", \"reg_lambda\", \"reg_alpha\"}\n\n_search = {}\nfor _e in hp[\"search_space\"]:\n _search[_e[\"param\"]] = (float(_e[\"low\"]), float(_e[\"high\"]))\n_fixed = {\n \"n_estimators\": 100,\n \"max_depth\": 6,\n \"learning_rate\": 0.3,\n \"min_child_weight\": 1.0,\n \"gamma\": 0.0,\n \"subsample\": 1.0,\n \"colsample_bytree\": 1.0,\n \"reg_lambda\": 1.0,\n \"reg_alpha\": 0.0,\n}\n_fixed.update(hp[\"fixed_params\"])\nfor _p in _INT_PARAMS:\n if _p in _fixed:\n _fixed[_p] = int(round(float(_fixed[_p])))\n_n_trials = int(hp[\"n_trials\"])\n\n_Xtr = X_train.reset_index(drop=True)\n_ytr = np.asarray(y_train).astype(int).ravel()\n\n\ndef _spw(y):\n pos = int((y == 1).sum())\n neg = int((y == 0).sum())\n return (neg / pos) if pos > 0 else 1.0\n\n\ndef _suggest(trial):\n params = dict(_fixed)\n for name, bounds in _search.items():\n lo, hi = bounds\n if name in _INT_PARAMS:\n params[name] = trial.suggest_int(name, int(round(lo)), int(round(hi)))\n elif name in _LOG_PARAMS:\n params[name] = trial.suggest_float(name, lo, hi, log=True)\n else:\n params[name] = trial.suggest_float(name, lo, hi)\n return params\n\n\ndef _make_model(params, spw):\n return XGBClassifier(\n objective=\"binary:logistic\",\n eval_metric=\"logloss\",\n tree_method=\"hist\",\n n_jobs=1,\n random_state=XGB_SEED,\n scale_pos_weight=spw,\n **params,\n )\n\n\n_cv = StratifiedKFold(n_splits=CV_FOLDS, shuffle=True, random_state=CV_FOLD_SEED)\n\n\ndef _objective(trial):\n params = _suggest(trial)\n scores = []\n for tr_idx, va_idx in _cv.split(_Xtr, _ytr):\n Xt = _Xtr.iloc[tr_idx]\n Xv = _Xtr.iloc[va_idx]\n yt = _ytr[tr_idx]\n yv = _ytr[va_idx]\n model = _make_model(params, _spw(yt))\n model.fit(Xt, yt)\n proba = model.predict_proba(Xv)[:, 1]\n scores.append(average_precision_score(yv, proba))\n return float(np.mean(scores))\n\n\n_study = optuna.create_study(direction=\"maximize\", sampler=TPESampler(seed=OPTUNA_SAMPLER_SEED))\n_study.optimize(_objective, n_trials=_n_trials, show_progress_bar=False)\n\n_best = dict(_fixed)\n_best.update(_study.best_params)\nfor _p in _INT_PARAMS:\n if _p in _best:\n _best[_p] = int(round(float(_best[_p])))\n\n_idx = np.arange(len(_Xtr))\n_fit_idx, _val_idx = train_test_split(\n _idx, test_size=VAL_FRACTION, stratify=_ytr, random_state=VAL_SPLIT_SEED\n)\n_Xfit = _Xtr.iloc[_fit_idx]\n_Xval = _Xtr.iloc[_val_idx]\n_yfit = _ytr[_fit_idx]\n_yval = _ytr[_val_idx]\n\n_base = _make_model(_best, _spw(_yfit))\n_base.fit(_Xfit, _yfit)\n\n_val_raw = _base.predict_proba(_Xval)[:, 1]\n_iso = IsotonicRegression(out_of_bounds=\"clip\")\n_iso.fit(_val_raw, _yval)\n\n\ndef predict_proba(X):\n raw = _base.predict_proba(X)[:, 1]\n return _iso.predict(raw)\n\n\n_val_cal = _iso.predict(_val_raw)\n_grid = np.unique(np.concatenate([np.array([0.0, 1.0]), _val_cal]))\n_best_t, _best_f1 = 0.5, -1.0\nfor _t in _grid:\n _f1 = f1_score(_yval, (_val_cal >= _t).astype(int), zero_division=0)\n if _f1 > _best_f1:\n _best_f1, _best_t = _f1, float(_t)\nchosen_threshold = float(_best_t)\n\n_check = predict_proba(_Xtr.head(5))\nassert _check.shape[0] == 5\n\nresult = {\n \"best_hyperparameters\": {k: (int(v) if k in _INT_PARAMS else float(v)) for k, v in _best.items()},\n \"tuned_params\": sorted(_search.keys()),\n \"n_trials\": _n_trials,\n \"optuna_best_value\": float(_study.best_value),\n \"cv_metric\": \"average_precision\",\n \"cv_folds\": CV_FOLDS,\n \"calibration\": \"isotonic\",\n \"calibration_brier_val\": float(brier_score_loss(_yval, _val_cal)),\n \"val_fraction\": VAL_FRACTION,\n \"chosen_threshold\": chosen_threshold,\n \"chosen_threshold_val_f1\": float(_best_f1),\n \"scale_pos_weight_fit\": float(_spw(_yfit)),\n \"n_train_fit\": int(len(_fit_idx)),\n \"n_val\": int(len(_val_idx)),\n}\n"
},
"factor_bindings": {}
}
@@ -535,7 +495,7 @@
{
"name": "domain_violations",
"type": "array",
- "description": "Domain-rule violations found and corrected"
+ "description": "list of {feature, rule, n_coerced}"
},
{
"name": "missingness_threshold",
@@ -545,17 +505,17 @@
{
"name": "dropped_for_missingness",
"type": "array",
- "description": "Columns dropped for exceeding the missingness threshold"
+ "description": "list of [feature, raw_missing_pct]"
},
{
"name": "outliers",
"type": "array",
- "description": "Outlier handling summary per column"
+ "description": "list of {feature, method, n_flagged, n_deleted, reason}"
},
{
"name": "imputation",
"type": "array",
- "description": "Imputation strategy used per column"
+ "description": "list of {feature_or_group, dtype, strategy, reason}"
},
{
"name": "notes_for_fte",
@@ -585,17 +545,17 @@
{
"name": "engineering_recipe",
"type": "array",
- "description": "Feature-engineering steps applied"
+ "description": "list of {name, op, inputs, params, rationale, components_retained}"
},
{
"name": "encoding_map",
- "type": "object",
- "description": "Categorical encoding scheme per column"
+ "type": "array",
+ "description": "list of {feature, encoding, order_or_bins, reason}"
},
{
"name": "n_features_out",
"type": "integer",
- "description": "Feature count after engineering/encoding"
+ "description": "number of columns in the committed v2_fte matrix"
},
{
"name": "notes_for_fs",
@@ -632,16 +592,6 @@
"type": "integer",
"description": "Feature count after selection"
},
- {
- "name": "n_engineered_features_selected",
- "type": "integer",
- "description": "Selected features created by the upstream engineering recipe"
- },
- {
- "name": "pct_engineered_features_selected",
- "type": "number",
- "description": "Percentage of selected features created by the upstream engineering recipe"
- },
{
"name": "variance_threshold",
"type": "number",
@@ -660,7 +610,7 @@
{
"name": "dropped_features",
"type": "array",
- "description": "Features dropped during selection"
+ "description": "list of {feature, reason}"
},
{
"name": "selected_features",
@@ -705,22 +655,22 @@
{
"name": "search_space",
"type": "array",
- "description": "Hyperparameter search space proposal"
+ "description": "list of {param, low, high}: the XGBoost params to tune and their bounds; at least 3 distinct params"
},
{
"name": "fixed_params",
"type": "object",
- "description": "Fixed (non-searched) XGBoost params"
+ "description": "map of param -> constant value for every param NOT in search_space"
},
{
"name": "n_trials",
"type": "integer",
- "description": "Proposed Optuna trial count"
+ "description": "number of Optuna trials to run, 1-100"
},
{
"name": "rationale",
"type": "string",
- "description": "Rationale for the proposed search space"
+ "description": "one specific reason per non-default search_space/fixed_params choice, tied to the imbalanced PR-AUC task"
}
]
},
@@ -730,43 +680,58 @@
}
},
{
- "id": "output-parser-score",
- "type": "output_parser",
+ "id": "tool-step-score",
+ "type": "tool_step",
"position": {
- "x": 2476,
- "y": 160
+ "x": 2240,
+ "y": 0
},
"data": {
- "label": "MI-Score Output Parser",
+ "label": "Score (run_model_script)",
+ "active": true,
"config": {
- "output_contract": {
- "name": "ScoreReport",
- "fields": [
- {
- "name": "test_metrics",
- "type": "object",
- "description": "Held-out test metrics computed server-side"
- },
- {
- "name": "code_sha256",
- "type": "string",
- "description": "Hash of the executed script, for the verbatim-execution guard"
- },
- {
- "name": "payload_sha256",
- "type": "string",
- "description": "Hash of the scored payload, for the approved-payload guard"
- },
- {
- "name": "model_decisions",
- "type": "object",
- "description": "Train-side decisions the script itself reported (tuning, calibration, threshold)"
- }
- ]
+ "tool_name": "run_model_script",
+ "arguments": {
+ "code": {
+ "source": "script_code"
+ },
+ "payload_json": {
+ "source": "upstream_payload",
+ "format": "json_string"
+ },
+ "workspace_id": {
+ "source": "workspace_id"
+ },
+ "random_seed": {
+ "source": "value",
+ "value": 20260705
+ },
+ "task_type": {
+ "source": "value",
+ "value": "binary"
+ },
+ "positive_label": {
+ "source": "value",
+ "value": "1"
+ },
+ "selection_metric": {
+ "source": "value",
+ "value": "average_precision"
+ },
+ "param_spec_json": {
+ "source": "value",
+ "value": "{\"n_estimators\":{\"type\":\"int\",\"log\":false,\"low\":100,\"high\":1000},\"max_depth\":{\"type\":\"int\",\"log\":false,\"low\":3,\"high\":10},\"learning_rate\":{\"type\":\"float\",\"log\":true,\"low\":0.001,\"high\":0.3},\"min_child_weight\":{\"type\":\"float\",\"log\":false,\"low\":1,\"high\":20},\"gamma\":{\"type\":\"float\",\"log\":false,\"low\":0,\"high\":5},\"subsample\":{\"type\":\"float\",\"log\":false,\"low\":0.5,\"high\":1.0},\"colsample_bytree\":{\"type\":\"float\",\"log\":false,\"low\":0.5,\"high\":1.0},\"reg_lambda\":{\"type\":\"float\",\"log\":true,\"low\":0.001,\"high\":10},\"reg_alpha\":{\"type\":\"float\",\"log\":true,\"low\":0.001,\"high\":10}}"
+ },
+ "default_n_trials": {
+ "source": "value",
+ "value": 30
+ }
},
- "enabled": true
- },
- "factor_bindings": {}
+ "hash_checks": {
+ "code_sha256": "code",
+ "payload_sha256": "payload_json"
+ }
+ }
}
}
],
@@ -799,7 +764,19 @@
{
"id": "e-gatefs-to-mlm",
"source": "gate-fs",
- "target": "agent-mlm"
+ "target": "agent-mlm",
+ "data": {
+ "handoff": {
+ "mode": "selected",
+ "fields": [
+ {"name": "selected_features"},
+ {"name": "n_features_out"},
+ {"name": "observed_class_distribution"},
+ {"name": "class_balance_check"},
+ {"name": "notes_for_mlm"}
+ ]
+ }
+ }
},
{
"id": "e-mlm-to-gatemlm",
@@ -953,39 +930,6 @@
"sourceHandle": "dataset",
"targetHandle": "dataset"
},
- {
- "id": "e-gatemlm-to-score",
- "source": "gate-mlm",
- "target": "agent-score"
- },
- {
- "id": "e-llm-score",
- "source": "llm-shared",
- "target": "agent-score",
- "sourceHandle": "model",
- "targetHandle": "model"
- },
- {
- "id": "e-pattern-score",
- "source": "pattern-score",
- "target": "agent-score",
- "sourceHandle": "architectural_pattern",
- "targetHandle": "architectural_pattern"
- },
- {
- "id": "e-tool-model-score",
- "source": "tool-model-score",
- "target": "agent-score",
- "sourceHandle": "tool",
- "targetHandle": "tool"
- },
- {
- "id": "e-script-model-score",
- "source": "script-model",
- "target": "agent-score",
- "sourceHandle": "tool",
- "targetHandle": "tool"
- },
{
"id": "e-output-parser-dc",
"source": "output-parser-dc",
@@ -1015,11 +959,23 @@
"targetHandle": "output_parser"
},
{
- "id": "e-output-parser-score",
- "source": "output-parser-score",
- "target": "agent-score",
- "sourceHandle": "output_parser",
- "targetHandle": "output_parser"
+ "id": "e-gatemlm-to-score",
+ "source": "gate-mlm",
+ "target": "tool-step-score"
+ },
+ {
+ "id": "e-tool-model-score",
+ "source": "tool-model-score",
+ "target": "tool-step-score",
+ "sourceHandle": "tool",
+ "targetHandle": "tool"
+ },
+ {
+ "id": "e-script-model-score",
+ "source": "script-model",
+ "target": "tool-step-score",
+ "sourceHandle": "tool",
+ "targetHandle": "tool"
}
]
},
@@ -1184,16 +1140,6 @@
"aggregation": "sum",
"unit": "reviews"
},
- {
- "id": "model-evaluation",
- "name": "model_evaluation",
- "description": "Complete held-out scoring response from run_model_script.",
- "kind": "custom",
- "valueType": "opaque",
- "primary": false,
- "direction": "neutral",
- "aggregation": "none"
- },
{
"id": "pr-auc",
"name": "pr_auc",
@@ -1508,6 +1454,17 @@
"aggregation": "mean",
"unit": "features"
},
+ {
+ "id": "n-features-created",
+ "name": "n_features_created",
+ "description": "Features created by FTE: n_features_after_fte - n_features_after_dc, computed from the stage payloads.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "neutral",
+ "aggregation": "mean",
+ "unit": "features"
+ },
{
"id": "n-features-after-fs",
"name": "n_features_after_fs",
@@ -1522,7 +1479,7 @@
{
"id": "n-engineered-features-selected",
"name": "n_engineered_features_selected",
- "description": "Engineered features surviving feature selection.",
+ "description": "Selected features that FTE created (not raw columns), computed from FS's selected_features.",
"kind": "custom",
"valueType": "number",
"primary": false,
@@ -1531,15 +1488,15 @@
"unit": "features"
},
{
- "id": "pct-engineered-features-selected",
- "name": "pct_engineered_features_selected",
- "description": "Engineered features as a percentage of selected features.",
+ "id": "frac-created-selected",
+ "name": "frac_created_selected",
+ "description": "Fraction of selected features that FTE created (not raw columns), computed from FS's selected_features.",
"kind": "custom",
"valueType": "number",
"primary": false,
"direction": "neutral",
"aggregation": "mean",
- "unit": "percent"
+ "unit": "fraction"
},
{
"id": "variance-threshold",
@@ -1713,127 +1670,400 @@
"primary": false,
"direction": "neutral",
"aggregation": "none"
- }
- ],
- "coordination_strategy": {
- "slug": "critic_gate"
- }
- },
- "measurement_plan": {
- "metrics": [
+ },
{
- "id": "runtime-cost",
- "name": "Cost",
- "value_type": "number",
+ "id": "n-schema-violations",
+ "name": "n_schema_violations",
+ "description": "MLM payload entries run_model_script dropped against the Tool Step's param_spec_json before fitting (0 = the payload was already clean).",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
"direction": "minimize",
- "aggregation": "sum",
+ "aggregation": "mean",
+ "unit": "entries"
+ },
+ {
+ "id": "threshold-prevalence",
+ "name": "threshold_prevalence",
+ "description": "Decision threshold equal to the observed test-set positive rate.",
+ "kind": "custom",
+ "valueType": "number",
"primary": false,
- "description": "Estimated provider cost for the run.",
- "unit": "USD"
+ "direction": "neutral",
+ "aggregation": "mean"
},
{
- "id": "runtime-duration",
- "name": "Duration",
- "value_type": "number",
- "direction": "minimize",
- "aggregation": "sum",
+ "id": "runtime-critic-invocations",
+ "catalogKey": "critic_invocations",
+ "name": "Critic invocations",
+ "description": "Critic-gate reviews run during the run.",
+ "kind": "runtime",
+ "valueType": "number",
"primary": false,
- "description": "Wall-clock duration of the run.",
- "unit": "seconds"
+ "direction": "neutral",
+ "aggregation": "sum",
+ "unit": "reviews"
},
{
- "id": "runtime-input-tokens",
- "name": "Input tokens",
- "value_type": "number",
+ "id": "runtime-critic-rejections-partial",
+ "catalogKey": "critic_rejections_partial",
+ "name": "Partial critic rejections",
+ "description": "Rejections scoped to a targeted correction.",
+ "kind": "runtime",
+ "valueType": "number",
+ "primary": false,
"direction": "minimize",
"aggregation": "sum",
- "primary": false,
- "description": "Tokens sent to the model during the run.",
- "unit": "tokens"
+ "unit": "reviews"
},
{
- "id": "runtime-output-tokens",
- "name": "Output tokens",
- "value_type": "number",
+ "id": "runtime-critic-rejections-full",
+ "catalogKey": "critic_rejections_full",
+ "name": "Full critic rejections",
+ "description": "Rejections asking the stage to reconsider its approach (includes unscoped ones).",
+ "kind": "runtime",
+ "valueType": "number",
+ "primary": false,
"direction": "minimize",
"aggregation": "sum",
- "primary": false,
- "description": "Tokens generated by the model during the run.",
- "unit": "tokens"
+ "unit": "reviews"
},
{
- "id": "runtime-total-tokens",
- "name": "Total tokens",
- "value_type": "number",
+ "id": "runtime-revision-rounds",
+ "catalogKey": "revision_rounds",
+ "name": "Revision rounds",
+ "description": "Worker reruns triggered by critic rejections.",
+ "kind": "runtime",
+ "valueType": "number",
+ "primary": false,
"direction": "minimize",
"aggregation": "sum",
- "primary": false,
- "description": "Combined input and output tokens for the run.",
- "unit": "tokens"
+ "unit": "rounds"
},
{
- "id": "runtime-tool-calls",
- "name": "Tool calls",
- "value_type": "number",
+ "id": "runtime-capped-agent-runs",
+ "catalogKey": "capped_agent_runs",
+ "name": "Capped agent runs",
+ "description": "Reason+Act runs cut off by their iteration ceiling.",
+ "kind": "runtime",
+ "valueType": "number",
+ "primary": false,
"direction": "minimize",
"aggregation": "sum",
- "primary": false,
- "description": "Recorded tool-call attempts across attributed Agent runs.",
- "unit": "calls"
+ "unit": "runs"
},
{
- "id": "runtime-tool-error-rate",
- "name": "Tool error rate",
- "value_type": "number",
- "direction": "minimize",
- "aggregation": "mean",
+ "id": "runtime-agent-runs",
+ "catalogKey": "agent_runs",
+ "name": "Agent runs",
+ "description": "Every agent and critic run the attempt launched, revisions included.",
+ "kind": "runtime",
+ "valueType": "number",
"primary": false,
- "description": "Failed tool calls divided by all tool-call attempts.",
- "unit": "rate"
+ "direction": "neutral",
+ "aggregation": "sum",
+ "unit": "runs"
},
{
- "id": "runtime-agent-loop-iterations",
- "name": "Agent-loop iterations",
- "value_type": "number",
- "direction": "minimize",
- "aggregation": "sum",
+ "id": "runtime-react-runs",
+ "catalogKey": "react_runs",
+ "name": "Reason+Act runs",
+ "description": "Runs that went through a Reason+Act loop.",
+ "kind": "runtime",
+ "valueType": "number",
"primary": false,
- "description": "Distinct Agent reasoning-loop iterations in the run.",
- "unit": "iterations"
+ "direction": "neutral",
+ "aggregation": "sum",
+ "unit": "runs"
},
{
- "id": "runtime-critic-rejections",
- "name": "Critic rejections",
- "value_type": "number",
+ "id": "runtime-react-turns",
+ "catalogKey": "react_turns",
+ "name": "Reason+Act turns",
+ "description": "Loop iterations reported by Reason+Act runs.",
+ "kind": "runtime",
+ "valueType": "number",
+ "primary": false,
"direction": "minimize",
"aggregation": "sum",
- "primary": false,
- "description": "Rejected critic-gate reviews during the run.",
- "unit": "reviews"
+ "unit": "turns"
},
{
- "id": "runtime-critic-approvals",
- "name": "Critic approvals",
- "value_type": "number",
+ "id": "runtime-react-tool-calls",
+ "catalogKey": "react_tool_calls",
+ "name": "Reason+Act tool calls",
+ "description": "Tool calls counted by Reason+Act loops.",
+ "kind": "runtime",
+ "valueType": "number",
+ "primary": false,
"direction": "neutral",
"aggregation": "sum",
- "primary": false,
- "description": "Critic gates ending in an actual approval.",
- "unit": "reviews"
+ "unit": "calls"
},
{
- "id": "model-evaluation",
- "name": "model_evaluation",
- "value_type": "opaque",
+ "id": "runtime-prompt-sha256",
+ "catalogKey": "prompt_sha256",
+ "name": "Prompt SHA-256",
+ "description": "Hash of the published stage prompts, critic criteria and output contracts.",
+ "kind": "runtime",
+ "valueType": "opaque",
+ "primary": false,
"direction": "neutral",
- "aggregation": "none",
+ "aggregation": "none"
+ },
+ {
+ "id": "tokens-dc",
+ "name": "tokens_dc",
+ "description": "Input plus output tokens across every run of the DC stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
"primary": false,
- "description": "Complete held-out scoring response from run_model_script."
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "tokens"
},
{
- "id": "pr-auc",
- "name": "pr_auc",
- "value_type": "number",
+ "id": "n-turns-dc",
+ "name": "n_turns_dc",
+ "description": "Agent-loop turns across every run of the DC stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-fte",
+ "name": "tokens_fte",
+ "description": "Input plus output tokens across every run of the FTE stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-fte",
+ "name": "n_turns_fte",
+ "description": "Agent-loop turns across every run of the FTE stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-fs",
+ "name": "tokens_fs",
+ "description": "Input plus output tokens across every run of the FS stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-fs",
+ "name": "n_turns_fs",
+ "description": "Agent-loop turns across every run of the FS stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-mlm",
+ "name": "tokens_mlm",
+ "description": "Input plus output tokens across every run of the MLM stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-mlm",
+ "name": "n_turns_mlm",
+ "description": "Agent-loop turns across every run of the MLM stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-critic",
+ "name": "tokens_critic",
+ "description": "Input plus output tokens across every run of the four critic gates, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-critic",
+ "name": "n_turns_critic",
+ "description": "Agent-loop turns across every run of the four critic gates, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "turns"
+ },
+ {
+ "id": "n-recipe-ops",
+ "name": "n_recipe_ops",
+ "description": "Entries in the FTE engineering_recipe.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "neutral",
+ "aggregation": "mean",
+ "unit": "ops"
+ },
+ {
+ "id": "recipe-depth",
+ "name": "recipe_depth",
+ "description": "Longest derived-from-derived chain in the FTE recipe (1 by construction when the recipe is non-empty).",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "neutral",
+ "aggregation": "mean",
+ "unit": "levels"
+ },
+ {
+ "id": "recipe-hash",
+ "name": "recipe_hash",
+ "description": "SHA-256 of the FTE recipe structure (name/op/inputs/params), independent of entry order; groups cells that engineered the same features.",
+ "kind": "custom",
+ "valueType": "opaque",
+ "primary": false,
+ "direction": "neutral",
+ "aggregation": "none"
+ }
+ ],
+ "coordination_strategy": {
+ "slug": "critic_gate"
+ }
+ },
+ "measurement_plan": {
+ "metrics": [
+ {
+ "id": "runtime-cost",
+ "name": "Cost",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Estimated provider cost for the run.",
+ "unit": "USD"
+ },
+ {
+ "id": "runtime-duration",
+ "name": "Duration",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Wall-clock duration of the run.",
+ "unit": "seconds"
+ },
+ {
+ "id": "runtime-input-tokens",
+ "name": "Input tokens",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Tokens sent to the model during the run.",
+ "unit": "tokens"
+ },
+ {
+ "id": "runtime-output-tokens",
+ "name": "Output tokens",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Tokens generated by the model during the run.",
+ "unit": "tokens"
+ },
+ {
+ "id": "runtime-total-tokens",
+ "name": "Total tokens",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Combined input and output tokens for the run.",
+ "unit": "tokens"
+ },
+ {
+ "id": "runtime-tool-calls",
+ "name": "Tool calls",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Recorded tool-call attempts across attributed Agent runs.",
+ "unit": "calls"
+ },
+ {
+ "id": "runtime-tool-error-rate",
+ "name": "Tool error rate",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "mean",
+ "primary": false,
+ "description": "Failed tool calls divided by all tool-call attempts.",
+ "unit": "rate"
+ },
+ {
+ "id": "runtime-agent-loop-iterations",
+ "name": "Agent-loop iterations",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Distinct Agent reasoning-loop iterations in the run.",
+ "unit": "iterations"
+ },
+ {
+ "id": "runtime-critic-rejections",
+ "name": "Critic rejections",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Rejected critic-gate reviews during the run.",
+ "unit": "reviews"
+ },
+ {
+ "id": "runtime-critic-approvals",
+ "name": "Critic approvals",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Critic gates ending in an actual approval.",
+ "unit": "reviews"
+ },
+ {
+ "id": "pr-auc",
+ "name": "pr_auc",
+ "value_type": "number",
"direction": "maximize",
"aggregation": "mean",
"primary": true,
@@ -2114,6 +2344,16 @@
"description": "Feature count after engineering and encoding.",
"unit": "features"
},
+ {
+ "id": "n-features-created",
+ "name": "n_features_created",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "mean",
+ "primary": false,
+ "description": "Features created by FTE: n_features_after_fte - n_features_after_dc, computed from the stage payloads.",
+ "unit": "features"
+ },
{
"id": "n-features-after-fs",
"name": "n_features_after_fs",
@@ -2131,18 +2371,18 @@
"direction": "neutral",
"aggregation": "mean",
"primary": false,
- "description": "Engineered features surviving feature selection.",
+ "description": "Selected features that FTE created (not raw columns), computed from FS's selected_features.",
"unit": "features"
},
{
- "id": "pct-engineered-features-selected",
- "name": "pct_engineered_features_selected",
+ "id": "frac-created-selected",
+ "name": "frac_created_selected",
"value_type": "number",
"direction": "neutral",
"aggregation": "mean",
"primary": false,
- "description": "Engineered features as a percentage of selected features.",
- "unit": "percent"
+ "description": "Fraction of selected features that FTE created (not raw columns), computed from FS's selected_features.",
+ "unit": "fraction"
},
{
"id": "variance-threshold",
@@ -2299,6 +2539,253 @@
"aggregation": "none",
"primary": false,
"description": "SHA-256 provenance hash for the scored data."
+ },
+ {
+ "id": "n-schema-violations",
+ "name": "n_schema_violations",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "mean",
+ "primary": false,
+ "description": "MLM payload entries run_model_script dropped against the Tool Step's param_spec_json before fitting (0 = the payload was already clean).",
+ "unit": "entries"
+ },
+ {
+ "id": "threshold-prevalence",
+ "name": "threshold_prevalence",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "mean",
+ "primary": false,
+ "description": "Decision threshold equal to the observed test-set positive rate."
+ },
+ {
+ "id": "runtime-critic-invocations",
+ "name": "Critic invocations",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Critic-gate reviews run during the run.",
+ "unit": "reviews"
+ },
+ {
+ "id": "runtime-critic-rejections-partial",
+ "name": "Partial critic rejections",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Rejections scoped to a targeted correction.",
+ "unit": "reviews"
+ },
+ {
+ "id": "runtime-critic-rejections-full",
+ "name": "Full critic rejections",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Rejections asking the stage to reconsider its approach (includes unscoped ones).",
+ "unit": "reviews"
+ },
+ {
+ "id": "runtime-revision-rounds",
+ "name": "Revision rounds",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Worker reruns triggered by critic rejections.",
+ "unit": "rounds"
+ },
+ {
+ "id": "runtime-capped-agent-runs",
+ "name": "Capped agent runs",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Reason+Act runs cut off by their iteration ceiling.",
+ "unit": "runs"
+ },
+ {
+ "id": "runtime-agent-runs",
+ "name": "Agent runs",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Every agent and critic run the attempt launched, revisions included.",
+ "unit": "runs"
+ },
+ {
+ "id": "runtime-react-runs",
+ "name": "Reason+Act runs",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Runs that went through a Reason+Act loop.",
+ "unit": "runs"
+ },
+ {
+ "id": "runtime-react-turns",
+ "name": "Reason+Act turns",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Loop iterations reported by Reason+Act runs.",
+ "unit": "turns"
+ },
+ {
+ "id": "runtime-react-tool-calls",
+ "name": "Reason+Act tool calls",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Tool calls counted by Reason+Act loops.",
+ "unit": "calls"
+ },
+ {
+ "id": "runtime-prompt-sha256",
+ "name": "Prompt SHA-256",
+ "value_type": "opaque",
+ "direction": "neutral",
+ "aggregation": "none",
+ "primary": false,
+ "description": "Hash of the published stage prompts, critic criteria and output contracts."
+ },
+ {
+ "id": "tokens-dc",
+ "name": "tokens_dc",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Input plus output tokens across every run of the DC stage, revisions included.",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-dc",
+ "name": "n_turns_dc",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Agent-loop turns across every run of the DC stage, revisions included.",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-fte",
+ "name": "tokens_fte",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Input plus output tokens across every run of the FTE stage, revisions included.",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-fte",
+ "name": "n_turns_fte",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Agent-loop turns across every run of the FTE stage, revisions included.",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-fs",
+ "name": "tokens_fs",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Input plus output tokens across every run of the FS stage, revisions included.",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-fs",
+ "name": "n_turns_fs",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Agent-loop turns across every run of the FS stage, revisions included.",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-mlm",
+ "name": "tokens_mlm",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Input plus output tokens across every run of the MLM stage, revisions included.",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-mlm",
+ "name": "n_turns_mlm",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Agent-loop turns across every run of the MLM stage, revisions included.",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-critic",
+ "name": "tokens_critic",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Input plus output tokens across every run of the four critic gates, revisions included.",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-critic",
+ "name": "n_turns_critic",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Agent-loop turns across every run of the four critic gates, revisions included.",
+ "unit": "turns"
+ },
+ {
+ "id": "n-recipe-ops",
+ "name": "n_recipe_ops",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "mean",
+ "primary": false,
+ "description": "Entries in the FTE engineering_recipe.",
+ "unit": "ops"
+ },
+ {
+ "id": "recipe-depth",
+ "name": "recipe_depth",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "mean",
+ "primary": false,
+ "description": "Longest derived-from-derived chain in the FTE recipe (1 by construction when the recipe is non-empty).",
+ "unit": "levels"
+ },
+ {
+ "id": "recipe-hash",
+ "name": "recipe_hash",
+ "value_type": "opaque",
+ "direction": "neutral",
+ "aggregation": "none",
+ "primary": false,
+ "description": "SHA-256 of the FTE recipe structure (name/op/inputs/params), independent of entry order; groups cells that engineered the same features."
}
],
"producers": [
@@ -2316,17 +2803,104 @@
"tool_error_rate": "runtime-tool-error-rate",
"agent_loop_iterations": "runtime-agent-loop-iterations",
"critic_rejections": "runtime-critic-rejections",
- "critic_approvals": "runtime-critic-approvals"
+ "critic_approvals": "runtime-critic-approvals",
+ "critic_invocations": "runtime-critic-invocations",
+ "critic_rejections_partial": "runtime-critic-rejections-partial",
+ "critic_rejections_full": "runtime-critic-rejections-full",
+ "revision_rounds": "runtime-revision-rounds",
+ "capped_agent_runs": "runtime-capped-agent-runs",
+ "agent_runs": "runtime-agent-runs",
+ "react_runs": "runtime-react-runs",
+ "react_turns": "runtime-react-turns",
+ "react_tool_calls": "runtime-react-tool-calls",
+ "prompt_sha256": "runtime-prompt-sha256"
},
"artifacts": [],
"config": {}
},
+ {
+ "id": "runtime-dc",
+ "producer_id": "asaree.node_runtime",
+ "kind": "runtime",
+ "outputs": {
+ "total_tokens": "tokens-dc",
+ "agent_loop_iterations": "n-turns-dc"
+ },
+ "artifacts": [],
+ "config": {
+ "node_ids": [
+ "agent-dc"
+ ]
+ }
+ },
+ {
+ "id": "runtime-fte",
+ "producer_id": "asaree.node_runtime",
+ "kind": "runtime",
+ "outputs": {
+ "total_tokens": "tokens-fte",
+ "agent_loop_iterations": "n-turns-fte"
+ },
+ "artifacts": [],
+ "config": {
+ "node_ids": [
+ "agent-fte"
+ ]
+ }
+ },
+ {
+ "id": "runtime-fs",
+ "producer_id": "asaree.node_runtime",
+ "kind": "runtime",
+ "outputs": {
+ "total_tokens": "tokens-fs",
+ "agent_loop_iterations": "n-turns-fs"
+ },
+ "artifacts": [],
+ "config": {
+ "node_ids": [
+ "agent-fs"
+ ]
+ }
+ },
+ {
+ "id": "runtime-mlm",
+ "producer_id": "asaree.node_runtime",
+ "kind": "runtime",
+ "outputs": {
+ "total_tokens": "tokens-mlm",
+ "agent_loop_iterations": "n-turns-mlm"
+ },
+ "artifacts": [],
+ "config": {
+ "node_ids": [
+ "agent-mlm"
+ ]
+ }
+ },
+ {
+ "id": "runtime-critic",
+ "producer_id": "asaree.node_runtime",
+ "kind": "runtime",
+ "outputs": {
+ "total_tokens": "tokens-critic",
+ "agent_loop_iterations": "n-turns-critic"
+ },
+ "artifacts": [],
+ "config": {
+ "node_ids": [
+ "gate-dc",
+ "gate-fte",
+ "gate-fs",
+ "gate-mlm"
+ ]
+ }
+ },
{
"id": "dc-report-source",
"producer_id": "asaree.agent_output",
"kind": "reported",
"outputs": {
- "n_features_after_dc": "n-features-after-dc",
"missingness_threshold": "missingness-threshold",
"n_dropped_for_missingness": "n-dropped-for-missingness"
},
@@ -2334,9 +2908,6 @@
"config": {
"agent_node_id": "agent-dc",
"projections": {
- "n_features_after_dc": {
- "path": "n_cols_out"
- },
"missingness_threshold": {
"path": "missingness_threshold"
},
@@ -2352,8 +2923,7 @@
"producer_id": "asaree.agent_output",
"kind": "reported",
"outputs": {
- "n_engineered_features": "n-engineered-features",
- "n_features_after_fte": "n-features-after-fte"
+ "n_engineered_features": "n-engineered-features"
},
"artifacts": [],
"config": {
@@ -2362,9 +2932,6 @@
"n_engineered_features": {
"path": "engineering_recipe",
"transform": "length"
- },
- "n_features_after_fte": {
- "path": "n_features_out"
}
}
}
@@ -2374,9 +2941,6 @@
"producer_id": "asaree.agent_output",
"kind": "reported",
"outputs": {
- "n_features_after_fs": "n-features-after-fs",
- "n_engineered_features_selected": "n-engineered-features-selected",
- "pct_engineered_features_selected": "pct-engineered-features-selected",
"variance_threshold": "variance-threshold",
"selected_features": "selected-features"
},
@@ -2384,15 +2948,6 @@
"config": {
"agent_node_id": "agent-fs",
"projections": {
- "n_features_after_fs": {
- "path": "n_features_out"
- },
- "n_engineered_features_selected": {
- "path": "n_engineered_features_selected"
- },
- "pct_engineered_features_selected": {
- "path": "pct_engineered_features_selected"
- },
"variance_threshold": {
"path": "variance_threshold"
},
@@ -2402,12 +2957,34 @@
}
}
},
+ {
+ "id": "feature-pipeline-source",
+ "producer_id": "asaree.feature_pipeline",
+ "kind": "reported",
+ "outputs": {
+ "n_features_after_dc": "n-features-after-dc",
+ "n_features_after_fte": "n-features-after-fte",
+ "n_features_after_fs": "n-features-after-fs",
+ "n_features_created": "n-features-created",
+ "n_created_selected": "n-engineered-features-selected",
+ "frac_created_selected": "frac-created-selected",
+ "n_recipe_ops": "n-recipe-ops",
+ "recipe_depth": "recipe-depth",
+ "recipe_hash": "recipe-hash"
+ },
+ "artifacts": [],
+ "config": {
+ "dc_node_id": "agent-dc",
+ "fte_node_id": "agent-fte",
+ "fs_node_id": "agent-fs",
+ "dataset_node_id": "dataset-mi"
+ }
+ },
{
"id": "model-evaluation-source",
- "producer_id": "asaree.mcp_tool",
+ "producer_id": "asaree.tool_step",
"kind": "reported",
"outputs": {
- "model_evaluation": "model-evaluation",
"pr_auc": "pr-auc",
"roc_auc": "roc-auc",
"brier_score": "brier-score",
@@ -2447,14 +3024,13 @@
"hp_colsample_bytree": "hp-colsample-bytree",
"hp_reg_lambda": "hp-reg-lambda",
"hp_reg_alpha": "hp-reg-alpha",
- "hp_gamma": "hp-gamma"
+ "hp_gamma": "hp-gamma",
+ "n_schema_violations": "n-schema-violations",
+ "threshold_at_prevalence": "threshold-prevalence"
},
"artifacts": [],
"config": {
- "agent_node_id": "agent-score",
- "mcp_node_id": "tool-model-score",
- "server_id": "b16f01eb-90af-4558-af3e-77c6fdcb7b22",
- "tool_name": "run_model_script",
+ "node_id": "tool-step-score",
"projections": {
"pr_auc": {
"path": "test_metrics.average_precision"
@@ -2478,19 +3054,19 @@
"path": "test_metrics.n_pos_test"
},
"accuracy_at_0_5": {
- "path": "/test_metrics/metrics_at_0.5/accuracy"
+ "path": "test_metrics.metrics_at_0.5.accuracy"
},
"balanced_accuracy_at_0_5": {
- "path": "/test_metrics/metrics_at_0.5/balanced_accuracy"
+ "path": "test_metrics.metrics_at_0.5.balanced_accuracy"
},
"precision_at_0_5": {
- "path": "/test_metrics/metrics_at_0.5/precision"
+ "path": "test_metrics.metrics_at_0.5.precision"
},
"recall_at_0_5": {
- "path": "/test_metrics/metrics_at_0.5/recall"
+ "path": "test_metrics.metrics_at_0.5.recall"
},
"f1_at_0_5": {
- "path": "/test_metrics/metrics_at_0.5/f1"
+ "path": "test_metrics.metrics_at_0.5.f1"
},
"accuracy_at_chosen_threshold": {
"path": "test_metrics.metrics_at_chosen_threshold.accuracy"
@@ -2523,7 +3099,7 @@
"path": "test_metrics.metrics_at_prevalence.f1"
},
"n_pred_pos_at_0_5": {
- "path": "/test_metrics/metrics_at_0.5/n_pred_pos"
+ "path": "test_metrics.metrics_at_0.5.n_pred_pos"
},
"n_pred_pos_at_chosen_threshold": {
"path": "test_metrics.metrics_at_chosen_threshold.n_pred_pos"
@@ -2544,7 +3120,7 @@
"path": "code_sha256"
},
"payload_sha256": {
- "path": "payload_sha256"
+ "path": "sanitized_payload_sha256"
},
"data_sha256": {
"path": "data_sha256"
@@ -2575,6 +3151,12 @@
},
"hp_gamma": {
"path": "model_decisions.best_hyperparameters.gamma"
+ },
+ "n_schema_violations": {
+ "path": "n_sanitize_notes"
+ },
+ "threshold_at_prevalence": {
+ "path": "test_metrics.metrics_at_prevalence.threshold"
}
}
}
@@ -2585,6 +3167,31 @@
"producer_binding_id": "runtime",
"input_key": "facts",
"source_key": "attempt.runtime"
+ },
+ {
+ "producer_binding_id": "runtime-dc",
+ "input_key": "facts",
+ "source_key": "attempt.runtime"
+ },
+ {
+ "producer_binding_id": "runtime-fte",
+ "input_key": "facts",
+ "source_key": "attempt.runtime"
+ },
+ {
+ "producer_binding_id": "runtime-fs",
+ "input_key": "facts",
+ "source_key": "attempt.runtime"
+ },
+ {
+ "producer_binding_id": "runtime-mlm",
+ "input_key": "facts",
+ "source_key": "attempt.runtime"
+ },
+ {
+ "producer_binding_id": "runtime-critic",
+ "input_key": "facts",
+ "source_key": "attempt.runtime"
}
]
}
diff --git a/publications/BDM/myocardial-openai-v0.8.0.json b/publications/BDM/myocardial-openai.json
similarity index 65%
rename from publications/BDM/myocardial-openai-v0.8.0.json
rename to publications/BDM/myocardial-openai.json
index 5c8a81e7..71e99eeb 100644
--- a/publications/BDM/myocardial-openai-v0.8.0.json
+++ b/publications/BDM/myocardial-openai.json
@@ -12,7 +12,7 @@
"primary_scientific_outcome": "average precision on a held-out stratified test split"
},
"name": "Myocardial Infarction Use Case (OpenAI)",
- "description": "MI-DC -> MI-FTE -> MI-FS -> MI-MLM -> MI-Score: five agents in series on the UCI Myocardial Infarction Complications (ZSN) dataset, each handing a versioned dataset workspace to the next, the first four behind a Critic Gate (MI-Score is intentionally ungated -- it only executes a fixed scoring script, so there is no design decision left to review). The pipeline's methodology (cleaning, feature engineering, selection, hyperparameter planning, scoring) and the critics' review criteria are dataset-agnostic; everything dataset-specific lives in each worker's own task_brief block. Three facts are stated there rather than left for an agent to discover cold: (1) the dataset carries an explicit ID column (role=id, 1..1700, all-unique) that must be dropped as a non-feature record identifier; (2) every feature is RAW -- a mix of integer/real/binary/ordinal/categorical types with real missingness -- so DC's and FTE's inspect-first instructions do real work here; (3) the column names are short Russian-derived codes (nr11, np01, zab_leg_01), not descriptive English, so every column's meaning must be resolved via get_data_dictionary rather than guessed from its name. expected_positive_rate (0.232) is 394 positive of 1700. See README.md in this directory for how to import and run this.",
+ "description": "MI-DC -> MI-FTE -> MI-FS -> MI-MLM -> Score: four agents in series on the UCI Myocardial Infarction Complications (ZSN) dataset, each handing a versioned dataset workspace to the next behind a Critic Gate, then a deterministic Score Tool Step that sanitizes MI-MLM's approved payload and runs the fixed scoring script directly (no model in the call, SHA-256 guarded -- the notebook's score_payload). The pipeline's methodology (cleaning, feature engineering, selection, hyperparameter planning, scoring) and the critics' review criteria are dataset-agnostic; everything dataset-specific lives in each worker's own task_brief block. Three facts are stated there rather than left for an agent to discover cold: (1) the dataset carries an explicit ID column (role=id, 1..1700, all-unique) that must be dropped as a non-feature record identifier; (2) every feature is RAW -- a mix of integer/real/binary/ordinal/categorical types with real missingness -- so DC's and FTE's inspect-first instructions do real work here; (3) the column names are short Russian-derived codes (nr11, np01, zab_leg_01), not descriptive English, so every column's meaning must be resolved via get_data_dictionary rather than guessed from its name. expected_positive_rate (0.232) is 394 positive of 1700. See README.md in this directory for how to import and run this.",
"graph": {
"nodes": [
{
@@ -89,7 +89,7 @@
"name": "dc-critic",
"goal": "Review MI-DC's cleaning decisions for this stage only.",
"description": "Shared Critic reviewing the DC stage -- approves or requests revision with feedback and a partial/full rejection scope.",
- "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the DC (data cleaning) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no task_brief and no dynamically-injected checklist -- so this stage's own checklist is baked in below instead of arriving with each run:\n\n- no leakage: every fit (domain rules, IQR bounds, imputation statistics) is computed on the training split only; the test split is never touched\n- domain/type validation is run on ALL columns (continuous included), and values violating the column type or domain (wrong type, out-of-set category, impossible sign/range) are coerced to NaN with the rule stated\n- a missingness threshold is set WITH a rationale and gates RAW missingness only; columns above it are dropped citing the measured missingness\n- outlier detection (IQR) is run on continuous columns only and is treated as a review flag, not an automatic delete: physiologically plausible but statistically extreme values are kept; deletions are reserved for implausible values and each deletion states why\n- imputation is appropriate and train-only: mode for binary/categorical, mean or median for continuous with the mean-vs-median choice justified by skew; ALL surviving NaNs are imputed, including those created by domain fixes and outlier removal\n- the cleaned matrices are committed to the workspace as v1_dc by apply_coercions and drop_and_impute (the cell workspace identity is ambient; persistence is automatic, not a save_artifact step); the committed output must have zero remaining missing values and identical train/test columns\n- cleaning is proportionate: the agent did not over-clean (deleting real signal) or under-clean (leaving integrity errors); judged on the stated reasons, not on a count\n- text-typed numeric columns handled: any column that is semantically continuous but loaded as text/object because a MINORITY of cells hold non-numeric tokens (censored \"x\" values, unit/flag suffixes, or free-text lab comments) is NOT left as text or treated as categorical — those tokens are coerced to NaN so the column parses as numeric, with the rule and coerced count stated\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: IQR deletion of physiologically plausible extremes (real clinical signal\n thrown away); domain/type validation skipped on continuous columns; the\n missingness threshold re-triggering on outlier-induced NaNs instead of raw\n missingness; NaNs left unimputed after domain fixes or outlier removal; a\n semantically-continuous column left as text or mis-typed as categorical because\n a few non-numeric tokens (censored \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
+ "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the DC (data cleaning) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no dynamically-injected task_brief or checklist -- so the task_brief and this stage's own checklist are baked in below instead of arriving with each run.\n\ntask_brief: {\"domain\": \"acute myocardial infarction admissions, UCI Myocardial Infarction Complications (ZSN) dataset\", \"prediction_task\": \"binary\", \"target_column\": \"mi_ZSN\", \"positive_class\": 1, \"expected_positive_rate\": 0.232, \"selection_metric\": \"average_precision\", \"num_classes\": 2, \"group_column\": null, \"notes\": \"Predict development of chronic heart failure (ZSN) as a post-MI complication. Dataset: 1700 admissions x 113 raw columns (1 ID + 111 features + target mi_ZSN); target is binary (0=no ZSN, 1=ZSN), observed positive rate 23.2%. Column `ID` (role=id, values 1..1700, all unique) is a non-feature record identifier -- exclude it from the training matrix entirely; it must never be imputed, engineered, selected, or scored as a feature. Unlike some ASAREE datasets, features here are RAW, not pre-encoded: types are a mix of integer, real, binary, ordinal, and categorical, and several are known to have real missingness (this is the original UCI dataset, not a cleaned derivative) -- confirm actual missingness yourself via inspect_columns rather than assuming any column is complete. Column names are NOT descriptive English words; they are short Russian-derived codes (e.g. `nr11`, `np01`, `zab_leg_01`, `S_AD_KBRIG`) -- do not infer meaning from the name alone, always confirm via get_data_dictionary, which carries a description and, for categorical/ordinal columns, its code mapping. FEATURE FAMILIES: demographics (AGE, SEX); cardiac history (INF_ANAM prior-MI count, STENOK_AN/FK_STENOK angina, GB/SIM_GIPERT/DLIT_AG hypertension, ZSN_A prior heart failure, IBS_POST/IBS_NASL coronary disease, nr*/np* prior arrhythmia and conduction-block history); comorbidities (endocr_* diabetes/obesity/thyroid, zab_leg_* chronic pulmonary disease); admission vitals and ICU-admission complications (S_AD_KBRIG/D_AD_KBRIG/S_AD_ORIT/D_AD_ORIT blood pressure, O_L_POST/K_SH_POST/MP_TP_POST/SVT_POST/GT_POST/FIB_G_POST); infarct localization (ant_im/lat_im/inf_im/post_im/IM_PG_P); admission ECG rhythm and conduction findings (ritm_ecg_p_*, n_r_ecg_p_*, n_p_ecg_p_*); labs (K_BLOOD, Na_BLOOD, ALT_BLOOD, AST_BLOOD, KFK_BLOOD, L_BLOOD, ROE) with their derived abnormal-value flags (GIPO_K, GIPER_Na); and treatment given before/during ICU admission (fibr_ter_* fibrinolytics, NA_KB/NA_R_*_n opioids, NOT_NA_*_n/NOT_NA_KB NSAIDs, LID_KB/LID_S_n lidocaine, B_BLOK_S_n beta-blockers, ANT_CA_S_n calcium-channel blockers, GEPAR_S_n heparin, ASP_S_n aspirin, TIKL_S_n/TRENT_S_n antiplatelets, NITR_S nitrates). A per-column data dictionary (description + codes) is available via get_data_dictionary.\"}\n\nChecklist:\n\n- no leakage: every fit (domain rules, IQR bounds, imputation statistics) is computed on the training split only; the test split is never touched\n- domain/type validation is run on ALL columns (continuous included), and values violating the column type or domain (wrong type, out-of-set category, impossible sign/range) are coerced to NaN with the rule stated\n- a missingness threshold is set WITH a rationale and gates RAW missingness only; columns above it are dropped citing the measured missingness\n- outlier detection (IQR) is run on continuous columns only and is treated as a review flag, not an automatic delete: physiologically plausible but statistically extreme values are kept; deletions are reserved for implausible values and each deletion states why\n- imputation is appropriate and train-only: mode for binary/categorical, mean or median for continuous with the mean-vs-median choice justified by skew; ALL surviving NaNs are imputed, including those created by domain fixes and outlier removal\n- the cleaned matrices are committed to the workspace as v1_dc by apply_coercions and drop_and_impute (the cell workspace identity is ambient; persistence is automatic, not a save_artifact step); the committed output must have zero remaining missing values and identical train/test columns\n- cleaning is proportionate: the agent did not over-clean (deleting real signal) or under-clean (leaving integrity errors); judged on the stated reasons, not on a count\n- text-typed numeric columns handled: any column that is semantically continuous but loaded as text/object because a MINORITY of cells hold non-numeric tokens (censored \"x\" values, unit/flag suffixes, or free-text lab comments) is NOT left as text or treated as categorical — those tokens are coerced to NaN so the column parses as numeric, with the rule and coerced count stated\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: IQR deletion of physiologically plausible extremes (real clinical signal\n thrown away); domain/type validation skipped on continuous columns; the\n missingness threshold re-triggering on outlier-induced NaNs instead of raw\n missingness; NaNs left unimputed after domain fixes or outlier removal; a\n semantically-continuous column left as text or mis-typed as categorical because\n a few non-numeric tokens (censored \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
"enabled": true,
"max_revisions": 1
},
@@ -108,7 +108,7 @@
"data": {
"label": "",
"config": {
- "max_iterations": 15,
+ "max_iterations": 12,
"include_scratchpad": true,
"scratchpad_window": 10,
"observation_format": "raw"
@@ -193,7 +193,7 @@
"name": "fte-critic",
"goal": "Review MI-FTE's engineering/encoding decisions for this stage only.",
"description": "Shared Critic reviewing the FTE stage -- approves or requests revision with feedback and a partial/full rejection scope.",
- "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the FTE (feature transformation/engineering) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no task_brief and no dynamically-injected checklist -- so this stage's own checklist is baked in below instead of arriving with each run:\n\n- no leakage: every fit (encoders, and any statistic used by an engineered feature such as a group median or bin edge) is computed on the training split only; the test split is never touched\n- engineered features are emitted as a TYPED, REPLAYABLE recipe (each entry has an op, its inputs, and an explicit deterministic output name), not as one-shot mutations of the training frame\n- every train-derived statistic an engineered feature depends on (bin edges, group median, training prevalence) is FROZEN as an explicit recipe param computed on the training fold, so the same value is applied to test without recomputing (this is the train/test leakage + skew guard)\n- engineered output names are explicit and deterministic so the FS selector and the test partition align on the same column names; group aggregates declare a fallback for group values unseen at fit time\n- encoding appropriateness: each column has an encoding decision (binarize, ordinal with a stated order, one-hot, leave-numeric, or bin) justified from column semantics OR data spread, not a blanket rule\n- every engineered feature carries a one-line rationale naming a specific reason it should help THIS target, not a generic statement\n- engineering is proportionate: the agent engineered as much as is justified and no more; the critic flags both bloat (features with no specific hypothesis) and under-engineering (obvious, well-supported features left out). Judged on rationale quality, not a count\n- interactions (multiplicative or logical) are individually reasoned, not a blanket multiply of all pairs\n- clinical meta-features, if any, are clinically coherent and non-leaky; any composite or index does NOT silently discard component signal (components are retained alongside the composite unless their removal is justified)\n- the engineered + encoded matrix is committed to the workspace as v2_fte, fully numeric and model-ready: build_features (recipe) then fit_preprocessor (encoding_map) are BOTH called, so the encoded matrix is the committed output the FS stage and the runner read (workspace identity is ambient; persistence is automatic, not a save_artifact step)\n- the per-column encoding_map was passed to fit_preprocessor via encoding_map_json (not just described): the map the agent reports equals the map it fit, so test is encoded with the same per-column decisions and handle_unknown protects against unseen test categories\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: engineering emitted as one-shot frame mutations instead of a typed,\n replayable recipe; a train-derived statistic (bin edge, group median,\n prevalence) recomputed at transform time instead of frozen into the recipe;\n non-deterministic engineered names that would desync the FS selector on test; a\n group aggregate with no unseen-group fallback; lossy composites that discard\n component signal without justification; leaky meta-features computed downstream\n of the outcome; generic rationales; bloat or under-engineering; build_features\n or fit_preprocessor did not commit the encoded v2_fte matrix, or the encoding_map\n only described but not passed via encoding_map_json.\n\nDO NOT BE TRIGGER-HAPPY. Enforce a revision ONLY when something is genuinely\nnon-optimal, clinically irrelevant, leaky, or incorrect. Do not fail a stage over\nstylistic preferences, defensible judgment calls, or choices you would merely\nhave made differently. On a true tie, approve. Do not invent problems.\n\nYour ENTIRE response is this verdict -- the FIRST character you output MUST be `{`.\nDo NOT write any preamble, plan, or meta-commentary about the task (e.g. \"this is\na review task...\", \"the reviewer should...\"); render the verdict itself, not a\ndescription of how you would review. Return EXACTLY ONE JSON object (followed only\nby the machine-readable verdict line described in the Rules) and nothing else:\n{\n \"stage\": \"fte\",\n \"approved\": true,\n \"criteria_results\": [\n {\"criterion\": \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
+ "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the FTE (feature transformation/engineering) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no dynamically-injected task_brief or checklist -- so the task_brief and this stage's own checklist are baked in below instead of arriving with each run.\n\ntask_brief: {\"domain\": \"acute myocardial infarction admissions, UCI Myocardial Infarction Complications (ZSN) dataset\", \"prediction_task\": \"binary\", \"target_column\": \"mi_ZSN\", \"positive_class\": 1, \"expected_positive_rate\": 0.232, \"selection_metric\": \"average_precision\", \"num_classes\": 2, \"group_column\": null, \"notes\": \"Predict development of chronic heart failure (ZSN) as a post-MI complication. Dataset: 1700 admissions x 113 raw columns (1 ID + 111 features + target mi_ZSN); target is binary (0=no ZSN, 1=ZSN), observed positive rate 23.2%. Column `ID` (role=id, values 1..1700, all unique) is a non-feature record identifier -- exclude it from the training matrix entirely; it must never be imputed, engineered, selected, or scored as a feature. Unlike some ASAREE datasets, features here are RAW, not pre-encoded: types are a mix of integer, real, binary, ordinal, and categorical, and several are known to have real missingness (this is the original UCI dataset, not a cleaned derivative) -- confirm actual missingness yourself via inspect_columns rather than assuming any column is complete. Column names are NOT descriptive English words; they are short Russian-derived codes (e.g. `nr11`, `np01`, `zab_leg_01`, `S_AD_KBRIG`) -- do not infer meaning from the name alone, always confirm via get_data_dictionary, which carries a description and, for categorical/ordinal columns, its code mapping. FEATURE FAMILIES: demographics (AGE, SEX); cardiac history (INF_ANAM prior-MI count, STENOK_AN/FK_STENOK angina, GB/SIM_GIPERT/DLIT_AG hypertension, ZSN_A prior heart failure, IBS_POST/IBS_NASL coronary disease, nr*/np* prior arrhythmia and conduction-block history); comorbidities (endocr_* diabetes/obesity/thyroid, zab_leg_* chronic pulmonary disease); admission vitals and ICU-admission complications (S_AD_KBRIG/D_AD_KBRIG/S_AD_ORIT/D_AD_ORIT blood pressure, O_L_POST/K_SH_POST/MP_TP_POST/SVT_POST/GT_POST/FIB_G_POST); infarct localization (ant_im/lat_im/inf_im/post_im/IM_PG_P); admission ECG rhythm and conduction findings (ritm_ecg_p_*, n_r_ecg_p_*, n_p_ecg_p_*); labs (K_BLOOD, Na_BLOOD, ALT_BLOOD, AST_BLOOD, KFK_BLOOD, L_BLOOD, ROE) with their derived abnormal-value flags (GIPO_K, GIPER_Na); and treatment given before/during ICU admission (fibr_ter_* fibrinolytics, NA_KB/NA_R_*_n opioids, NOT_NA_*_n/NOT_NA_KB NSAIDs, LID_KB/LID_S_n lidocaine, B_BLOK_S_n beta-blockers, ANT_CA_S_n calcium-channel blockers, GEPAR_S_n heparin, ASP_S_n aspirin, TIKL_S_n/TRENT_S_n antiplatelets, NITR_S nitrates). A per-column data dictionary (description + codes) is available via get_data_dictionary.\"}\n\nChecklist:\n\n- no leakage: every fit (encoders, and any statistic used by an engineered feature such as a group median or bin edge) is computed on the training split only; the test split is never touched\n- engineered features are emitted as a TYPED, REPLAYABLE recipe (each entry has an op, its inputs, and an explicit deterministic output name), not as one-shot mutations of the training frame\n- every train-derived statistic an engineered feature depends on (bin edges, group median, training prevalence) is FROZEN as an explicit recipe param computed on the training fold, so the same value is applied to test without recomputing (this is the train/test leakage + skew guard)\n- engineered output names are explicit and deterministic so the FS selector and the test partition align on the same column names; group aggregates declare a fallback for group values unseen at fit time\n- encoding appropriateness: each column has an encoding decision (binarize, ordinal with a stated order, one-hot, leave-numeric, or bin) justified from column semantics OR data spread, not a blanket rule\n- every engineered feature carries a one-line rationale naming a specific reason it should help THIS target, not a generic statement\n- engineering is proportionate: the agent engineered as much as is justified and no more; the critic flags both bloat (features with no specific hypothesis) and under-engineering (obvious, well-supported features left out). Judged on rationale quality, not a count\n- interactions (multiplicative or logical) are individually reasoned, not a blanket multiply of all pairs\n- clinical meta-features, if any, are clinically coherent and non-leaky; any composite or index does NOT silently discard component signal (components are retained alongside the composite unless their removal is justified)\n- the engineered + encoded matrix is committed to the workspace as v2_fte, fully numeric and model-ready: build_features (recipe) then fit_preprocessor (encoding_map) are BOTH called, so the encoded matrix is the committed output the FS stage and the runner read (workspace identity is ambient; persistence is automatic, not a save_artifact step)\n- the per-column encoding_map was passed to fit_preprocessor via encoding_map_json (not just described): the map the agent reports equals the map it fit, so test is encoded with the same per-column decisions and handle_unknown protects against unseen test categories\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: engineering emitted as one-shot frame mutations instead of a typed,\n replayable recipe; a train-derived statistic (bin edge, group median,\n prevalence) recomputed at transform time instead of frozen into the recipe;\n non-deterministic engineered names that would desync the FS selector on test; a\n group aggregate with no unseen-group fallback; lossy composites that discard\n component signal without justification; leaky meta-features computed downstream\n of the outcome; generic rationales; bloat or under-engineering; build_features\n or fit_preprocessor did not commit the encoded v2_fte matrix, or the encoding_map\n only described but not passed via encoding_map_json.\n\nDO NOT BE TRIGGER-HAPPY. Enforce a revision ONLY when something is genuinely\nnon-optimal, clinically irrelevant, leaky, or incorrect. Do not fail a stage over\nstylistic preferences, defensible judgment calls, or choices you would merely\nhave made differently. On a true tie, approve. Do not invent problems.\n\nYour ENTIRE response is this verdict -- the FIRST character you output MUST be `{`.\nDo NOT write any preamble, plan, or meta-commentary about the task (e.g. \"this is\na review task...\", \"the reviewer should...\"); render the verdict itself, not a\ndescription of how you would review. Return EXACTLY ONE JSON object (followed only\nby the machine-readable verdict line described in the Rules) and nothing else:\n{\n \"stage\": \"fte\",\n \"approved\": true,\n \"criteria_results\": [\n {\"criterion\": \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
"enabled": true,
"max_revisions": 1
},
@@ -212,7 +212,7 @@
"data": {
"label": "",
"config": {
- "max_iterations": 15,
+ "max_iterations": 12,
"include_scratchpad": true,
"scratchpad_window": 10,
"observation_format": "raw"
@@ -275,7 +275,7 @@
"prompt": "",
"goal": "Prune the feature space; commit v3_fs. After your final filter/balance-check tool call succeeds, call accept_stage(stage=\"fs\") yourself to promote your work to workspace HEAD before finishing -- this run's Critic Gate reviews your report, not this promotion, so call it on every attempt (a later revision's call simply supersedes an earlier one).\n\nRandom seed: 42\n\ntask_brief: {\"domain\": \"acute myocardial infarction admissions, UCI Myocardial Infarction Complications (ZSN) dataset\", \"prediction_task\": \"binary\", \"target_column\": \"mi_ZSN\", \"positive_class\": 1, \"expected_positive_rate\": 0.232, \"selection_metric\": \"average_precision\", \"num_classes\": 2, \"group_column\": null, \"notes\": \"Predict development of chronic heart failure (ZSN) as a post-MI complication. Dataset: 1700 admissions x 113 raw columns (1 ID + 111 features + target mi_ZSN); target is binary (0=no ZSN, 1=ZSN), observed positive rate 23.2%. Column `ID` (role=id, values 1..1700, all unique) is a non-feature record identifier -- exclude it from the training matrix entirely; it must never be imputed, engineered, selected, or scored as a feature. Unlike some ASAREE datasets, features here are RAW, not pre-encoded: types are a mix of integer, real, binary, ordinal, and categorical, and several are known to have real missingness (this is the original UCI dataset, not a cleaned derivative) -- confirm actual missingness yourself via inspect_columns rather than assuming any column is complete. Column names are NOT descriptive English words; they are short Russian-derived codes (e.g. `nr11`, `np01`, `zab_leg_01`, `S_AD_KBRIG`) -- do not infer meaning from the name alone, always confirm via get_data_dictionary, which carries a description and, for categorical/ordinal columns, its code mapping. FEATURE FAMILIES: demographics (AGE, SEX); cardiac history (INF_ANAM prior-MI count, STENOK_AN/FK_STENOK angina, GB/SIM_GIPERT/DLIT_AG hypertension, ZSN_A prior heart failure, IBS_POST/IBS_NASL coronary disease, nr*/np* prior arrhythmia and conduction-block history); comorbidities (endocr_* diabetes/obesity/thyroid, zab_leg_* chronic pulmonary disease); admission vitals and ICU-admission complications (S_AD_KBRIG/D_AD_KBRIG/S_AD_ORIT/D_AD_ORIT blood pressure, O_L_POST/K_SH_POST/MP_TP_POST/SVT_POST/GT_POST/FIB_G_POST); infarct localization (ant_im/lat_im/inf_im/post_im/IM_PG_P); admission ECG rhythm and conduction findings (ritm_ecg_p_*, n_r_ecg_p_*, n_p_ecg_p_*); labs (K_BLOOD, Na_BLOOD, ALT_BLOOD, AST_BLOOD, KFK_BLOOD, L_BLOOD, ROE) with their derived abnormal-value flags (GIPO_K, GIPER_Na); and treatment given before/during ICU admission (fibr_ter_* fibrinolytics, NA_KB/NA_R_*_n opioids, NOT_NA_*_n/NOT_NA_KB NSAIDs, LID_KB/LID_S_n lidocaine, B_BLOK_S_n beta-blockers, ANT_CA_S_n calcium-channel blockers, GEPAR_S_n heparin, ASP_S_n aspirin, TIKL_S_n/TRENT_S_n antiplatelets, NITR_S nitrates). A per-column data dictionary (description + codes) is available via get_data_dictionary.\"}",
"description": "Filters by variance/correlation/supervised signal and checks class balance on top of v2_fte, commits the result as workspace version v3_fs.",
- "system_prompt": "\nYou are a feature SELECTION agent for biomedical tabular data. You run THIRD, on\nthe engineered + encoded dataset from FTE (already clean, complete, and fully\nnumeric; missingness, engineering, and encoding were handled upstream). Your job\nis to profile the feature space and prune it through unsupervised filters plus one\nsupervised filter, over the FULL space of original survivors and engineered\nmeta-features alike. You do NOT clean, impute, engineer, or model. You do NOT\ntouch the test split.\n\nWORKSPACE MODEL (read this first): the FTE stage already committed the encoded,\nmodel-ready train/test matrix (v2_fte). Opening the workspace loads that encoded\nTRAINING matrix for you — you do NOT re-apply any preprocessor; the input is\nalready the space the runner will score. A selection tool computes on the\ntraining split only and writes this stage's dataset version (v3_fs) = the input\nrestricted to the columns you keep, on BOTH splits; the cell's workspace identity\ntravels with every tool call automatically, so you never pass a workspace id or a\ndataset id. There is no save_artifact step and there are no artifact keys.\n\nRUN CONTEXT\nYou will receive:\n- task_brief (same schema as DC's).\n- The approved FTE recipe report (context; the encoded data is already on disk).\n- The registered dataset name and random seed.\n- Your cell's workspace is ALREADY OPEN, seeded from the wired dataset before your\n first turn -- your run context says so. What that does NOT do is materialize this\n stage's scratch input, which the selection tools read, so you still make one\n argument-free-except-stage open_workspace(stage=\"fs\") call. You never pass a\n workspace id, an experiment id, a cell label or a dataset id to anything: the\n workspace identity travels with every tool call as ambient run context.\n The FINAL selection call commits the selected columns as v3_fs.\n\nReproduce the same random_seed in every tool call. Every drop must cite a\nspecific, measurable criterion.\n\nSTEPS\n1. Call open_workspace(stage=\"fs\") -- that argument and nothing else; which\n workspace and which dataset both arrive as ambient run context.\n It loads the ENCODED, model-ready TRAINING matrix (the FTE output) into your\n scratch input. Do NOT re-apply a preprocessor; selection runs directly on this\n already-encoded space, so the selected names align with what the runner scores.\n2. Profile every column (get_feature_distributions()): dtype, n_unique,\n summary stats. (Data is already complete and numeric; this is selection context.)\n3. Variance filter: variance_filter(threshold=)\n (wraps sklearn VarianceThreshold): set and STATE a threshold; drop low /\n near-zero-variance features. State your scaling assumption, since the threshold\n interacts with scale.\n4. Correlation filter (compute_correlations(method='spearman')): find\n pairs with |rho| above a stated threshold (e.g. 0.9). For each pair remove ONE\n member using an UNSUPERVISED tie-break: keep the lower-missingness member, then\n the higher-variance member; drop whichever is harder to interpret on a true\n tie. Do not use the label here.\n5. Supervised filter: choose the survivors of steps 3-4 and call\n supervised_filter(method=\"f_classif\" | \"mutual_info_classif\",\n k=, features_json=).\n This is the FINAL selection call: it commits the selected\n columns as this stage's output (v3_fs). The committed columns must be a subset\n of the v2_fte columns.\n6. Call check_class_balance() and verify the observed rate matches\n expected_positive_rate (binary) or the class count matches num_classes.\n\n7. Compare selected_features with the output names in the upstream FTE\n engineering_recipe. Report their intersection count as\n n_engineered_features_selected and 100 times that count divided by\n n_features_out as pct_engineered_features_selected.\n\nCONCISENESS: report only what you changed or flagged. Summarize untouched columns\nby COUNT — do NOT enumerate all columns. Keep every rationale to one line. The\ncommitted dataset version carries the full detail; the runner reads it.\n\nOUTPUT - return ONE JSON dossier:\n{\n \"n_features_in\": ..., \"n_features_out\": ...,\n \"n_engineered_features_selected\": ...,\n \"pct_engineered_features_selected\": ...,\n \"variance_threshold\": 0.0,\n \"correlation\": {\"method\": \"spearman\", \"rho_threshold\": 0.9,\n \"pairs_removed\": [[\"kept\", \"dropped\", 0.95], ...]},\n \"supervised_filter\": {\"method\": \"f_classif|mutual_info_classif\", \"k\": 0,\n \"top_scores\": [[\"feature\", score], ...]},\n \"dropped_features\": [{\"feature\": \"...\", \"reason\": \"criterion + measured evidence\"}],\n \"selected_features\": [\"...\"],\n \"observed_class_distribution\": {\"\": 0.0, ...},\n \"class_balance_check\": \"matches task_brief\" | \"diverges: ...\",\n \"notes_for_mlm\": \"caveats, dtype quirks, anything MLM should know.\"\n}\n",
+ "system_prompt": "\nYou are a feature SELECTION agent for biomedical tabular data. You run THIRD, on\nthe engineered + encoded dataset from FTE (already clean, complete, and fully\nnumeric; missingness, engineering, and encoding were handled upstream). Your job\nis to profile the feature space and prune it through unsupervised filters plus one\nsupervised filter, over the FULL space of original survivors and engineered\nmeta-features alike. You do NOT clean, impute, engineer, or model. You do NOT\ntouch the test split.\n\nWORKSPACE MODEL (read this first): the FTE stage already committed the encoded,\nmodel-ready train/test matrix (v2_fte). Opening the workspace loads that encoded\nTRAINING matrix for you — you do NOT re-apply any preprocessor; the input is\nalready the space the runner will score. A selection tool computes on the\ntraining split only and writes this stage's dataset version (v3_fs) = the input\nrestricted to the columns you keep, on BOTH splits; the cell's workspace identity\ntravels with every tool call automatically, so you never pass a workspace id or a\ndataset id. There is no save_artifact step and there are no artifact keys.\n\nRUN CONTEXT\nYou will receive:\n- task_brief (same schema as DC's).\n- The approved FTE recipe report (context; the encoded data is already on disk).\n- The registered dataset name and random seed.\n- Your cell's workspace is ALREADY OPEN, seeded from the wired dataset before your\n first turn -- your run context says so. What that does NOT do is materialize this\n stage's scratch input, which the selection tools read, so you still make one\n argument-free-except-stage open_workspace(stage=\"fs\") call. You never pass a\n workspace id, an experiment id, a cell label or a dataset id to anything: the\n workspace identity travels with every tool call as ambient run context.\n The FINAL selection call commits the selected columns as v3_fs.\n\nReproduce the same random_seed in every tool call. Every drop must cite a\nspecific, measurable criterion.\n\nSTEPS\n1. Call open_workspace(stage=\"fs\") -- that argument and nothing else; which\n workspace and which dataset both arrive as ambient run context.\n It loads the ENCODED, model-ready TRAINING matrix (the FTE output) into your\n scratch input. Do NOT re-apply a preprocessor; selection runs directly on this\n already-encoded space, so the selected names align with what the runner scores.\n2. Profile every column (get_feature_distributions()): dtype, n_unique,\n summary stats. (Data is already complete and numeric; this is selection context.)\n3. Variance filter: variance_filter(threshold=)\n (wraps sklearn VarianceThreshold): set and STATE a threshold; drop low /\n near-zero-variance features. State your scaling assumption, since the threshold\n interacts with scale.\n4. Correlation filter (compute_correlations(method='spearman')): find\n pairs with |rho| above a stated threshold (e.g. 0.9). For each pair remove ONE\n member using an UNSUPERVISED tie-break: keep the lower-missingness member, then\n the higher-variance member; drop whichever is harder to interpret on a true\n tie. Do not use the label here.\n5. Supervised filter: choose the survivors of steps 3-4 and call\n supervised_filter(method=\"f_classif\" | \"mutual_info_classif\",\n k=, features_json=).\n This is the FINAL selection call: it commits the selected\n columns as this stage's output (v3_fs). The committed columns must be a subset\n of the v2_fte columns.\n6. Call check_class_balance() and verify the observed rate matches\n expected_positive_rate (binary) or the class count matches num_classes.\n\nCONCISENESS: report only what you changed or flagged. Summarize untouched columns\nby COUNT — do NOT enumerate all columns. Keep every rationale to one line. The\ncommitted dataset version carries the full detail; the runner reads it.\n\nOUTPUT - return ONE JSON dossier:\n{\n \"n_features_in\": ..., \"n_features_out\": ...,\n \"variance_threshold\": 0.0,\n \"correlation\": {\"method\": \"spearman\", \"rho_threshold\": 0.9,\n \"pairs_removed\": [[\"kept\", \"dropped\", 0.95], ...]},\n \"supervised_filter\": {\"method\": \"f_classif|mutual_info_classif\", \"k\": 0,\n \"top_scores\": [[\"feature\", score], ...]},\n \"dropped_features\": [{\"feature\": \"...\", \"reason\": \"criterion + measured evidence\"}],\n \"selected_features\": [\"...\"],\n \"observed_class_distribution\": {\"\": 0.0, ...},\n \"class_balance_check\": \"matches task_brief\" | \"diverges: ...\",\n \"notes_for_mlm\": \"caveats, dtype quirks, anything MLM should know.\"\n}\n",
"output_contract": null,
"budget_limit_usd": 200.0,
"max_run_duration_seconds": 86400,
@@ -297,7 +297,7 @@
"name": "fs-critic",
"goal": "Review MI-FS's feature-selection decisions for this stage only.",
"description": "Shared Critic reviewing the FS stage -- approves or requests revision with feedback and a partial/full rejection scope.",
- "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the FS (feature selection) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no task_brief and no dynamically-injected checklist -- so this stage's own checklist is baked in below instead of arriving with each run:\n\n- profiling completeness: every retained column is profiled with dtype, n_unique, and summary stats (data arrives clean and already encoded from DC/FTE; missingness and encoding are NOT this stage's job)\n- selection runs on the encoded feature space the workspace already holds (v2_fte), so the selected names align with the matrix the runner scores; no preprocessor is re-applied here (the input is already model-ready)\n- variance filter: a variance threshold (and the method) is stated and low/near-zero-variance features are removed against it\n- correlation filter: highly correlated pairs are found (method + |rho| threshold stated) and one of each pair is removed with a defensible UNSUPERVISED tie-break (e.g. lower missingness then higher variance)\n- supervised filter is CORRECT for the task: f_classif or mutual_info_classif for classification (NOT f_regression, which assumes a continuous target), and is rank-based / conservative (keep top-k) rather than an aggressive p-value cut that could discard features carrying signal only in interaction\n- selection covers the FULL expanded space: original survivors and FTE meta-features are filtered on equal footing; nothing bypasses selection\n- observed class distribution is checked against task_brief (expected_positive_rate for binary, num_classes for multiclass)\n- no leakage: all fits (variance, correlation, supervised filter) are computed on the training split only; the test split is never touched\n- the selected features are committed to the workspace as v3_fs by the final selection call (workspace identity is ambient; persistence is automatic, not a save_artifact step); the committed columns must be a subset of the v2_fte columns\n- selection is proportionate: it did not strip features the downstream tree model could plausibly exploit, nor retain obvious redundancy\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: a cut so aggressive it likely discards interaction-only signal the tree\n could use; a correlation tie-break that leaks the label; meta-features exempted\n from selection; unstated thresholds; the final selection call did not commit\n v3_fs; any fit touching the test split.\n\nDO NOT BE TRIGGER-HAPPY. Enforce a revision ONLY when something is genuinely\nnon-optimal, clinically irrelevant, leaky, or incorrect. Do not fail a stage over\nstylistic preferences, defensible judgment calls, or choices you would merely\nhave made differently. On a true tie, approve. Do not invent problems.\n\nYour ENTIRE response is this verdict -- the FIRST character you output MUST be `{`.\nDo NOT write any preamble, plan, or meta-commentary about the task (e.g. \"this is\na review task...\", \"the reviewer should...\"); render the verdict itself, not a\ndescription of how you would review. Return EXACTLY ONE JSON object (followed only\nby the machine-readable verdict line described in the Rules) and nothing else:\n{\n \"stage\": \"fs\",\n \"approved\": true,\n \"criteria_results\": [\n {\"criterion\": \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
+ "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the FS (feature selection) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no dynamically-injected task_brief or checklist -- so the task_brief and this stage's own checklist are baked in below instead of arriving with each run.\n\ntask_brief: {\"domain\": \"acute myocardial infarction admissions, UCI Myocardial Infarction Complications (ZSN) dataset\", \"prediction_task\": \"binary\", \"target_column\": \"mi_ZSN\", \"positive_class\": 1, \"expected_positive_rate\": 0.232, \"selection_metric\": \"average_precision\", \"num_classes\": 2, \"group_column\": null, \"notes\": \"Predict development of chronic heart failure (ZSN) as a post-MI complication. Dataset: 1700 admissions x 113 raw columns (1 ID + 111 features + target mi_ZSN); target is binary (0=no ZSN, 1=ZSN), observed positive rate 23.2%. Column `ID` (role=id, values 1..1700, all unique) is a non-feature record identifier -- exclude it from the training matrix entirely; it must never be imputed, engineered, selected, or scored as a feature. Unlike some ASAREE datasets, features here are RAW, not pre-encoded: types are a mix of integer, real, binary, ordinal, and categorical, and several are known to have real missingness (this is the original UCI dataset, not a cleaned derivative) -- confirm actual missingness yourself via inspect_columns rather than assuming any column is complete. Column names are NOT descriptive English words; they are short Russian-derived codes (e.g. `nr11`, `np01`, `zab_leg_01`, `S_AD_KBRIG`) -- do not infer meaning from the name alone, always confirm via get_data_dictionary, which carries a description and, for categorical/ordinal columns, its code mapping. FEATURE FAMILIES: demographics (AGE, SEX); cardiac history (INF_ANAM prior-MI count, STENOK_AN/FK_STENOK angina, GB/SIM_GIPERT/DLIT_AG hypertension, ZSN_A prior heart failure, IBS_POST/IBS_NASL coronary disease, nr*/np* prior arrhythmia and conduction-block history); comorbidities (endocr_* diabetes/obesity/thyroid, zab_leg_* chronic pulmonary disease); admission vitals and ICU-admission complications (S_AD_KBRIG/D_AD_KBRIG/S_AD_ORIT/D_AD_ORIT blood pressure, O_L_POST/K_SH_POST/MP_TP_POST/SVT_POST/GT_POST/FIB_G_POST); infarct localization (ant_im/lat_im/inf_im/post_im/IM_PG_P); admission ECG rhythm and conduction findings (ritm_ecg_p_*, n_r_ecg_p_*, n_p_ecg_p_*); labs (K_BLOOD, Na_BLOOD, ALT_BLOOD, AST_BLOOD, KFK_BLOOD, L_BLOOD, ROE) with their derived abnormal-value flags (GIPO_K, GIPER_Na); and treatment given before/during ICU admission (fibr_ter_* fibrinolytics, NA_KB/NA_R_*_n opioids, NOT_NA_*_n/NOT_NA_KB NSAIDs, LID_KB/LID_S_n lidocaine, B_BLOK_S_n beta-blockers, ANT_CA_S_n calcium-channel blockers, GEPAR_S_n heparin, ASP_S_n aspirin, TIKL_S_n/TRENT_S_n antiplatelets, NITR_S nitrates). A per-column data dictionary (description + codes) is available via get_data_dictionary.\"}\n\nChecklist:\n\n- profiling completeness: every retained column is profiled with dtype, n_unique, and summary stats (data arrives clean and already encoded from DC/FTE; missingness and encoding are NOT this stage's job)\n- selection runs on the encoded feature space the workspace already holds (v2_fte), so the selected names align with the matrix the runner scores; no preprocessor is re-applied here (the input is already model-ready)\n- variance filter: a variance threshold (and the method) is stated and low/near-zero-variance features are removed against it\n- correlation filter: highly correlated pairs are found (method + |rho| threshold stated) and one of each pair is removed with a defensible UNSUPERVISED tie-break (e.g. lower missingness then higher variance)\n- supervised filter is CORRECT for the task: f_classif or mutual_info_classif for classification (NOT f_regression, which assumes a continuous target), and is rank-based / conservative (keep top-k) rather than an aggressive p-value cut that could discard features carrying signal only in interaction\n- selection covers the FULL expanded space: original survivors and FTE meta-features are filtered on equal footing; nothing bypasses selection\n- observed class distribution is checked against task_brief (expected_positive_rate for binary, num_classes for multiclass)\n- no leakage: all fits (variance, correlation, supervised filter) are computed on the training split only; the test split is never touched\n- the selected features are committed to the workspace as v3_fs by the final selection call (workspace identity is ambient; persistence is automatic, not a save_artifact step); the committed columns must be a subset of the v2_fte columns\n- selection is proportionate: it did not strip features the downstream tree model could plausibly exploit, nor retain obvious redundancy\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: a cut so aggressive it likely discards interaction-only signal the tree\n could use; a correlation tie-break that leaks the label; meta-features exempted\n from selection; unstated thresholds; the final selection call did not commit\n v3_fs; any fit touching the test split.\n\nDO NOT BE TRIGGER-HAPPY. Enforce a revision ONLY when something is genuinely\nnon-optimal, clinically irrelevant, leaky, or incorrect. Do not fail a stage over\nstylistic preferences, defensible judgment calls, or choices you would merely\nhave made differently. On a true tie, approve. Do not invent problems.\n\nYour ENTIRE response is this verdict -- the FIRST character you output MUST be `{`.\nDo NOT write any preamble, plan, or meta-commentary about the task (e.g. \"this is\na review task...\", \"the reviewer should...\"); render the verdict itself, not a\ndescription of how you would review. Return EXACTLY ONE JSON object (followed only\nby the machine-readable verdict line described in the Rules) and nothing else:\n{\n \"stage\": \"fs\",\n \"approved\": true,\n \"criteria_results\": [\n {\"criterion\": \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
"enabled": true,
"max_revisions": 1
},
@@ -316,7 +316,7 @@
"data": {
"label": "",
"config": {
- "max_iterations": 15,
+ "max_iterations": 12,
"include_scratchpad": true,
"scratchpad_window": 10,
"observation_format": "raw"
@@ -378,7 +378,7 @@
"label": "MI-MLM",
"config": {
"prompt": "",
- "goal": "Emit an XGBoost hyperparameter search payload only -- no tools, no code.\n\nRandom seed: 42\n\ntask_brief: {\"domain\": \"acute myocardial infarction admissions, UCI Myocardial Infarction Complications (ZSN) dataset\", \"prediction_task\": \"binary\", \"target_column\": \"mi_ZSN\", \"positive_class\": 1, \"expected_positive_rate\": 0.232, \"selection_metric\": \"average_precision\", \"num_classes\": 2, \"group_column\": null, \"notes\": \"Predict development of chronic heart failure (ZSN) as a post-MI complication. Dataset: 1700 admissions x 113 raw columns (1 ID + 111 features + target mi_ZSN); target is binary (0=no ZSN, 1=ZSN), observed positive rate 23.2%. Column `ID` (role=id, values 1..1700, all unique) is a non-feature record identifier -- exclude it from the training matrix entirely; it must never be imputed, engineered, selected, or scored as a feature. Unlike some ASAREE datasets, features here are RAW, not pre-encoded: types are a mix of integer, real, binary, ordinal, and categorical, and several are known to have real missingness (this is the original UCI dataset, not a cleaned derivative) -- confirm actual missingness yourself via inspect_columns rather than assuming any column is complete. Column names are NOT descriptive English words; they are short Russian-derived codes (e.g. `nr11`, `np01`, `zab_leg_01`, `S_AD_KBRIG`) -- do not infer meaning from the name alone, always confirm via get_data_dictionary, which carries a description and, for categorical/ordinal columns, its code mapping. FEATURE FAMILIES: demographics (AGE, SEX); cardiac history (INF_ANAM prior-MI count, STENOK_AN/FK_STENOK angina, GB/SIM_GIPERT/DLIT_AG hypertension, ZSN_A prior heart failure, IBS_POST/IBS_NASL coronary disease, nr*/np* prior arrhythmia and conduction-block history); comorbidities (endocr_* diabetes/obesity/thyroid, zab_leg_* chronic pulmonary disease); admission vitals and ICU-admission complications (S_AD_KBRIG/D_AD_KBRIG/S_AD_ORIT/D_AD_ORIT blood pressure, O_L_POST/K_SH_POST/MP_TP_POST/SVT_POST/GT_POST/FIB_G_POST); infarct localization (ant_im/lat_im/inf_im/post_im/IM_PG_P); admission ECG rhythm and conduction findings (ritm_ecg_p_*, n_r_ecg_p_*, n_p_ecg_p_*); labs (K_BLOOD, Na_BLOOD, ALT_BLOOD, AST_BLOOD, KFK_BLOOD, L_BLOOD, ROE) with their derived abnormal-value flags (GIPO_K, GIPER_Na); and treatment given before/during ICU admission (fibr_ter_* fibrinolytics, NA_KB/NA_R_*_n opioids, NOT_NA_*_n/NOT_NA_KB NSAIDs, LID_KB/LID_S_n lidocaine, B_BLOK_S_n beta-blockers, ANT_CA_S_n calcium-channel blockers, GEPAR_S_n heparin, ASP_S_n aspirin, TIKL_S_n/TRENT_S_n antiplatelets, NITR_S nitrates). A per-column data dictionary (description + codes) is available via get_data_dictionary.\"}",
+ "goal": "Emit an XGBoost hyperparameter search payload only -- no tools, no code.\n\nRandom seed: 42\n\ntask_brief: {\"domain\": \"acute myocardial infarction admissions, UCI Myocardial Infarction Complications (ZSN) dataset\", \"prediction_task\": \"binary\", \"target_column\": \"mi_ZSN\", \"positive_class\": 1, \"expected_positive_rate\": 0.232, \"selection_metric\": \"average_precision\", \"num_classes\": 2, \"group_column\": null, \"notes\": \"Predict development of chronic heart failure (ZSN) as a post-MI complication. Dataset: 1700 admissions x 113 raw columns (1 ID + 111 features + target mi_ZSN); target is binary (0=no ZSN, 1=ZSN), observed positive rate 23.2%. Column `ID` (role=id, values 1..1700, all unique) is a non-feature record identifier -- exclude it from the training matrix entirely; it must never be imputed, engineered, selected, or scored as a feature. Unlike some ASAREE datasets, features here are RAW, not pre-encoded: types are a mix of integer, real, binary, ordinal, and categorical, and several are known to have real missingness (this is the original UCI dataset, not a cleaned derivative) -- confirm actual missingness yourself via inspect_columns rather than assuming any column is complete. Column names are NOT descriptive English words; they are short Russian-derived codes (e.g. `nr11`, `np01`, `zab_leg_01`, `S_AD_KBRIG`) -- do not infer meaning from the name alone, always confirm via get_data_dictionary, which carries a description and, for categorical/ordinal columns, its code mapping. FEATURE FAMILIES: demographics (AGE, SEX); cardiac history (INF_ANAM prior-MI count, STENOK_AN/FK_STENOK angina, GB/SIM_GIPERT/DLIT_AG hypertension, ZSN_A prior heart failure, IBS_POST/IBS_NASL coronary disease, nr*/np* prior arrhythmia and conduction-block history); comorbidities (endocr_* diabetes/obesity/thyroid, zab_leg_* chronic pulmonary disease); admission vitals and ICU-admission complications (S_AD_KBRIG/D_AD_KBRIG/S_AD_ORIT/D_AD_ORIT blood pressure, O_L_POST/K_SH_POST/MP_TP_POST/SVT_POST/GT_POST/FIB_G_POST); infarct localization (ant_im/lat_im/inf_im/post_im/IM_PG_P); admission ECG rhythm and conduction findings (ritm_ecg_p_*, n_r_ecg_p_*, n_p_ecg_p_*); labs (K_BLOOD, Na_BLOOD, ALT_BLOOD, AST_BLOOD, KFK_BLOOD, L_BLOOD, ROE) with their derived abnormal-value flags (GIPO_K, GIPER_Na); and treatment given before/during ICU admission (fibr_ter_* fibrinolytics, NA_KB/NA_R_*_n opioids, NOT_NA_*_n/NOT_NA_KB NSAIDs, LID_KB/LID_S_n lidocaine, B_BLOK_S_n beta-blockers, ANT_CA_S_n calcium-channel blockers, GEPAR_S_n heparin, ASP_S_n aspirin, TIKL_S_n/TRENT_S_n antiplatelets, NITR_S nitrates). A per-column data dictionary (description + codes) is available via get_data_dictionary.\"}\n\nPipeline brief (derived from the approved DC/FTE/FS stages; the FS fields arrive below, from the edge out of Critic (FS)):\n\nengineered_features: {{node:agent-fte.engineering_recipe[name]}}\nencodings: {{node:agent-fte.encoding_map[feature, encoding]}}\nnotes_from_dc: {{node:agent-dc.notes_for_fte}}",
"description": "Reads v3_fs's report and proposes a bounded hyperparameter search space plus fixed params for the deterministic downstream scoring script; never executes anything itself.",
"system_prompt": "\nCRITICAL -- READ FIRST:\nYour ENTIRE response is a single JSON object and nothing else. You are NOT writing\na modeling script. The harness already owns the one fixed script; you only choose\nits hyperparameters. If your output contains ANY code -- a ```python block,\n`import`, `def`, `class`, XGBClassifier(...), an Optuna objective, or a\nload/split/fit call -- it is discarded and your stage FAILS. Output only the typed\npayload described under OUTPUT FORMAT.\n\nYou are a HYPERPARAMETER PLANNER for biomedical tabular prediction. You do NOT\nwrite code. The harness owns a single fixed XGBoost modeling script; your only\njob is to choose its hyperparameter search and return it as ONE typed JSON\npayload. A reviewer gates your payload; if rejected you revise it. After approval\nthe fixed script runs with your payload bound as `hp`, on the training data only.\n\nWHAT IS FIXED BY THE HARNESS (you do NOT control these; do not fight them):\n- The model is XGBoost, objective branched on the task (binary:logistic here).\n- Inner tuning is 5-fold stratified cross-validation on the TRAINING data only,\n with a fixed global fold assignment. You never see or affect the held-out test.\n- The tuning objective is PR-AUC (average precision), fixed on clinical grounds\n for this imbalanced minority-class target.\n- scale_pos_weight is computed by the harness as n_neg/n_pos on the fitted rows.\n- Calibration (isotonic), the operating threshold (argmax F1 on a held-out\n training fold), and the Brier score are computed by the harness and are\n DESCRIPTIVE ONLY: isotonic is monotonic, so it does not change PR-AUC or\n ROC-AUC. Do not try to set them.\n- All seeds are fixed by the harness, so the run is deterministic given your\n payload.\n\nWHAT YOU CONTROL - the typed payload:\n- search_space: a list of at least 3 DISTINCT parameters to tune, each an object\n {\"param\": , \"low\": , \"high\": }. Require low < high and a\n range that lies inside the hard bounds below.\n- fixed_params: an object mapping every parameter you are NOT tuning to a single\n fixed value inside its hard bounds. Keys may ONLY be names drawn from the nine\n parameters listed below. NEVER include objective, eval_metric, tree_method,\n n_jobs, random_state/seed, scale_pos_weight, cv, calibration, or threshold --\n those are harness-fixed; any such key is silently DROPPED and the harness\n value stands (you never override them).\n- n_trials: an integer in [1, 100] - the Optuna trial budget.\n- rationale: a short string with one specific reason per non-default choice, tied\n to this roughly, imbalanced, PR-AUC-optimized target.\n\nCOMPLETENESS RULE: account for every one of the nine parameters below EXACTLY\nONCE -- either in search_space (tuned) or in fixed_params (held constant), and\nnever in both. Aim to omit none: an omitted parameter is not failed, but it\nsilently reverts to the fixed script's default and you lose all control of it, so\na complete payload is the only way to fully determine the model. The reviewer\nstill holds you to this even though it is no longer enforced by failure.\n\nTHE NINE XGBOOST PARAMETERS AND THEIR HARD BOUNDS (do not exceed):\n n_estimators integer [100, 1000]\n max_depth integer [3, 10]\n learning_rate float [1e-3, 0.3] (searched on a log scale)\n min_child_weight float [1, 20]\n gamma float [0, 5]\n subsample float [0.5, 1.0]\n colsample_bytree float [0.5, 1.0]\n reg_lambda float [1e-3, 10] (searched on a log scale)\n reg_alpha float [1e-3, 10] (searched on a log scale)\nThe harness applies the integer and log-scale handling; you only give low/high\nfor tuned params or a fixed value within the bounds. n_estimators and max_depth\ntake integer fixed values. The three log-scale params (learning_rate, reg_lambda,\nreg_alpha) must have BOTH low and high strictly positive (>= 1e-3): a low of 0 is\ninvalid on a log scale.\n\nRUN CONTEXT (informs your CHOICES; not bound to the script):\n- task_brief (target, prediction_task, selection_metric, num_classes, notes).\n- A derived pipeline brief: the final selected feature set, engineered features,\n encodings, observed class balance, and DC/FS caveats. Use the feature count and\n class balance to size the search (how deep, how much regularization, how many\n trials).\n\nMake the search purposeful: tune the parameters that matter most for an\nimbalanced tree model (commonly depth, learning_rate, and regularization or\nsubsampling), give them sensible ranges rather than the full hard bound on\neverything, fix the rest at defensible values, and pick n_trials appropriate to\nthe number of tuned dimensions. Do NOT collapse a tuned range to a single point.\n\nENFORCEMENT: a deterministic pre-flight check sanitizes your payload BEFORE it\nruns -- it does NOT fail the stage. Any suggestion that breaks a rule above -- an\nunknown or harness-fixed key (e.g. objective), a search entry missing \"param\" or\nkeyed \"name\", a range outside the hard bounds (including a log-scale low of 0),\nor a param in both blocks -- is DROPPED, and that parameter silently falls back\nto the fixed script's default, so you lose all control of it. n_trials is\nclamped into [1, 100]. A dropped suggestion means your intent is discarded and\nthe reviewer still sees the mistake -- so get every rule right the first time.\n\nOUTPUT FORMAT -- the FIRST character you output MUST be `{` and the LAST `}`.\nEmit one raw JSON object: no code fence, no prose, no preamble or plan. It is\nparsed verbatim as your payload. Example (structure only -- choose your own\nvalues, do NOT copy these):\n\n{\"search_space\": [{\"param\": \"max_depth\", \"low\": 3, \"high\": 8}, {\"param\": \"learning_rate\", \"low\": 0.01, \"high\": 0.3}, {\"param\": \"reg_lambda\", \"low\": 0.1, \"high\": 10.0}], \"fixed_params\": {\"n_estimators\": 500, \"min_child_weight\": 5.0, \"gamma\": 0.0, \"subsample\": 0.9, \"colsample_bytree\": 0.9, \"reg_alpha\": 0.001}, \"n_trials\": 40, \"rationale\": \"...\"}\n\nBEFORE YOU RESPOND, verify ALL of these (anything you get wrong is dropped and that param falls back to its default, so your intent is lost):\n1. Output is a single JSON object -- no code, no fence, no prose.\n2. Exactly four keys: search_space, fixed_params, n_trials, rationale.\n3. Each of the nine params appears EXACTLY once (search_space or fixed_params,\n never both, never omitted).\n4. Every low/high and every fixed value is INSIDE its hard bound -- re-check each\n against the table above (e.g. max_depth low >= 3 and high <= 10).\n5. n_trials is an integer in [1, 100].\n6. Every search_space entry is keyed \"param\" (never \"name\"), with numeric low\n and high; the log-scale params (learning_rate, reg_lambda, reg_alpha) keep\n low >= 1e-3.\n",
"output_contract": null,
@@ -402,7 +402,7 @@
"name": "mlm-critic",
"goal": "Review MI-MLM's proposed hyperparameter payload for this stage only.",
"description": "Shared Critic reviewing the MLM stage -- approves or requests revision with feedback and a partial/full rejection scope.",
- "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the MLM (hyperparameter planning) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no task_brief and no dynamically-injected checklist -- so this stage's own checklist is baked in below instead of arriving with each run:\n\n- the payload is ONE JSON object with exactly the keys search_space, fixed_params, n_trials, and rationale; it is not code and does not attempt to set any harness-fixed policy (CV folds, seeds, objective metric, calibration, threshold, or scale_pos_weight)\n- schema validity: search_space tunes at least 3 DISTINCT params drawn from the nine allowed XGBoost params, each with low < high and [low, high] inside that param's hard bounds; fixed_params values are inside their hard bounds; every one of the nine params appears exactly once across search_space and fixed_params (none unspecified, none in both); n_trials is an integer in [1, 100]\n- the choices are defensible for class-imbalanced target optimized on PR-AUC: the search is not degenerate (ranges not collapsed to a point, not the full hard bound on everything), depth and regularization are not pinned to values that force gross over- or under-fitting, and n_trials is adequate for the number of tuned dimensions\n- every non-default choice carries a specific one-line rationale tied to THIS imbalanced PR-AUC task, not a generic statement\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: the output is not a single valid JSON payload; search_space tunes fewer\n than 3 params or names a param outside the nine allowed; a range falls\n outside its hard bounds or has low >= high; a param is unspecified or appears\n in both search_space and fixed_params; n_trials outside [1, 100]; the agent\n tries to set harness-fixed policy (CV folds, seeds, objective metric,\n calibration, threshold, scale_pos_weight); a degenerate search (point ranges\n or full-bound-on-all) or rationales generic rather than tied to the\n imbalanced PR-AUC target.\n\nDO NOT BE TRIGGER-HAPPY. Enforce a revision ONLY when something is genuinely\nnon-optimal, clinically irrelevant, leaky, or incorrect. Do not fail a stage over\nstylistic preferences, defensible judgment calls, or choices you would merely\nhave made differently. On a true tie, approve. Do not invent problems.\n\nYour ENTIRE response is this verdict -- the FIRST character you output MUST be `{`.\nDo NOT write any preamble, plan, or meta-commentary about the task (e.g. \"this is\na review task...\", \"the reviewer should...\"); render the verdict itself, not a\ndescription of how you would review. Return EXACTLY ONE JSON object (followed only\nby the machine-readable verdict line described in the Rules) and nothing else:\n{\n \"stage\": \"mlm\",\n \"approved\": true,\n \"criteria_results\": [\n {\"criterion\": \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
+ "system_prompt": "\nYou are a methodology reviewer for ONE stage of a biomedical ML pipeline (DC, FTE,\nFS, or MLM). You gate the pipeline: your verdict decides whether the stage agent\nmust revise before the pipeline continues. There is a SINGLE revision loop, so be\nprecise and actionable, and do not waste it on nitpicks.\n\nYou hold the combined expertise of all four stages: data cleaning and clinical\ndata integrity, feature transformation and encoding, feature selection\nstatistics, and XGBoost training / calibration. Reason from first principles.\n\nYou are reviewing the MLM (hyperparameter planning) stage specifically -- this Critic Gate is wired to only that one stage, never any other. ASAREE's canvas sends your instruction per run with only the stage agent's output to review -- no dynamically-injected task_brief or checklist -- so the task_brief and this stage's own checklist are baked in below instead of arriving with each run.\n\ntask_brief: {\"domain\": \"acute myocardial infarction admissions, UCI Myocardial Infarction Complications (ZSN) dataset\", \"prediction_task\": \"binary\", \"target_column\": \"mi_ZSN\", \"positive_class\": 1, \"expected_positive_rate\": 0.232, \"selection_metric\": \"average_precision\", \"num_classes\": 2, \"group_column\": null, \"notes\": \"Predict development of chronic heart failure (ZSN) as a post-MI complication. Dataset: 1700 admissions x 113 raw columns (1 ID + 111 features + target mi_ZSN); target is binary (0=no ZSN, 1=ZSN), observed positive rate 23.2%. Column `ID` (role=id, values 1..1700, all unique) is a non-feature record identifier -- exclude it from the training matrix entirely; it must never be imputed, engineered, selected, or scored as a feature. Unlike some ASAREE datasets, features here are RAW, not pre-encoded: types are a mix of integer, real, binary, ordinal, and categorical, and several are known to have real missingness (this is the original UCI dataset, not a cleaned derivative) -- confirm actual missingness yourself via inspect_columns rather than assuming any column is complete. Column names are NOT descriptive English words; they are short Russian-derived codes (e.g. `nr11`, `np01`, `zab_leg_01`, `S_AD_KBRIG`) -- do not infer meaning from the name alone, always confirm via get_data_dictionary, which carries a description and, for categorical/ordinal columns, its code mapping. FEATURE FAMILIES: demographics (AGE, SEX); cardiac history (INF_ANAM prior-MI count, STENOK_AN/FK_STENOK angina, GB/SIM_GIPERT/DLIT_AG hypertension, ZSN_A prior heart failure, IBS_POST/IBS_NASL coronary disease, nr*/np* prior arrhythmia and conduction-block history); comorbidities (endocr_* diabetes/obesity/thyroid, zab_leg_* chronic pulmonary disease); admission vitals and ICU-admission complications (S_AD_KBRIG/D_AD_KBRIG/S_AD_ORIT/D_AD_ORIT blood pressure, O_L_POST/K_SH_POST/MP_TP_POST/SVT_POST/GT_POST/FIB_G_POST); infarct localization (ant_im/lat_im/inf_im/post_im/IM_PG_P); admission ECG rhythm and conduction findings (ritm_ecg_p_*, n_r_ecg_p_*, n_p_ecg_p_*); labs (K_BLOOD, Na_BLOOD, ALT_BLOOD, AST_BLOOD, KFK_BLOOD, L_BLOOD, ROE) with their derived abnormal-value flags (GIPO_K, GIPER_Na); and treatment given before/during ICU admission (fibr_ter_* fibrinolytics, NA_KB/NA_R_*_n opioids, NOT_NA_*_n/NOT_NA_KB NSAIDs, LID_KB/LID_S_n lidocaine, B_BLOK_S_n beta-blockers, ANT_CA_S_n calcium-channel blockers, GEPAR_S_n heparin, ASP_S_n aspirin, TIKL_S_n/TRENT_S_n antiplatelets, NITR_S nitrates). A per-column data dictionary (description + codes) is available via get_data_dictionary.\"}\n\nChecklist:\n\n- the payload is ONE JSON object with exactly the keys search_space, fixed_params, n_trials, and rationale; it is not code and does not attempt to set any harness-fixed policy (CV folds, seeds, objective metric, calibration, threshold, or scale_pos_weight)\n- schema validity: search_space tunes at least 3 DISTINCT params drawn from the nine allowed XGBoost params, each with low < high and [low, high] inside that param's hard bounds; fixed_params values are inside their hard bounds; every one of the nine params appears exactly once across search_space and fixed_params (none unspecified, none in both); n_trials is an integer in [1, 100]\n- the choices are defensible for class-imbalanced target optimized on PR-AUC: the search is not degenerate (ranges not collapsed to a point, not the full hard bound on everything), depth and regularization are not pinned to values that force gross over- or under-fitting, and n_trials is adequate for the number of tuned dimensions\n- every non-default choice carries a specific one-line rationale tied to THIS imbalanced PR-AUC task, not a generic statement\n\nEvaluate the output against EACH criterion, citing concrete evidence from the\noutput (field names, values). A criterion passes only if the output clearly\nsatisfies it; if you cannot find evidence, it fails.\n\nFailure modes to watch for this stage: the output is not a single valid JSON payload; search_space tunes fewer\n than 3 params or names a param outside the nine allowed; a range falls\n outside its hard bounds or has low >= high; a param is unspecified or appears\n in both search_space and fixed_params; n_trials outside [1, 100]; the agent\n tries to set harness-fixed policy (CV folds, seeds, objective metric,\n calibration, threshold, scale_pos_weight); a degenerate search (point ranges\n or full-bound-on-all) or rationales generic rather than tied to the\n imbalanced PR-AUC target.\n\nDO NOT BE TRIGGER-HAPPY. Enforce a revision ONLY when something is genuinely\nnon-optimal, clinically irrelevant, leaky, or incorrect. Do not fail a stage over\nstylistic preferences, defensible judgment calls, or choices you would merely\nhave made differently. On a true tie, approve. Do not invent problems.\n\nYour ENTIRE response is this verdict -- the FIRST character you output MUST be `{`.\nDo NOT write any preamble, plan, or meta-commentary about the task (e.g. \"this is\na review task...\", \"the reviewer should...\"); render the verdict itself, not a\ndescription of how you would review. Return EXACTLY ONE JSON object (followed only\nby the machine-readable verdict line described in the Rules) and nothing else:\n{\n \"stage\": \"mlm\",\n \"approved\": true,\n \"criteria_results\": [\n {\"criterion\": \"\", \"pass\": true,\n \"evidence\": \"what in the output supports this\"}\n ],\n \"feedback\": \"\",\n \"rejection_scope\": \"\"\n}\n\nRules:\n- \"approved\" is true ONLY if every criterion passes.\n- When \"approved\" is false, \"feedback\" MUST be specific, actionable instructions\n the stage agent can follow to fix the failing criteria in a SINGLE revision:\n name the concrete fields, decisions, or tool calls to change and why. Do NOT\n rewrite the output yourself; tell the agent what to do.\n- When \"approved\" is true, \"feedback\" is an empty string.\n- When \"approved\" is false, \"rejection_scope\" says how much of the output the\n stage agent should rebuild. It changes what the agent is told to PRESERVE:\n \"partial\" -- the failing criteria are LOCALIZED. Every other decision in the\n output is sound, and the agent will be instructed to reproduce it exactly.\n Use this for a specific, fixable defect: a wrong encoding, a missed\n coercion, one column dropped that should have been kept.\n \"full\" -- the stage's APPROACH is wrong at its foundation, so preserving the\n surrounding decisions would preserve the problem. The agent will be told to\n reconsider the stage from first principles.\n Choose \"partial\" unless the approach ITSELF is the defect; \"full\" discards\n sound work and costs the stage its only revision. An omitted or unrecognized\n value is treated as \"full\".\n- When \"approved\" is true, \"rejection_scope\" is an empty string.\n- Be strict but fair: approve work that meets the criteria.\n- Emit the JSON object as the very first content of your response; never precede\n it with commentary. If every criterion is satisfied, still return the complete\n verdict object -- silence or a task description is not a valid response.\n- After the JSON object, on its own final line, also emit a single machine-readable\n verdict anchor -- literally `VERDICT: APPROVED` or `VERDICT: REJECTED` -- that\n matches the \"approved\" field. On a rejection, follow it with a second anchor\n line -- literally `SCOPE: PARTIAL` or `SCOPE: FULL` -- matching\n \"rejection_scope\". The JSON object stays the source of truth; these lines are\n only fallback anchors for automated parsing.\n",
"enabled": true,
"max_revisions": 1
},
@@ -427,51 +427,11 @@
"factor_bindings": {}
}
},
- {
- "id": "agent-score",
- "type": "agent",
- "position": {
- "x": 2240,
- "y": 0
- },
- "data": {
- "label": "MI-Score",
- "config": {
- "prompt": "",
- "goal": "Score MI-MLM's approved hyperparameter payload against the held-out test split. Call run_model_script with NO `code` argument -- the wired Script node reaches the tool as a path in ambient run context and executes byte-for-byte as written; retyping it is the one thing that could corrupt it. Pass only payload_json, set to the approved payload (as compact JSON) from the upstream context -- do not alter it. Report the returned test_metrics and the code_sha256/payload_sha256 guards verbatim; never compute or estimate a metric yourself.",
- "description": "Runs the harness-fixed XGBoost+Optuna scoring script against MI-MLM's approved payload via run_model_script (the script arrives as an ambient path, never pasted into the prompt) and reports the held-out test metrics. Unlike the real notebook (a direct, non-agent call with SHA guards), this is an ASAREE agent so the canvas can demonstrate the Script node end-to-end; the same SHA guards are still enforced server-side and reported back verbatim.",
- "system_prompt": "",
- "output_contract": null,
- "budget_limit_usd": 200.0,
- "max_run_duration_seconds": 86400,
- "require_output_parser": true
- },
- "factor_bindings": {}
- }
- },
- {
- "id": "pattern-score",
- "type": "pattern_reason_act",
- "position": {
- "x": 2248,
- "y": -220
- },
- "data": {
- "label": "",
- "config": {
- "max_iterations": 15,
- "include_scratchpad": true,
- "scratchpad_window": 10,
- "observation_format": "raw"
- },
- "factor_bindings": {}
- }
- },
{
"id": "tool-model-score",
"type": "mcp_tool",
"position": {
- "x": 2314,
+ "x": 2354,
"y": 240
},
"data": {
@@ -491,7 +451,7 @@
"id": "script-model",
"type": "script",
"position": {
- "x": 2494,
+ "x": 2534,
"y": 240
},
"data": {
@@ -499,7 +459,7 @@
"config": {
"name": "fixed_model_script",
"language": "python",
- "code": "CV_FOLDS = 5\nCV_FOLD_SEED = 20260701\nOPTUNA_SAMPLER_SEED = 20260702\nXGB_SEED = 20260703\nVAL_SPLIT_SEED = 20260704\nVAL_FRACTION = 0.15\n\n# ==================================================================\n# ASAREE fixed modeling script (harness-owned; the MLM agent does NOT\n# write this). The agent supplies only the typed payload, bound as hp.\n# Everything here is deterministic given hp and the seeds baked above,\n# so replicate variance reflects LLM stochasticity in the payload only.\n#\n# Pre-bound by run_model_script: hp, X_train, y_train, np, pd. The test\n# split is NEVER bound; this script defines predict_proba(X) and\n# chosen_threshold, and the runner scores the held-out 30 itself.\n#\n# Harness-fixed policy (not agent-controllable):\n# inner CV 5-fold StratifiedKFold, fixed global fold assignment\n# objective PR-AUC (average_precision) on the training folds only\n# pos weighting scale_pos_weight = n_neg/n_pos on the fitted rows\n# calibration isotonic on one held-out training val fold\n# threshold argmax-F1 on the val fold; Brier reported on the val fold\n# no early stopping\n# Collaborator note: isotonic is monotonic, so calibration cannot change\n# PR-AUC or ROC-AUC (both rank-based); it only moves Brier and the\n# threshold-dependent secondaries, none of which is a tested outcome. The\n# scored model is fit on train-minus-val, so its PR-AUC/ROC-AUC equal the\n# base model ranking and the descriptive-only claim holds exactly.\n# ==================================================================\nimport optuna\nfrom optuna.samplers import TPESampler\nfrom sklearn.model_selection import StratifiedKFold, train_test_split\nfrom sklearn.metrics import average_precision_score, f1_score, brier_score_loss\nfrom sklearn.isotonic import IsotonicRegression\nfrom xgboost import XGBClassifier\n\noptuna.logging.set_verbosity(optuna.logging.WARNING)\n\n_INT_PARAMS = {\"n_estimators\", \"max_depth\"}\n_LOG_PARAMS = {\"learning_rate\", \"reg_lambda\", \"reg_alpha\"}\n\n_search = {}\nfor _e in hp[\"search_space\"]:\n _search[_e[\"param\"]] = (float(_e[\"low\"]), float(_e[\"high\"]))\n_fixed = dict(hp[\"fixed_params\"])\nfor _p in _INT_PARAMS:\n if _p in _fixed:\n _fixed[_p] = int(round(float(_fixed[_p])))\n_n_trials = int(hp[\"n_trials\"])\n\n_Xtr = X_train.reset_index(drop=True)\n_ytr = np.asarray(y_train).astype(int).ravel()\n\n\ndef _spw(y):\n pos = int((y == 1).sum())\n neg = int((y == 0).sum())\n return (neg / pos) if pos > 0 else 1.0\n\n\ndef _suggest(trial):\n params = dict(_fixed)\n for name, bounds in _search.items():\n lo, hi = bounds\n if name in _INT_PARAMS:\n params[name] = trial.suggest_int(name, int(round(lo)), int(round(hi)))\n elif name in _LOG_PARAMS:\n params[name] = trial.suggest_float(name, lo, hi, log=True)\n else:\n params[name] = trial.suggest_float(name, lo, hi)\n return params\n\n\ndef _make_model(params, spw):\n return XGBClassifier(\n objective=\"binary:logistic\",\n eval_metric=\"logloss\",\n tree_method=\"hist\",\n n_jobs=1,\n random_state=XGB_SEED,\n scale_pos_weight=spw,\n **params,\n )\n\n\n_cv = StratifiedKFold(n_splits=CV_FOLDS, shuffle=True, random_state=CV_FOLD_SEED)\n\n\ndef _objective(trial):\n params = _suggest(trial)\n scores = []\n for tr_idx, va_idx in _cv.split(_Xtr, _ytr):\n Xt = _Xtr.iloc[tr_idx]\n Xv = _Xtr.iloc[va_idx]\n yt = _ytr[tr_idx]\n yv = _ytr[va_idx]\n model = _make_model(params, _spw(yt))\n model.fit(Xt, yt)\n proba = model.predict_proba(Xv)[:, 1]\n scores.append(average_precision_score(yv, proba))\n return float(np.mean(scores))\n\n\n_study = optuna.create_study(direction=\"maximize\", sampler=TPESampler(seed=OPTUNA_SAMPLER_SEED))\n_study.optimize(_objective, n_trials=_n_trials, show_progress_bar=False)\n\n_best = dict(_fixed)\n_best.update(_study.best_params)\nfor _p in _INT_PARAMS:\n if _p in _best:\n _best[_p] = int(round(float(_best[_p])))\n\n_idx = np.arange(len(_Xtr))\n_fit_idx, _val_idx = train_test_split(\n _idx, test_size=VAL_FRACTION, stratify=_ytr, random_state=VAL_SPLIT_SEED\n)\n_Xfit = _Xtr.iloc[_fit_idx]\n_Xval = _Xtr.iloc[_val_idx]\n_yfit = _ytr[_fit_idx]\n_yval = _ytr[_val_idx]\n\n_base = _make_model(_best, _spw(_yfit))\n_base.fit(_Xfit, _yfit)\n\n_val_raw = _base.predict_proba(_Xval)[:, 1]\n_iso = IsotonicRegression(out_of_bounds=\"clip\")\n_iso.fit(_val_raw, _yval)\n\n\ndef predict_proba(X):\n raw = _base.predict_proba(X)[:, 1]\n return _iso.predict(raw)\n\n\n_val_cal = _iso.predict(_val_raw)\n_grid = np.unique(np.concatenate([np.array([0.0, 1.0]), _val_cal]))\n_best_t, _best_f1 = 0.5, -1.0\nfor _t in _grid:\n _f1 = f1_score(_yval, (_val_cal >= _t).astype(int), zero_division=0)\n if _f1 > _best_f1:\n _best_f1, _best_t = _f1, float(_t)\nchosen_threshold = float(_best_t)\n\n_check = predict_proba(_Xtr.head(5))\nassert _check.shape[0] == 5\n\nresult = {\n \"best_hyperparameters\": {k: (int(v) if k in _INT_PARAMS else float(v)) for k, v in _best.items()},\n \"tuned_params\": sorted(_search.keys()),\n \"n_trials\": _n_trials,\n \"optuna_best_value\": float(_study.best_value),\n \"cv_metric\": \"average_precision\",\n \"cv_folds\": CV_FOLDS,\n \"calibration\": \"isotonic\",\n \"calibration_brier_val\": float(brier_score_loss(_yval, _val_cal)),\n \"val_fraction\": VAL_FRACTION,\n \"chosen_threshold\": chosen_threshold,\n \"chosen_threshold_val_f1\": float(_best_f1),\n \"scale_pos_weight_fit\": float(_spw(_yfit)),\n \"n_train_fit\": int(len(_fit_idx)),\n \"n_val\": int(len(_val_idx)),\n}\n"
+ "code": "CV_FOLDS = 5\nCV_FOLD_SEED = 20260701\nOPTUNA_SAMPLER_SEED = 20260702\nXGB_SEED = 20260703\nVAL_SPLIT_SEED = 20260704\nVAL_FRACTION = 0.15\n\n# ==================================================================\n# ASAREE fixed modeling script (harness-owned; the MLM agent does NOT\n# write this). The agent supplies only the typed payload, bound as hp.\n# Everything here is deterministic given hp and the seeds baked above,\n# so replicate variance reflects LLM stochasticity in the payload only.\n#\n# Pre-bound by run_model_script: hp, X_train, y_train, np, pd. The test\n# split is NEVER bound; this script defines predict_proba(X) and\n# chosen_threshold, and the runner scores the held-out 30 itself.\n#\n# Harness-fixed policy (not agent-controllable):\n# inner CV 5-fold StratifiedKFold, fixed global fold assignment\n# objective PR-AUC (average_precision) on the training folds only\n# pos weighting scale_pos_weight = n_neg/n_pos on the fitted rows\n# calibration isotonic on one held-out training val fold\n# threshold argmax-F1 on the val fold; Brier reported on the val fold\n# no early stopping\n# Collaborator note: isotonic is monotonic, so calibration cannot change\n# PR-AUC or ROC-AUC (both rank-based); it only moves Brier and the\n# threshold-dependent secondaries, none of which is a tested outcome. The\n# scored model is fit on train-minus-val, so its PR-AUC/ROC-AUC equal the\n# base model ranking and the descriptive-only claim holds exactly.\n# ==================================================================\nimport optuna\nfrom optuna.samplers import TPESampler\nfrom sklearn.model_selection import StratifiedKFold, train_test_split\nfrom sklearn.metrics import average_precision_score, f1_score, brier_score_loss\nfrom sklearn.isotonic import IsotonicRegression\nfrom xgboost import XGBClassifier\n\noptuna.logging.set_verbosity(optuna.logging.WARNING)\n\n_INT_PARAMS = {\"n_estimators\", \"max_depth\"}\n_LOG_PARAMS = {\"learning_rate\", \"reg_lambda\", \"reg_alpha\"}\n\n_search = {}\nfor _e in hp[\"search_space\"]:\n _search[_e[\"param\"]] = (float(_e[\"low\"]), float(_e[\"high\"]))\n_fixed = {\n \"n_estimators\": 100,\n \"max_depth\": 6,\n \"learning_rate\": 0.3,\n \"min_child_weight\": 1.0,\n \"gamma\": 0.0,\n \"subsample\": 1.0,\n \"colsample_bytree\": 1.0,\n \"reg_lambda\": 1.0,\n \"reg_alpha\": 0.0,\n}\n_fixed.update(hp[\"fixed_params\"])\nfor _p in _INT_PARAMS:\n if _p in _fixed:\n _fixed[_p] = int(round(float(_fixed[_p])))\n_n_trials = int(hp[\"n_trials\"])\n\n_Xtr = X_train.reset_index(drop=True)\n_ytr = np.asarray(y_train).astype(int).ravel()\n\n\ndef _spw(y):\n pos = int((y == 1).sum())\n neg = int((y == 0).sum())\n return (neg / pos) if pos > 0 else 1.0\n\n\ndef _suggest(trial):\n params = dict(_fixed)\n for name, bounds in _search.items():\n lo, hi = bounds\n if name in _INT_PARAMS:\n params[name] = trial.suggest_int(name, int(round(lo)), int(round(hi)))\n elif name in _LOG_PARAMS:\n params[name] = trial.suggest_float(name, lo, hi, log=True)\n else:\n params[name] = trial.suggest_float(name, lo, hi)\n return params\n\n\ndef _make_model(params, spw):\n return XGBClassifier(\n objective=\"binary:logistic\",\n eval_metric=\"logloss\",\n tree_method=\"hist\",\n n_jobs=1,\n random_state=XGB_SEED,\n scale_pos_weight=spw,\n **params,\n )\n\n\n_cv = StratifiedKFold(n_splits=CV_FOLDS, shuffle=True, random_state=CV_FOLD_SEED)\n\n\ndef _objective(trial):\n params = _suggest(trial)\n scores = []\n for tr_idx, va_idx in _cv.split(_Xtr, _ytr):\n Xt = _Xtr.iloc[tr_idx]\n Xv = _Xtr.iloc[va_idx]\n yt = _ytr[tr_idx]\n yv = _ytr[va_idx]\n model = _make_model(params, _spw(yt))\n model.fit(Xt, yt)\n proba = model.predict_proba(Xv)[:, 1]\n scores.append(average_precision_score(yv, proba))\n return float(np.mean(scores))\n\n\n_study = optuna.create_study(direction=\"maximize\", sampler=TPESampler(seed=OPTUNA_SAMPLER_SEED))\n_study.optimize(_objective, n_trials=_n_trials, show_progress_bar=False)\n\n_best = dict(_fixed)\n_best.update(_study.best_params)\nfor _p in _INT_PARAMS:\n if _p in _best:\n _best[_p] = int(round(float(_best[_p])))\n\n_idx = np.arange(len(_Xtr))\n_fit_idx, _val_idx = train_test_split(\n _idx, test_size=VAL_FRACTION, stratify=_ytr, random_state=VAL_SPLIT_SEED\n)\n_Xfit = _Xtr.iloc[_fit_idx]\n_Xval = _Xtr.iloc[_val_idx]\n_yfit = _ytr[_fit_idx]\n_yval = _ytr[_val_idx]\n\n_base = _make_model(_best, _spw(_yfit))\n_base.fit(_Xfit, _yfit)\n\n_val_raw = _base.predict_proba(_Xval)[:, 1]\n_iso = IsotonicRegression(out_of_bounds=\"clip\")\n_iso.fit(_val_raw, _yval)\n\n\ndef predict_proba(X):\n raw = _base.predict_proba(X)[:, 1]\n return _iso.predict(raw)\n\n\n_val_cal = _iso.predict(_val_raw)\n_grid = np.unique(np.concatenate([np.array([0.0, 1.0]), _val_cal]))\n_best_t, _best_f1 = 0.5, -1.0\nfor _t in _grid:\n _f1 = f1_score(_yval, (_val_cal >= _t).astype(int), zero_division=0)\n if _f1 > _best_f1:\n _best_f1, _best_t = _f1, float(_t)\nchosen_threshold = float(_best_t)\n\n_check = predict_proba(_Xtr.head(5))\nassert _check.shape[0] == 5\n\nresult = {\n \"best_hyperparameters\": {k: (int(v) if k in _INT_PARAMS else float(v)) for k, v in _best.items()},\n \"tuned_params\": sorted(_search.keys()),\n \"n_trials\": _n_trials,\n \"optuna_best_value\": float(_study.best_value),\n \"cv_metric\": \"average_precision\",\n \"cv_folds\": CV_FOLDS,\n \"calibration\": \"isotonic\",\n \"calibration_brier_val\": float(brier_score_loss(_yval, _val_cal)),\n \"val_fraction\": VAL_FRACTION,\n \"chosen_threshold\": chosen_threshold,\n \"chosen_threshold_val_f1\": float(_best_f1),\n \"scale_pos_weight_fit\": float(_spw(_yfit)),\n \"n_train_fit\": int(len(_fit_idx)),\n \"n_val\": int(len(_val_idx)),\n}\n"
},
"factor_bindings": {}
}
@@ -535,7 +495,7 @@
{
"name": "domain_violations",
"type": "array",
- "description": "Domain-rule violations found and corrected"
+ "description": "list of {feature, rule, n_coerced}"
},
{
"name": "missingness_threshold",
@@ -545,17 +505,17 @@
{
"name": "dropped_for_missingness",
"type": "array",
- "description": "Columns dropped for exceeding the missingness threshold"
+ "description": "list of [feature, raw_missing_pct]"
},
{
"name": "outliers",
"type": "array",
- "description": "Outlier handling summary per column"
+ "description": "list of {feature, method, n_flagged, n_deleted, reason}"
},
{
"name": "imputation",
"type": "array",
- "description": "Imputation strategy used per column"
+ "description": "list of {feature_or_group, dtype, strategy, reason}"
},
{
"name": "notes_for_fte",
@@ -585,17 +545,17 @@
{
"name": "engineering_recipe",
"type": "array",
- "description": "Feature-engineering steps applied"
+ "description": "list of {name, op, inputs, params, rationale, components_retained}"
},
{
"name": "encoding_map",
- "type": "object",
- "description": "Categorical encoding scheme per column"
+ "type": "array",
+ "description": "list of {feature, encoding, order_or_bins, reason}"
},
{
"name": "n_features_out",
"type": "integer",
- "description": "Feature count after engineering/encoding"
+ "description": "number of columns in the committed v2_fte matrix"
},
{
"name": "notes_for_fs",
@@ -632,16 +592,6 @@
"type": "integer",
"description": "Feature count after selection"
},
- {
- "name": "n_engineered_features_selected",
- "type": "integer",
- "description": "Selected features created by the upstream engineering recipe"
- },
- {
- "name": "pct_engineered_features_selected",
- "type": "number",
- "description": "Percentage of selected features created by the upstream engineering recipe"
- },
{
"name": "variance_threshold",
"type": "number",
@@ -660,7 +610,7 @@
{
"name": "dropped_features",
"type": "array",
- "description": "Features dropped during selection"
+ "description": "list of {feature, reason}"
},
{
"name": "selected_features",
@@ -705,22 +655,22 @@
{
"name": "search_space",
"type": "array",
- "description": "Hyperparameter search space proposal"
+ "description": "list of {param, low, high}: the XGBoost params to tune and their bounds; at least 3 distinct params"
},
{
"name": "fixed_params",
"type": "object",
- "description": "Fixed (non-searched) XGBoost params"
+ "description": "map of param -> constant value for every param NOT in search_space"
},
{
"name": "n_trials",
"type": "integer",
- "description": "Proposed Optuna trial count"
+ "description": "number of Optuna trials to run, 1-100"
},
{
"name": "rationale",
"type": "string",
- "description": "Rationale for the proposed search space"
+ "description": "one specific reason per non-default search_space/fixed_params choice, tied to the imbalanced PR-AUC task"
}
]
},
@@ -730,43 +680,58 @@
}
},
{
- "id": "output-parser-score",
- "type": "output_parser",
+ "id": "tool-step-score",
+ "type": "tool_step",
"position": {
- "x": 2476,
- "y": 160
+ "x": 2240,
+ "y": 0
},
"data": {
- "label": "MI-Score Output Parser",
+ "label": "Score (run_model_script)",
+ "active": true,
"config": {
- "output_contract": {
- "name": "ScoreReport",
- "fields": [
- {
- "name": "test_metrics",
- "type": "object",
- "description": "Held-out test metrics computed server-side"
- },
- {
- "name": "code_sha256",
- "type": "string",
- "description": "Hash of the executed script, for the verbatim-execution guard"
- },
- {
- "name": "payload_sha256",
- "type": "string",
- "description": "Hash of the scored payload, for the approved-payload guard"
- },
- {
- "name": "model_decisions",
- "type": "object",
- "description": "Train-side decisions the script itself reported (tuning, calibration, threshold)"
- }
- ]
+ "tool_name": "run_model_script",
+ "arguments": {
+ "code": {
+ "source": "script_code"
+ },
+ "payload_json": {
+ "source": "upstream_payload",
+ "format": "json_string"
+ },
+ "workspace_id": {
+ "source": "workspace_id"
+ },
+ "random_seed": {
+ "source": "value",
+ "value": 20260705
+ },
+ "task_type": {
+ "source": "value",
+ "value": "binary"
+ },
+ "positive_label": {
+ "source": "value",
+ "value": "1"
+ },
+ "selection_metric": {
+ "source": "value",
+ "value": "average_precision"
+ },
+ "param_spec_json": {
+ "source": "value",
+ "value": "{\"n_estimators\":{\"type\":\"int\",\"log\":false,\"low\":100,\"high\":1000},\"max_depth\":{\"type\":\"int\",\"log\":false,\"low\":3,\"high\":10},\"learning_rate\":{\"type\":\"float\",\"log\":true,\"low\":0.001,\"high\":0.3},\"min_child_weight\":{\"type\":\"float\",\"log\":false,\"low\":1,\"high\":20},\"gamma\":{\"type\":\"float\",\"log\":false,\"low\":0,\"high\":5},\"subsample\":{\"type\":\"float\",\"log\":false,\"low\":0.5,\"high\":1.0},\"colsample_bytree\":{\"type\":\"float\",\"log\":false,\"low\":0.5,\"high\":1.0},\"reg_lambda\":{\"type\":\"float\",\"log\":true,\"low\":0.001,\"high\":10},\"reg_alpha\":{\"type\":\"float\",\"log\":true,\"low\":0.001,\"high\":10}}"
+ },
+ "default_n_trials": {
+ "source": "value",
+ "value": 30
+ }
},
- "enabled": true
- },
- "factor_bindings": {}
+ "hash_checks": {
+ "code_sha256": "code",
+ "payload_sha256": "payload_json"
+ }
+ }
}
}
],
@@ -799,7 +764,19 @@
{
"id": "e-gatefs-to-mlm",
"source": "gate-fs",
- "target": "agent-mlm"
+ "target": "agent-mlm",
+ "data": {
+ "handoff": {
+ "mode": "selected",
+ "fields": [
+ {"name": "selected_features"},
+ {"name": "n_features_out"},
+ {"name": "observed_class_distribution"},
+ {"name": "class_balance_check"},
+ {"name": "notes_for_mlm"}
+ ]
+ }
+ }
},
{
"id": "e-mlm-to-gatemlm",
@@ -953,39 +930,6 @@
"sourceHandle": "dataset",
"targetHandle": "dataset"
},
- {
- "id": "e-gatemlm-to-score",
- "source": "gate-mlm",
- "target": "agent-score"
- },
- {
- "id": "e-llm-score",
- "source": "llm-shared",
- "target": "agent-score",
- "sourceHandle": "model",
- "targetHandle": "model"
- },
- {
- "id": "e-pattern-score",
- "source": "pattern-score",
- "target": "agent-score",
- "sourceHandle": "architectural_pattern",
- "targetHandle": "architectural_pattern"
- },
- {
- "id": "e-tool-model-score",
- "source": "tool-model-score",
- "target": "agent-score",
- "sourceHandle": "tool",
- "targetHandle": "tool"
- },
- {
- "id": "e-script-model-score",
- "source": "script-model",
- "target": "agent-score",
- "sourceHandle": "tool",
- "targetHandle": "tool"
- },
{
"id": "e-output-parser-dc",
"source": "output-parser-dc",
@@ -1015,11 +959,23 @@
"targetHandle": "output_parser"
},
{
- "id": "e-output-parser-score",
- "source": "output-parser-score",
- "target": "agent-score",
- "sourceHandle": "output_parser",
- "targetHandle": "output_parser"
+ "id": "e-gatemlm-to-score",
+ "source": "gate-mlm",
+ "target": "tool-step-score"
+ },
+ {
+ "id": "e-tool-model-score",
+ "source": "tool-model-score",
+ "target": "tool-step-score",
+ "sourceHandle": "tool",
+ "targetHandle": "tool"
+ },
+ {
+ "id": "e-script-model-score",
+ "source": "script-model",
+ "target": "tool-step-score",
+ "sourceHandle": "tool",
+ "targetHandle": "tool"
}
]
},
@@ -1031,7 +987,11 @@
"gpt-5-mini",
"gpt-5"
],
- "level_type": "string"
+ "level_type": "string",
+ "level_labels": [
+ "gpt-5-mini",
+ "gpt-5"
+ ]
},
{
"name": "OpenAI:Effort",
@@ -1039,7 +999,11 @@
"medium",
"high"
],
- "level_type": "string"
+ "level_type": "string",
+ "level_labels": [
+ "medium",
+ "high"
+ ]
},
{
"name": "Critic enabled",
@@ -1176,16 +1140,6 @@
"aggregation": "sum",
"unit": "reviews"
},
- {
- "id": "model-evaluation",
- "name": "model_evaluation",
- "description": "Complete held-out scoring response from run_model_script.",
- "kind": "custom",
- "valueType": "opaque",
- "primary": false,
- "direction": "neutral",
- "aggregation": "none"
- },
{
"id": "pr-auc",
"name": "pr_auc",
@@ -1500,6 +1454,17 @@
"aggregation": "mean",
"unit": "features"
},
+ {
+ "id": "n-features-created",
+ "name": "n_features_created",
+ "description": "Features created by FTE: n_features_after_fte - n_features_after_dc, computed from the stage payloads.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "neutral",
+ "aggregation": "mean",
+ "unit": "features"
+ },
{
"id": "n-features-after-fs",
"name": "n_features_after_fs",
@@ -1514,7 +1479,7 @@
{
"id": "n-engineered-features-selected",
"name": "n_engineered_features_selected",
- "description": "Engineered features surviving feature selection.",
+ "description": "Selected features that FTE created (not raw columns), computed from FS's selected_features.",
"kind": "custom",
"valueType": "number",
"primary": false,
@@ -1523,15 +1488,15 @@
"unit": "features"
},
{
- "id": "pct-engineered-features-selected",
- "name": "pct_engineered_features_selected",
- "description": "Engineered features as a percentage of selected features.",
+ "id": "frac-created-selected",
+ "name": "frac_created_selected",
+ "description": "Fraction of selected features that FTE created (not raw columns), computed from FS's selected_features.",
"kind": "custom",
"valueType": "number",
"primary": false,
"direction": "neutral",
"aggregation": "mean",
- "unit": "percent"
+ "unit": "fraction"
},
{
"id": "variance-threshold",
@@ -1705,127 +1670,400 @@
"primary": false,
"direction": "neutral",
"aggregation": "none"
- }
- ],
- "coordination_strategy": {
- "slug": "critic_gate"
- }
- },
- "measurement_plan": {
- "metrics": [
+ },
{
- "id": "runtime-cost",
- "name": "Cost",
- "value_type": "number",
+ "id": "n-schema-violations",
+ "name": "n_schema_violations",
+ "description": "MLM payload entries run_model_script dropped against the Tool Step's param_spec_json before fitting (0 = the payload was already clean).",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
"direction": "minimize",
- "aggregation": "sum",
+ "aggregation": "mean",
+ "unit": "entries"
+ },
+ {
+ "id": "threshold-prevalence",
+ "name": "threshold_prevalence",
+ "description": "Decision threshold equal to the observed test-set positive rate.",
+ "kind": "custom",
+ "valueType": "number",
"primary": false,
- "description": "Estimated provider cost for the run.",
- "unit": "USD"
+ "direction": "neutral",
+ "aggregation": "mean"
},
{
- "id": "runtime-duration",
- "name": "Duration",
- "value_type": "number",
- "direction": "minimize",
- "aggregation": "sum",
+ "id": "runtime-critic-invocations",
+ "catalogKey": "critic_invocations",
+ "name": "Critic invocations",
+ "description": "Critic-gate reviews run during the run.",
+ "kind": "runtime",
+ "valueType": "number",
"primary": false,
- "description": "Wall-clock duration of the run.",
- "unit": "seconds"
+ "direction": "neutral",
+ "aggregation": "sum",
+ "unit": "reviews"
},
{
- "id": "runtime-input-tokens",
- "name": "Input tokens",
- "value_type": "number",
+ "id": "runtime-critic-rejections-partial",
+ "catalogKey": "critic_rejections_partial",
+ "name": "Partial critic rejections",
+ "description": "Rejections scoped to a targeted correction.",
+ "kind": "runtime",
+ "valueType": "number",
+ "primary": false,
"direction": "minimize",
"aggregation": "sum",
- "primary": false,
- "description": "Tokens sent to the model during the run.",
- "unit": "tokens"
+ "unit": "reviews"
},
{
- "id": "runtime-output-tokens",
- "name": "Output tokens",
- "value_type": "number",
+ "id": "runtime-critic-rejections-full",
+ "catalogKey": "critic_rejections_full",
+ "name": "Full critic rejections",
+ "description": "Rejections asking the stage to reconsider its approach (includes unscoped ones).",
+ "kind": "runtime",
+ "valueType": "number",
+ "primary": false,
"direction": "minimize",
"aggregation": "sum",
- "primary": false,
- "description": "Tokens generated by the model during the run.",
- "unit": "tokens"
+ "unit": "reviews"
},
{
- "id": "runtime-total-tokens",
- "name": "Total tokens",
- "value_type": "number",
+ "id": "runtime-revision-rounds",
+ "catalogKey": "revision_rounds",
+ "name": "Revision rounds",
+ "description": "Worker reruns triggered by critic rejections.",
+ "kind": "runtime",
+ "valueType": "number",
+ "primary": false,
"direction": "minimize",
"aggregation": "sum",
- "primary": false,
- "description": "Combined input and output tokens for the run.",
- "unit": "tokens"
+ "unit": "rounds"
},
{
- "id": "runtime-tool-calls",
- "name": "Tool calls",
- "value_type": "number",
+ "id": "runtime-capped-agent-runs",
+ "catalogKey": "capped_agent_runs",
+ "name": "Capped agent runs",
+ "description": "Reason+Act runs cut off by their iteration ceiling.",
+ "kind": "runtime",
+ "valueType": "number",
+ "primary": false,
"direction": "minimize",
"aggregation": "sum",
- "primary": false,
- "description": "Recorded tool-call attempts across attributed Agent runs.",
- "unit": "calls"
+ "unit": "runs"
},
{
- "id": "runtime-tool-error-rate",
- "name": "Tool error rate",
- "value_type": "number",
- "direction": "minimize",
- "aggregation": "mean",
+ "id": "runtime-agent-runs",
+ "catalogKey": "agent_runs",
+ "name": "Agent runs",
+ "description": "Every agent and critic run the attempt launched, revisions included.",
+ "kind": "runtime",
+ "valueType": "number",
"primary": false,
- "description": "Failed tool calls divided by all tool-call attempts.",
- "unit": "rate"
+ "direction": "neutral",
+ "aggregation": "sum",
+ "unit": "runs"
},
{
- "id": "runtime-agent-loop-iterations",
- "name": "Agent-loop iterations",
- "value_type": "number",
- "direction": "minimize",
- "aggregation": "sum",
+ "id": "runtime-react-runs",
+ "catalogKey": "react_runs",
+ "name": "Reason+Act runs",
+ "description": "Runs that went through a Reason+Act loop.",
+ "kind": "runtime",
+ "valueType": "number",
"primary": false,
- "description": "Distinct Agent reasoning-loop iterations in the run.",
- "unit": "iterations"
+ "direction": "neutral",
+ "aggregation": "sum",
+ "unit": "runs"
},
{
- "id": "runtime-critic-rejections",
- "name": "Critic rejections",
- "value_type": "number",
+ "id": "runtime-react-turns",
+ "catalogKey": "react_turns",
+ "name": "Reason+Act turns",
+ "description": "Loop iterations reported by Reason+Act runs.",
+ "kind": "runtime",
+ "valueType": "number",
+ "primary": false,
"direction": "minimize",
"aggregation": "sum",
- "primary": false,
- "description": "Rejected critic-gate reviews during the run.",
- "unit": "reviews"
+ "unit": "turns"
},
{
- "id": "runtime-critic-approvals",
- "name": "Critic approvals",
- "value_type": "number",
+ "id": "runtime-react-tool-calls",
+ "catalogKey": "react_tool_calls",
+ "name": "Reason+Act tool calls",
+ "description": "Tool calls counted by Reason+Act loops.",
+ "kind": "runtime",
+ "valueType": "number",
+ "primary": false,
"direction": "neutral",
"aggregation": "sum",
- "primary": false,
- "description": "Critic gates ending in an actual approval.",
- "unit": "reviews"
+ "unit": "calls"
},
{
- "id": "model-evaluation",
- "name": "model_evaluation",
- "value_type": "opaque",
- "direction": "neutral",
- "aggregation": "none",
+ "id": "runtime-prompt-sha256",
+ "catalogKey": "prompt_sha256",
+ "name": "Prompt SHA-256",
+ "description": "Hash of the published stage prompts, critic criteria and output contracts.",
+ "kind": "runtime",
+ "valueType": "opaque",
"primary": false,
- "description": "Complete held-out scoring response from run_model_script."
+ "direction": "neutral",
+ "aggregation": "none"
},
{
- "id": "pr-auc",
- "name": "pr_auc",
- "value_type": "number",
+ "id": "tokens-dc",
+ "name": "tokens_dc",
+ "description": "Input plus output tokens across every run of the DC stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-dc",
+ "name": "n_turns_dc",
+ "description": "Agent-loop turns across every run of the DC stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-fte",
+ "name": "tokens_fte",
+ "description": "Input plus output tokens across every run of the FTE stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-fte",
+ "name": "n_turns_fte",
+ "description": "Agent-loop turns across every run of the FTE stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-fs",
+ "name": "tokens_fs",
+ "description": "Input plus output tokens across every run of the FS stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-fs",
+ "name": "n_turns_fs",
+ "description": "Agent-loop turns across every run of the FS stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-mlm",
+ "name": "tokens_mlm",
+ "description": "Input plus output tokens across every run of the MLM stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-mlm",
+ "name": "n_turns_mlm",
+ "description": "Agent-loop turns across every run of the MLM stage, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-critic",
+ "name": "tokens_critic",
+ "description": "Input plus output tokens across every run of the four critic gates, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-critic",
+ "name": "n_turns_critic",
+ "description": "Agent-loop turns across every run of the four critic gates, revisions included.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "minimize",
+ "aggregation": "sum",
+ "unit": "turns"
+ },
+ {
+ "id": "n-recipe-ops",
+ "name": "n_recipe_ops",
+ "description": "Entries in the FTE engineering_recipe.",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "neutral",
+ "aggregation": "mean",
+ "unit": "ops"
+ },
+ {
+ "id": "recipe-depth",
+ "name": "recipe_depth",
+ "description": "Longest derived-from-derived chain in the FTE recipe (1 by construction when the recipe is non-empty).",
+ "kind": "custom",
+ "valueType": "number",
+ "primary": false,
+ "direction": "neutral",
+ "aggregation": "mean",
+ "unit": "levels"
+ },
+ {
+ "id": "recipe-hash",
+ "name": "recipe_hash",
+ "description": "SHA-256 of the FTE recipe structure (name/op/inputs/params), independent of entry order; groups cells that engineered the same features.",
+ "kind": "custom",
+ "valueType": "opaque",
+ "primary": false,
+ "direction": "neutral",
+ "aggregation": "none"
+ }
+ ],
+ "coordination_strategy": {
+ "slug": "critic_gate"
+ }
+ },
+ "measurement_plan": {
+ "metrics": [
+ {
+ "id": "runtime-cost",
+ "name": "Cost",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Estimated provider cost for the run.",
+ "unit": "USD"
+ },
+ {
+ "id": "runtime-duration",
+ "name": "Duration",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Wall-clock duration of the run.",
+ "unit": "seconds"
+ },
+ {
+ "id": "runtime-input-tokens",
+ "name": "Input tokens",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Tokens sent to the model during the run.",
+ "unit": "tokens"
+ },
+ {
+ "id": "runtime-output-tokens",
+ "name": "Output tokens",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Tokens generated by the model during the run.",
+ "unit": "tokens"
+ },
+ {
+ "id": "runtime-total-tokens",
+ "name": "Total tokens",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Combined input and output tokens for the run.",
+ "unit": "tokens"
+ },
+ {
+ "id": "runtime-tool-calls",
+ "name": "Tool calls",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Recorded tool-call attempts across attributed Agent runs.",
+ "unit": "calls"
+ },
+ {
+ "id": "runtime-tool-error-rate",
+ "name": "Tool error rate",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "mean",
+ "primary": false,
+ "description": "Failed tool calls divided by all tool-call attempts.",
+ "unit": "rate"
+ },
+ {
+ "id": "runtime-agent-loop-iterations",
+ "name": "Agent-loop iterations",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Distinct Agent reasoning-loop iterations in the run.",
+ "unit": "iterations"
+ },
+ {
+ "id": "runtime-critic-rejections",
+ "name": "Critic rejections",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Rejected critic-gate reviews during the run.",
+ "unit": "reviews"
+ },
+ {
+ "id": "runtime-critic-approvals",
+ "name": "Critic approvals",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Critic gates ending in an actual approval.",
+ "unit": "reviews"
+ },
+ {
+ "id": "pr-auc",
+ "name": "pr_auc",
+ "value_type": "number",
"direction": "maximize",
"aggregation": "mean",
"primary": true,
@@ -2106,6 +2344,16 @@
"description": "Feature count after engineering and encoding.",
"unit": "features"
},
+ {
+ "id": "n-features-created",
+ "name": "n_features_created",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "mean",
+ "primary": false,
+ "description": "Features created by FTE: n_features_after_fte - n_features_after_dc, computed from the stage payloads.",
+ "unit": "features"
+ },
{
"id": "n-features-after-fs",
"name": "n_features_after_fs",
@@ -2123,18 +2371,18 @@
"direction": "neutral",
"aggregation": "mean",
"primary": false,
- "description": "Engineered features surviving feature selection.",
+ "description": "Selected features that FTE created (not raw columns), computed from FS's selected_features.",
"unit": "features"
},
{
- "id": "pct-engineered-features-selected",
- "name": "pct_engineered_features_selected",
+ "id": "frac-created-selected",
+ "name": "frac_created_selected",
"value_type": "number",
"direction": "neutral",
"aggregation": "mean",
"primary": false,
- "description": "Engineered features as a percentage of selected features.",
- "unit": "percent"
+ "description": "Fraction of selected features that FTE created (not raw columns), computed from FS's selected_features.",
+ "unit": "fraction"
},
{
"id": "variance-threshold",
@@ -2291,6 +2539,253 @@
"aggregation": "none",
"primary": false,
"description": "SHA-256 provenance hash for the scored data."
+ },
+ {
+ "id": "n-schema-violations",
+ "name": "n_schema_violations",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "mean",
+ "primary": false,
+ "description": "MLM payload entries run_model_script dropped against the Tool Step's param_spec_json before fitting (0 = the payload was already clean).",
+ "unit": "entries"
+ },
+ {
+ "id": "threshold-prevalence",
+ "name": "threshold_prevalence",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "mean",
+ "primary": false,
+ "description": "Decision threshold equal to the observed test-set positive rate."
+ },
+ {
+ "id": "runtime-critic-invocations",
+ "name": "Critic invocations",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Critic-gate reviews run during the run.",
+ "unit": "reviews"
+ },
+ {
+ "id": "runtime-critic-rejections-partial",
+ "name": "Partial critic rejections",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Rejections scoped to a targeted correction.",
+ "unit": "reviews"
+ },
+ {
+ "id": "runtime-critic-rejections-full",
+ "name": "Full critic rejections",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Rejections asking the stage to reconsider its approach (includes unscoped ones).",
+ "unit": "reviews"
+ },
+ {
+ "id": "runtime-revision-rounds",
+ "name": "Revision rounds",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Worker reruns triggered by critic rejections.",
+ "unit": "rounds"
+ },
+ {
+ "id": "runtime-capped-agent-runs",
+ "name": "Capped agent runs",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Reason+Act runs cut off by their iteration ceiling.",
+ "unit": "runs"
+ },
+ {
+ "id": "runtime-agent-runs",
+ "name": "Agent runs",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Every agent and critic run the attempt launched, revisions included.",
+ "unit": "runs"
+ },
+ {
+ "id": "runtime-react-runs",
+ "name": "Reason+Act runs",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Runs that went through a Reason+Act loop.",
+ "unit": "runs"
+ },
+ {
+ "id": "runtime-react-turns",
+ "name": "Reason+Act turns",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Loop iterations reported by Reason+Act runs.",
+ "unit": "turns"
+ },
+ {
+ "id": "runtime-react-tool-calls",
+ "name": "Reason+Act tool calls",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Tool calls counted by Reason+Act loops.",
+ "unit": "calls"
+ },
+ {
+ "id": "runtime-prompt-sha256",
+ "name": "Prompt SHA-256",
+ "value_type": "opaque",
+ "direction": "neutral",
+ "aggregation": "none",
+ "primary": false,
+ "description": "Hash of the published stage prompts, critic criteria and output contracts."
+ },
+ {
+ "id": "tokens-dc",
+ "name": "tokens_dc",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Input plus output tokens across every run of the DC stage, revisions included.",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-dc",
+ "name": "n_turns_dc",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Agent-loop turns across every run of the DC stage, revisions included.",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-fte",
+ "name": "tokens_fte",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Input plus output tokens across every run of the FTE stage, revisions included.",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-fte",
+ "name": "n_turns_fte",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Agent-loop turns across every run of the FTE stage, revisions included.",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-fs",
+ "name": "tokens_fs",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Input plus output tokens across every run of the FS stage, revisions included.",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-fs",
+ "name": "n_turns_fs",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Agent-loop turns across every run of the FS stage, revisions included.",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-mlm",
+ "name": "tokens_mlm",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Input plus output tokens across every run of the MLM stage, revisions included.",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-mlm",
+ "name": "n_turns_mlm",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Agent-loop turns across every run of the MLM stage, revisions included.",
+ "unit": "turns"
+ },
+ {
+ "id": "tokens-critic",
+ "name": "tokens_critic",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Input plus output tokens across every run of the four critic gates, revisions included.",
+ "unit": "tokens"
+ },
+ {
+ "id": "n-turns-critic",
+ "name": "n_turns_critic",
+ "value_type": "number",
+ "direction": "minimize",
+ "aggregation": "sum",
+ "primary": false,
+ "description": "Agent-loop turns across every run of the four critic gates, revisions included.",
+ "unit": "turns"
+ },
+ {
+ "id": "n-recipe-ops",
+ "name": "n_recipe_ops",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "mean",
+ "primary": false,
+ "description": "Entries in the FTE engineering_recipe.",
+ "unit": "ops"
+ },
+ {
+ "id": "recipe-depth",
+ "name": "recipe_depth",
+ "value_type": "number",
+ "direction": "neutral",
+ "aggregation": "mean",
+ "primary": false,
+ "description": "Longest derived-from-derived chain in the FTE recipe (1 by construction when the recipe is non-empty).",
+ "unit": "levels"
+ },
+ {
+ "id": "recipe-hash",
+ "name": "recipe_hash",
+ "value_type": "opaque",
+ "direction": "neutral",
+ "aggregation": "none",
+ "primary": false,
+ "description": "SHA-256 of the FTE recipe structure (name/op/inputs/params), independent of entry order; groups cells that engineered the same features."
}
],
"producers": [
@@ -2308,17 +2803,104 @@
"tool_error_rate": "runtime-tool-error-rate",
"agent_loop_iterations": "runtime-agent-loop-iterations",
"critic_rejections": "runtime-critic-rejections",
- "critic_approvals": "runtime-critic-approvals"
+ "critic_approvals": "runtime-critic-approvals",
+ "critic_invocations": "runtime-critic-invocations",
+ "critic_rejections_partial": "runtime-critic-rejections-partial",
+ "critic_rejections_full": "runtime-critic-rejections-full",
+ "revision_rounds": "runtime-revision-rounds",
+ "capped_agent_runs": "runtime-capped-agent-runs",
+ "agent_runs": "runtime-agent-runs",
+ "react_runs": "runtime-react-runs",
+ "react_turns": "runtime-react-turns",
+ "react_tool_calls": "runtime-react-tool-calls",
+ "prompt_sha256": "runtime-prompt-sha256"
},
"artifacts": [],
"config": {}
},
+ {
+ "id": "runtime-dc",
+ "producer_id": "asaree.node_runtime",
+ "kind": "runtime",
+ "outputs": {
+ "total_tokens": "tokens-dc",
+ "agent_loop_iterations": "n-turns-dc"
+ },
+ "artifacts": [],
+ "config": {
+ "node_ids": [
+ "agent-dc"
+ ]
+ }
+ },
+ {
+ "id": "runtime-fte",
+ "producer_id": "asaree.node_runtime",
+ "kind": "runtime",
+ "outputs": {
+ "total_tokens": "tokens-fte",
+ "agent_loop_iterations": "n-turns-fte"
+ },
+ "artifacts": [],
+ "config": {
+ "node_ids": [
+ "agent-fte"
+ ]
+ }
+ },
+ {
+ "id": "runtime-fs",
+ "producer_id": "asaree.node_runtime",
+ "kind": "runtime",
+ "outputs": {
+ "total_tokens": "tokens-fs",
+ "agent_loop_iterations": "n-turns-fs"
+ },
+ "artifacts": [],
+ "config": {
+ "node_ids": [
+ "agent-fs"
+ ]
+ }
+ },
+ {
+ "id": "runtime-mlm",
+ "producer_id": "asaree.node_runtime",
+ "kind": "runtime",
+ "outputs": {
+ "total_tokens": "tokens-mlm",
+ "agent_loop_iterations": "n-turns-mlm"
+ },
+ "artifacts": [],
+ "config": {
+ "node_ids": [
+ "agent-mlm"
+ ]
+ }
+ },
+ {
+ "id": "runtime-critic",
+ "producer_id": "asaree.node_runtime",
+ "kind": "runtime",
+ "outputs": {
+ "total_tokens": "tokens-critic",
+ "agent_loop_iterations": "n-turns-critic"
+ },
+ "artifacts": [],
+ "config": {
+ "node_ids": [
+ "gate-dc",
+ "gate-fte",
+ "gate-fs",
+ "gate-mlm"
+ ]
+ }
+ },
{
"id": "dc-report-source",
"producer_id": "asaree.agent_output",
"kind": "reported",
"outputs": {
- "n_features_after_dc": "n-features-after-dc",
"missingness_threshold": "missingness-threshold",
"n_dropped_for_missingness": "n-dropped-for-missingness"
},
@@ -2326,9 +2908,6 @@
"config": {
"agent_node_id": "agent-dc",
"projections": {
- "n_features_after_dc": {
- "path": "n_cols_out"
- },
"missingness_threshold": {
"path": "missingness_threshold"
},
@@ -2344,8 +2923,7 @@
"producer_id": "asaree.agent_output",
"kind": "reported",
"outputs": {
- "n_engineered_features": "n-engineered-features",
- "n_features_after_fte": "n-features-after-fte"
+ "n_engineered_features": "n-engineered-features"
},
"artifacts": [],
"config": {
@@ -2354,9 +2932,6 @@
"n_engineered_features": {
"path": "engineering_recipe",
"transform": "length"
- },
- "n_features_after_fte": {
- "path": "n_features_out"
}
}
}
@@ -2366,9 +2941,6 @@
"producer_id": "asaree.agent_output",
"kind": "reported",
"outputs": {
- "n_features_after_fs": "n-features-after-fs",
- "n_engineered_features_selected": "n-engineered-features-selected",
- "pct_engineered_features_selected": "pct-engineered-features-selected",
"variance_threshold": "variance-threshold",
"selected_features": "selected-features"
},
@@ -2376,15 +2948,6 @@
"config": {
"agent_node_id": "agent-fs",
"projections": {
- "n_features_after_fs": {
- "path": "n_features_out"
- },
- "n_engineered_features_selected": {
- "path": "n_engineered_features_selected"
- },
- "pct_engineered_features_selected": {
- "path": "pct_engineered_features_selected"
- },
"variance_threshold": {
"path": "variance_threshold"
},
@@ -2394,12 +2957,34 @@
}
}
},
+ {
+ "id": "feature-pipeline-source",
+ "producer_id": "asaree.feature_pipeline",
+ "kind": "reported",
+ "outputs": {
+ "n_features_after_dc": "n-features-after-dc",
+ "n_features_after_fte": "n-features-after-fte",
+ "n_features_after_fs": "n-features-after-fs",
+ "n_features_created": "n-features-created",
+ "n_created_selected": "n-engineered-features-selected",
+ "frac_created_selected": "frac-created-selected",
+ "n_recipe_ops": "n-recipe-ops",
+ "recipe_depth": "recipe-depth",
+ "recipe_hash": "recipe-hash"
+ },
+ "artifacts": [],
+ "config": {
+ "dc_node_id": "agent-dc",
+ "fte_node_id": "agent-fte",
+ "fs_node_id": "agent-fs",
+ "dataset_node_id": "dataset-mi"
+ }
+ },
{
"id": "model-evaluation-source",
- "producer_id": "asaree.mcp_tool",
+ "producer_id": "asaree.tool_step",
"kind": "reported",
"outputs": {
- "model_evaluation": "model-evaluation",
"pr_auc": "pr-auc",
"roc_auc": "roc-auc",
"brier_score": "brier-score",
@@ -2439,14 +3024,13 @@
"hp_colsample_bytree": "hp-colsample-bytree",
"hp_reg_lambda": "hp-reg-lambda",
"hp_reg_alpha": "hp-reg-alpha",
- "hp_gamma": "hp-gamma"
+ "hp_gamma": "hp-gamma",
+ "n_schema_violations": "n-schema-violations",
+ "threshold_at_prevalence": "threshold-prevalence"
},
"artifacts": [],
"config": {
- "agent_node_id": "agent-score",
- "mcp_node_id": "tool-model-score",
- "server_id": "b16f01eb-90af-4558-af3e-77c6fdcb7b22",
- "tool_name": "run_model_script",
+ "node_id": "tool-step-score",
"projections": {
"pr_auc": {
"path": "test_metrics.average_precision"
@@ -2470,19 +3054,19 @@
"path": "test_metrics.n_pos_test"
},
"accuracy_at_0_5": {
- "path": "/test_metrics/metrics_at_0.5/accuracy"
+ "path": "test_metrics.metrics_at_0.5.accuracy"
},
"balanced_accuracy_at_0_5": {
- "path": "/test_metrics/metrics_at_0.5/balanced_accuracy"
+ "path": "test_metrics.metrics_at_0.5.balanced_accuracy"
},
"precision_at_0_5": {
- "path": "/test_metrics/metrics_at_0.5/precision"
+ "path": "test_metrics.metrics_at_0.5.precision"
},
"recall_at_0_5": {
- "path": "/test_metrics/metrics_at_0.5/recall"
+ "path": "test_metrics.metrics_at_0.5.recall"
},
"f1_at_0_5": {
- "path": "/test_metrics/metrics_at_0.5/f1"
+ "path": "test_metrics.metrics_at_0.5.f1"
},
"accuracy_at_chosen_threshold": {
"path": "test_metrics.metrics_at_chosen_threshold.accuracy"
@@ -2515,7 +3099,7 @@
"path": "test_metrics.metrics_at_prevalence.f1"
},
"n_pred_pos_at_0_5": {
- "path": "/test_metrics/metrics_at_0.5/n_pred_pos"
+ "path": "test_metrics.metrics_at_0.5.n_pred_pos"
},
"n_pred_pos_at_chosen_threshold": {
"path": "test_metrics.metrics_at_chosen_threshold.n_pred_pos"
@@ -2536,7 +3120,7 @@
"path": "code_sha256"
},
"payload_sha256": {
- "path": "payload_sha256"
+ "path": "sanitized_payload_sha256"
},
"data_sha256": {
"path": "data_sha256"
@@ -2567,6 +3151,12 @@
},
"hp_gamma": {
"path": "model_decisions.best_hyperparameters.gamma"
+ },
+ "n_schema_violations": {
+ "path": "n_sanitize_notes"
+ },
+ "threshold_at_prevalence": {
+ "path": "test_metrics.metrics_at_prevalence.threshold"
}
}
}
@@ -2577,6 +3167,31 @@
"producer_binding_id": "runtime",
"input_key": "facts",
"source_key": "attempt.runtime"
+ },
+ {
+ "producer_binding_id": "runtime-dc",
+ "input_key": "facts",
+ "source_key": "attempt.runtime"
+ },
+ {
+ "producer_binding_id": "runtime-fte",
+ "input_key": "facts",
+ "source_key": "attempt.runtime"
+ },
+ {
+ "producer_binding_id": "runtime-fs",
+ "input_key": "facts",
+ "source_key": "attempt.runtime"
+ },
+ {
+ "producer_binding_id": "runtime-mlm",
+ "input_key": "facts",
+ "source_key": "attempt.runtime"
+ },
+ {
+ "producer_binding_id": "runtime-critic",
+ "input_key": "facts",
+ "source_key": "attempt.runtime"
}
]
}
diff --git a/src/asaree/api/experiments.py b/src/asaree/api/experiments.py
index 83c86fd6..d3fd4a5e 100644
--- a/src/asaree/api/experiments.py
+++ b/src/asaree/api/experiments.py
@@ -76,7 +76,7 @@
list_protocols,
sync_protocol_names_to_experiment,
)
-from asaree.services.runtime_metrics import RuntimeMetricProducer
+from asaree.services.runtime_metrics import NodeRuntimeMetricProducer, RuntimeMetricProducer
# For a Content-Disposition filename only -- never touches the experiment's
# own stored name, just what the browser offers to save the download as.
@@ -625,7 +625,7 @@ def _measurement_capability_outputs(experiment_id: uuid.UUID) -> dict[str, list[
experiment_id=str(experiment_id),
inputs={"attempt.runtime": MeasurementInput(value_type="runtime_facts", value={})},
)
- capabilities = MeasurementEngine([RuntimeMetricProducer()]).list_capabilities(snapshot)
+ capabilities = MeasurementEngine([RuntimeMetricProducer(), NodeRuntimeMetricProducer()]).list_capabilities(snapshot)
return {capability.producer_id: sorted(capability.scalar_outputs) for capability in capabilities}
diff --git a/src/asaree/api/skills.py b/src/asaree/api/skills.py
index 138f2097..11dffe82 100644
--- a/src/asaree/api/skills.py
+++ b/src/asaree/api/skills.py
@@ -37,13 +37,14 @@
from __future__ import annotations
import uuid
-from typing import Annotated, Any
+from typing import Annotated, Any, Never
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from motoro.schemas.skill import SkillCreate, SkillListResponse, SkillResponse, SkillUpdate
from motoro.services import skill_service
from motoro.services.skill_service import SkillFormatError
from pydantic import BaseModel, Field
+from sqlalchemy.exc import IntegrityError
from asaree.deps import CurrentUser
from asaree.services import skill_sources
@@ -51,6 +52,19 @@
router = APIRouter(prefix="/skills", tags=["skills"])
+_SKILL_NAME_CONSTRAINT = "uq_skills_owner_name_active"
+
+
+def _raise_skill_write_http_error(exc: SkillFormatError | IntegrityError) -> Never:
+ if isinstance(exc, SkillFormatError):
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
+ if _SKILL_NAME_CONSTRAINT in str(exc.orig):
+ raise HTTPException(
+ status_code=409,
+ detail="A skill with this name is already registered to your account.",
+ ) from exc
+ raise exc
+
def _readable(skill: Any, user: Any) -> bool:
"""Readable if the caller owns it, or it's a global system skill."""
@@ -79,8 +93,8 @@ async def create_skill_endpoint(body: SkillCreate, user: CurrentUser) -> SkillRe
body=body.body,
owner_id=user.id,
)
- except SkillFormatError as exc:
- raise HTTPException(status_code=422, detail=str(exc)) from exc
+ except (SkillFormatError, IntegrityError) as exc:
+ _raise_skill_write_http_error(exc)
return SkillResponse.model_validate(skill)
@@ -112,8 +126,8 @@ async def upload_skill_endpoint(
owner_id=user.id,
source_filename=file.filename,
)
- except SkillFormatError as exc:
- raise HTTPException(status_code=422, detail=str(exc)) from exc
+ except (SkillFormatError, IntegrityError) as exc:
+ _raise_skill_write_http_error(exc)
return SkillResponse.model_validate(skill)
@@ -157,8 +171,8 @@ async def upload_skill_folder_endpoint(
folder = (files[0].filename or "").replace("\\", "/").split("/")[0] if files else None
try:
skill = await skill_service.create_skill_from_bundle(payload, owner_id=user.id, source_filename=folder)
- except SkillFormatError as exc:
- raise HTTPException(status_code=422, detail=str(exc)) from exc
+ except (SkillFormatError, IntegrityError) as exc:
+ _raise_skill_write_http_error(exc)
return SkillResponse.model_validate(skill)
@@ -180,8 +194,8 @@ async def replace_skill_folder_endpoint(
payload = await _folder_payload(files)
try:
skill = await skill_service.update_skill_from_bundle(skill_id, payload)
- except SkillFormatError as exc:
- raise HTTPException(status_code=422, detail=str(exc)) from exc
+ except (SkillFormatError, IntegrityError) as exc:
+ _raise_skill_write_http_error(exc)
assert skill is not None
return SkillResponse.model_validate(skill)
@@ -259,8 +273,10 @@ async def create_skill_from_url_endpoint(body: SkillUrlRequest, user: CurrentUse
# tail of it beats a 500 on an otherwise valid skill.
provenance = f"{source.label}@{source.ref}"[:255]
skill = await skill_service.create_skill_from_bundle(bundle, owner_id=user.id, source_filename=provenance)
- except (SkillSourceError, SkillFormatError) as exc:
+ except SkillSourceError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
+ except (SkillFormatError, IntegrityError) as exc:
+ _raise_skill_write_http_error(exc)
return SkillResponse.model_validate(skill)
@@ -300,8 +316,8 @@ async def update_skill_endpoint(skill_id: uuid.UUID, body: SkillUpdate, user: Cu
raise HTTPException(status_code=404, detail="No such skill")
try:
skill = await skill_service.update_skill(skill_id, name=body.name, description=body.description, body=body.body)
- except SkillFormatError as exc:
- raise HTTPException(status_code=422, detail=str(exc)) from exc
+ except (SkillFormatError, IntegrityError) as exc:
+ _raise_skill_write_http_error(exc)
assert skill is not None # existence already checked above
return SkillResponse.model_validate(skill)
@@ -318,8 +334,8 @@ async def replace_skill_markdown_endpoint(
raise HTTPException(status_code=404, detail="No such skill")
try:
skill = await skill_service.update_skill_from_markdown(skill_id, _decode(await file.read()))
- except SkillFormatError as exc:
- raise HTTPException(status_code=422, detail=str(exc)) from exc
+ except (SkillFormatError, IntegrityError) as exc:
+ _raise_skill_write_http_error(exc)
assert skill is not None
return SkillResponse.model_validate(skill)
diff --git a/src/asaree/services/csv_export.py b/src/asaree/services/csv_export.py
index d60f659a..289255d6 100644
--- a/src/asaree/services/csv_export.py
+++ b/src/asaree/services/csv_export.py
@@ -420,15 +420,13 @@ def result_rows_schema(
{
"name": key,
"role": "reported" if key in reported_keys else "outcome",
- "value_type": (
- "string"
- if key in script_columns
- else (metric_types or {}).get(key, "opaque" if key in reported_keys else "number")
- ),
**(
{}
if key in reported_keys
- else {"cell_aggregation": (metric_aggregations or {}).get(key, "mean")}
+ else {
+ "value_type": (metric_types or {}).get(key, "number"),
+ "cell_aggregation": (metric_aggregations or {}).get(key, "mean"),
+ }
),
},
*(
diff --git a/src/asaree/services/derived_metrics.py b/src/asaree/services/derived_metrics.py
new file mode 100644
index 00000000..bc2d8666
--- /dev/null
+++ b/src/asaree/services/derived_metrics.py
@@ -0,0 +1,377 @@
+"""Custom metrics ASAREE computes in code from what a run already recorded.
+
+Two producers, both deterministic reads of ``ProtocolRun.node_runs`` at
+finalization -- nothing is executed here:
+
+* ``asaree.tool_step`` -- a Tool Step's parsed result (e.g. a scoring call's
+ response): the whole result, or one field of it per metric via a projection
+ (see ``project_value``).
+* ``asaree.feature_pipeline`` -- feature counts across a DC -> FTE -> FS chain,
+ plus the FTE recipe's size, chain depth and structural hash,
+ computed the way the spinal notebook's ``process_metrics`` does rather than
+ trusted from an agent's end-of-turn self-report. Only the raw dataset's
+ column names are needed beyond the run itself, to tell a created feature
+ from a raw passthrough.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import json
+import math
+import uuid
+from collections.abc import Awaitable, Callable, Mapping
+from typing import Any, TypeGuard
+
+from asaree.services.measurement_engine import (
+ MetricDefinition,
+ MetricObservation,
+ ProducerBinding,
+ ProducerProvenance,
+)
+from asaree.services.protocol_graph import node_map
+from asaree.services.tool_steps import flatten_paths, resolve_upstream_payload
+
+TOOL_STEP_PRODUCER_ID = "asaree.tool_step"
+FEATURE_PIPELINE_PRODUCER_ID = "asaree.feature_pipeline"
+DERIVED_PRODUCER_IDS = frozenset({TOOL_STEP_PRODUCER_ID, FEATURE_PIPELINE_PRODUCER_ID})
+
+FEATURE_PIPELINE_OUTPUTS = (
+ "n_features_after_dc",
+ "n_features_after_fte",
+ "n_features_after_fs",
+ "n_features_created",
+ "n_created_selected",
+ "frac_created_selected",
+ "n_recipe_ops",
+ "recipe_depth",
+ "recipe_hash",
+)
+_STAGE_NODE_KEYS = ("dc_node_id", "fte_node_id", "fs_node_id")
+_FIELD_DEFAULTS = {
+ "dc_count_field": "n_cols_out",
+ "fte_count_field": "n_features_out",
+ "fs_count_field": "n_features_out",
+ "selected_field": "selected_features",
+ "recipe_field": "engineering_recipe",
+}
+
+RawColumnsLoader = Callable[[str], Awaitable[frozenset[str] | None]]
+
+# A projection picks one field out of a producer's JSON output:
+# ``config.projections[output_key] = {"path": "a.b", "transform": "length"?}``.
+# The path is an exact key of ``flatten_paths`` (result keys may contain dots,
+# e.g. ``metrics_at_0.5``), and ``length`` -- the only transform -- turns a
+# list or object into its size. An output with no projection records the
+# producer's whole output.
+PROJECTION_TRANSFORMS = frozenset({"length"})
+
+
+def output_projection(binding: ProducerBinding, output_key: str) -> Mapping[str, Any] | None:
+ projections = binding.config.get("projections")
+ projection = projections.get(output_key) if isinstance(projections, Mapping) else None
+ return projection if isinstance(projection, Mapping) else None
+
+
+def project_value(paths: Mapping[str, Any], projection: Mapping[str, Any]) -> tuple[Any, str | None]:
+ """``(value, error)`` for one projection over a ``flatten_paths`` mapping."""
+ path = projection.get("path")
+ if not isinstance(path, str) or path not in paths:
+ return None, f"No value for {path!r} was recorded."
+ value = paths[path]
+ if projection.get("transform") == "length":
+ if isinstance(value, list | Mapping):
+ return len(value), None
+ return None, f"{path!r} is not a list or object, so it has no length."
+ return _json_safe(value)
+
+
+def projection_issue(binding: ProducerBinding) -> tuple[str, str, str] | None:
+ """``(code, message, config key)`` when a binding's projections are malformed."""
+ projections = binding.config.get("projections")
+ if projections is None:
+ return None
+ if not isinstance(projections, Mapping):
+ return "reported_projections_invalid", "Field projections must be keyed by producer output.", "projections"
+ for output_key, projection in projections.items():
+ if (
+ output_key not in binding.outputs
+ or not isinstance(projection, Mapping)
+ or not isinstance(projection.get("path"), str)
+ or not projection["path"]
+ or projection.get("transform") not in {None, *PROJECTION_TRANSFORMS}
+ ):
+ return (
+ "reported_projection_invalid",
+ f"The field projection for {output_key!r} needs an output, a path, and an optional 'length'.",
+ f"projections.{output_key}",
+ )
+ return None
+
+
+def _is_number(value: Any) -> TypeGuard[int | float]:
+ return isinstance(value, int | float) and not isinstance(value, bool)
+
+
+def _json_safe(value: Any) -> tuple[Any, str | None]:
+ """JSONB rejects NaN/Infinity, so a non-finite number is reported, not stored."""
+ if isinstance(value, float) and not math.isfinite(value):
+ return None, f"The value is not a finite number ({value})."
+ return value, None
+
+
+def _completed_payload(node_run: Any) -> dict[str, Any] | None:
+ if not isinstance(node_run, Mapping) or node_run.get("status") != "completed":
+ return None
+ return resolve_upstream_payload([node_run])
+
+
+def _tool_step_result(node_run: Any) -> Any:
+ """The whole result of a completed Tool Step: its parsed JSON, else its text."""
+ if not isinstance(node_run, Mapping) or node_run.get("status") != "completed":
+ return None
+ payload = node_run.get("payload")
+ return payload if payload is not None else node_run.get("output_text")
+
+
+def tool_step_values(node_run: Any) -> dict[str, Any] | None:
+ """Every addressable value of a completed Tool Step, by dotted path.
+
+ Paths address the tool's parsed result directly (``test_metrics.roc_auc``);
+ ``tool_step.*`` addresses the step's own provenance.
+ """
+ if not isinstance(node_run, Mapping) or node_run.get("status") != "completed":
+ return None
+ result = node_run.get("payload")
+ step = node_run.get("tool_step") or {}
+ values = flatten_paths(result) if isinstance(result, Mapping) else {}
+ values.update(flatten_paths({"tool_step": step}))
+ return values
+
+
+def _is_created_feature(name: Any, raw_columns: frozenset[str]) -> bool:
+ return str(name).rsplit("__", 1)[-1] not in raw_columns
+
+
+def recipe_depth(recipe: list[Any]) -> int:
+ """Longest derived-from-derived chain in an FTE recipe: 0 when empty, 1 when
+ every entry builds on source columns, 2 when one consumes another's output.
+ A declared cycle's back-edge counts as a source column so this terminates.
+ """
+ produced = {str(e["name"]): e for e in recipe if isinstance(e, Mapping) and e.get("name")}
+ depth_of: dict[str, int] = {}
+ resolving: set[str] = set()
+
+ def depth(name: str) -> int:
+ if name not in produced or name in resolving:
+ return 0
+ if name not in depth_of:
+ resolving.add(name)
+ inputs = produced[name].get("inputs")
+ inner = [depth(str(i)) for i in inputs] if isinstance(inputs, list) else []
+ depth_of[name] = 1 + max(inner or [0])
+ resolving.discard(name)
+ return depth_of[name]
+
+ return max([depth(name) for name in produced] or [0])
+
+
+def recipe_hash(recipe: list[Any]) -> str:
+ """SHA-256 over a recipe's structure (name/op/inputs/params), independent of
+ entry order and rationale, so cells that engineered the same features match."""
+ canon = sorted(
+ json.dumps({k: e.get(k) for k in ("name", "op", "inputs", "params")}, sort_keys=True, separators=(",", ":"))
+ for e in recipe
+ if isinstance(e, Mapping)
+ )
+ return hashlib.sha256("\n".join(canon).encode("utf-8")).hexdigest()
+
+
+def feature_pipeline_values(
+ node_runs: Mapping[str, Any],
+ config: Mapping[str, Any],
+ raw_columns: frozenset[str] | None,
+) -> dict[str, Any]:
+ """The notebook's process metrics from the three stages' typed payloads.
+
+ A value is absent (not ``None``) when its inputs are: the caller reports
+ that output as unavailable rather than recording a guess.
+ """
+ field = {key: str(config.get(key) or default) for key, default in _FIELD_DEFAULTS.items()}
+ dc = _completed_payload(node_runs.get(str(config.get("dc_node_id") or ""))) or {}
+ fte = _completed_payload(node_runs.get(str(config.get("fte_node_id") or ""))) or {}
+ fs = _completed_payload(node_runs.get(str(config.get("fs_node_id") or ""))) or {}
+ values: dict[str, Any] = {}
+ after_dc, after_fte = dc.get(field["dc_count_field"]), fte.get(field["fte_count_field"])
+ selected = fs.get(field["selected_field"])
+ after_fs = fs.get(field["fs_count_field"])
+ if not _is_number(after_fs) and isinstance(selected, list):
+ after_fs = len(selected)
+ if _is_number(after_dc):
+ values["n_features_after_dc"] = after_dc
+ if _is_number(after_fte):
+ values["n_features_after_fte"] = after_fte
+ if _is_number(after_fs):
+ values["n_features_after_fs"] = after_fs
+ if _is_number(after_dc) and _is_number(after_fte):
+ values["n_features_created"] = after_fte - after_dc
+ if raw_columns and isinstance(selected, list) and selected and _is_number(after_fs):
+ created = sum(_is_created_feature(name, raw_columns) for name in selected)
+ values["n_created_selected"] = created
+ if after_fs:
+ values["frac_created_selected"] = created / after_fs
+ recipe = fte.get(field["recipe_field"])
+ if isinstance(recipe, list):
+ values["n_recipe_ops"] = len(recipe)
+ values["recipe_depth"] = recipe_depth(recipe)
+ values["recipe_hash"] = recipe_hash(recipe)
+ return values
+
+
+async def load_dataset_raw_columns(dataset_id: str) -> frozenset[str] | None:
+ """Column names of a registered dataset's raw file (its train split as a fallback)."""
+ from asaree.models.database import get_session
+ from asaree.models.dataset import RegisteredDataset
+
+ try:
+ key = uuid.UUID(dataset_id)
+ except (TypeError, ValueError):
+ return None
+ async with get_session() as db:
+ dataset = await db.get(RegisteredDataset, key)
+ raw_path = getattr(dataset, "raw_path", None)
+ train_path = getattr(dataset, "train_path", None)
+
+ def read() -> frozenset[str] | None:
+ import pandas as pd
+
+ try:
+ if raw_path:
+ return frozenset(str(c) for c in pd.read_csv(raw_path, nrows=0).columns)
+ if train_path:
+ return frozenset(str(c) for c in pd.read_parquet(train_path).columns)
+ except (OSError, ValueError):
+ return None
+ return None
+
+ return await asyncio.to_thread(read)
+
+
+def _dataset_id(graph: Mapping[str, Any], dataset_node_id: Any) -> str | None:
+ node = node_map(graph).get(str(dataset_node_id or ""))
+ data = node.get("data") if isinstance(node, Mapping) else None
+ config = data.get("config") if isinstance(data, Mapping) else None
+ dataset_id = config.get("dataset_id") if isinstance(config, Mapping) else None
+ return str(dataset_id) if dataset_id else None
+
+
+async def collect_derived_observations(
+ binding: ProducerBinding,
+ metrics: Mapping[str, MetricDefinition],
+ *,
+ node_runs: Mapping[str, Any],
+ graph: Mapping[str, Any],
+ attempt_id: str,
+ raw_columns_loader: RawColumnsLoader = load_dataset_raw_columns,
+) -> list[MetricObservation]:
+ """One observation per output of a derived producer binding."""
+ whole: Any = None
+ if binding.producer_id == TOOL_STEP_PRODUCER_ID:
+ node_run = node_runs.get(str(binding.config.get("node_id") or ""))
+ values = tool_step_values(node_run)
+ whole = _tool_step_result(node_run)
+ missing_source = "The Tool Step did not complete."
+ source = "tool_step_result"
+ else:
+ dataset_id = _dataset_id(graph, binding.config.get("dataset_node_id"))
+ raw_columns = await raw_columns_loader(dataset_id) if dataset_id else None
+ values = feature_pipeline_values(node_runs, binding.config, raw_columns)
+ missing_source = None
+ source = "stage_payloads"
+ provenance = ProducerProvenance(
+ binding_id=binding.id,
+ producer_id=binding.producer_id,
+ kind=binding.kind,
+ version="1",
+ binding_config=dict(binding.config),
+ evaluation={"source": source},
+ )
+ observations: list[MetricObservation] = []
+ for output_key, metric_id in binding.outputs.items():
+ metric = metrics.get(metric_id)
+ if metric is None:
+ continue
+ error: str | None
+ projection = output_projection(binding, output_key) if binding.producer_id == TOOL_STEP_PRODUCER_ID else None
+ if values is None:
+ value, error = None, missing_source
+ elif projection is not None:
+ value, error = project_value(values, projection)
+ elif binding.producer_id == TOOL_STEP_PRODUCER_ID:
+ value, error = _json_safe(whole)
+ elif output_key not in values:
+ value, error = None, f"No value for {output_key!r} was recorded."
+ else:
+ value, error = _json_safe(values[output_key])
+ observations.append(
+ MetricObservation(
+ metric_id=metric.id,
+ metric_name=metric.name,
+ value_type=None,
+ value=value,
+ status="measured" if error is None else "unavailable",
+ error=error,
+ attempt_id=attempt_id,
+ producer=provenance,
+ input_provenance={},
+ )
+ )
+ return observations
+
+
+def derived_binding_issue(binding: ProducerBinding, graph: Mapping[str, Any]) -> tuple[str, str, str] | None:
+ """``(code, message, config key)`` when a derived binding's wiring is broken."""
+ nodes = node_map(graph)
+ if binding.producer_id == TOOL_STEP_PRODUCER_ID:
+ node = nodes.get(str(binding.config.get("node_id") or ""))
+ if node is None or node.get("type") != "tool_step":
+ return "tool_step_missing", "The Tool Step is unavailable.", "node_id"
+ data = node.get("data")
+ if isinstance(data, Mapping) and data.get("active") is False:
+ return "tool_step_disabled", "The Tool Step is disabled.", "node_id"
+ return None
+ unknown = sorted(set(binding.outputs) - set(FEATURE_PIPELINE_OUTPUTS))
+ if unknown:
+ return "feature_pipeline_unknown_output", f"Unknown feature-pipeline output {unknown[0]!r}.", "outputs"
+ for key in _STAGE_NODE_KEYS:
+ node = nodes.get(str(binding.config.get(key) or ""))
+ if node is None or node.get("type") not in ("agent", "sub_agent"):
+ return "feature_pipeline_stage_missing", f"The {key.split('_')[0].upper()} Agent is unavailable.", key
+ needs_columns = {"n_created_selected", "frac_created_selected"} & set(binding.outputs)
+ if needs_columns and _dataset_id(graph, binding.config.get("dataset_node_id")) is None:
+ return (
+ "feature_pipeline_dataset_missing",
+ "Counting created features needs the raw Dataset node.",
+ "dataset_node_id",
+ )
+ return None
+
+
+__all__ = [
+ "DERIVED_PRODUCER_IDS",
+ "PROJECTION_TRANSFORMS",
+ "FEATURE_PIPELINE_OUTPUTS",
+ "FEATURE_PIPELINE_PRODUCER_ID",
+ "TOOL_STEP_PRODUCER_ID",
+ "collect_derived_observations",
+ "derived_binding_issue",
+ "feature_pipeline_values",
+ "load_dataset_raw_columns",
+ "output_projection",
+ "project_value",
+ "projection_issue",
+ "recipe_depth",
+ "recipe_hash",
+ "tool_step_values",
+]
diff --git a/src/asaree/services/experiment_measurements.py b/src/asaree/services/experiment_measurements.py
index 3671f0f8..0b278f51 100644
--- a/src/asaree/services/experiment_measurements.py
+++ b/src/asaree/services/experiment_measurements.py
@@ -21,11 +21,17 @@
from asaree.services.metrics import normalize_metrics
from asaree.services.reported_metrics import (
AGENT_OUTPUT_PRODUCER_ID,
+ FEATURE_PIPELINE_PRODUCER_ID,
MCP_TOOL_PRODUCER_ID,
PYTHON_SCRIPT_PRODUCER_ID,
+ TOOL_STEP_PRODUCER_ID,
validate_reported_measurement_plan,
)
-from asaree.services.runtime_metrics import RuntimeMetricProducer, validate_runtime_measurement_plan
+from asaree.services.runtime_metrics import (
+ NodeRuntimeMetricProducer,
+ RuntimeMetricProducer,
+ validate_runtime_measurement_plan,
+)
_PRODUCER_PATH = re.compile(r"^producers\[(\d+)\]")
_OUTPUT_PATH = re.compile(r"^producers\[(\d+)\]\.outputs\.([^\.]+)$")
@@ -121,20 +127,21 @@ async def validate_experiment_measurement_plan(
)
)
continue
- selected_semantics = (
- str(metric.get("name") or metric_id),
- metric.get("valueType", "number"),
- metric.get("direction", "maximize"),
- "rate" if metric.get("valueType") == "boolean" else metric.get("aggregation", "mean"),
- bool(metric.get("primary")),
- )
- declared_semantics = (
- declared.name,
- declared.value_type,
- declared.direction,
- declared.aggregation,
- declared.primary,
- )
+ selected_semantics = (str(metric.get("name") or metric_id),)
+ declared_semantics = (declared.name,)
+ if metric.get("kind") == "runtime":
+ selected_semantics += (
+ metric.get("valueType", "number"),
+ metric.get("direction", "maximize"),
+ "rate" if metric.get("valueType") == "boolean" else metric.get("aggregation", "mean"),
+ bool(metric.get("primary")),
+ )
+ declared_semantics += (
+ declared.value_type,
+ declared.direction,
+ declared.aggregation,
+ bool(declared.primary),
+ )
if selected_semantics != declared_semantics:
reconciliation_issues.append(
ValidationIssue(
@@ -159,9 +166,12 @@ async def validate_experiment_measurement_plan(
return structural_report
supported_producers = {
RuntimeMetricProducer.producer_id: "runtime",
+ NodeRuntimeMetricProducer.producer_id: "runtime",
AGENT_OUTPUT_PRODUCER_ID: "reported",
PYTHON_SCRIPT_PRODUCER_ID: "reported",
MCP_TOOL_PRODUCER_ID: "reported",
+ TOOL_STEP_PRODUCER_ID: "reported",
+ FEATURE_PIPELINE_PRODUCER_ID: "reported",
}
producer_issues = []
for index, binding in enumerate(plan.producers):
@@ -201,7 +211,9 @@ async def validate_experiment_measurement_plan(
if allow_preserved_bindings
else set()
)
- issues = list(validate_runtime_measurement_plan(plan, preserved_binding_ids=preserved_binding_ids).issues)
+ issues = list(
+ validate_runtime_measurement_plan(plan, preserved_binding_ids=preserved_binding_ids, graph=graph).issues
+ )
reported_report = await validate_reported_measurement_plan(
plan,
graph,
diff --git a/src/asaree/services/experiment_run_results.py b/src/asaree/services/experiment_run_results.py
index 42641f58..7f4bd0f0 100644
--- a/src/asaree/services/experiment_run_results.py
+++ b/src/asaree/services/experiment_run_results.py
@@ -84,6 +84,34 @@ def _normalize_metric_values(values: dict[str, Any] | None) -> dict[str, Any]:
return {key: int(value) if isinstance(value, bool) else value for key, value in (values or {}).items()}
+def _attempt_metric_values(stored: dict[str, Any]) -> dict[str, Any]:
+ """Project score values plus measured node-scoped runtime observations.
+
+ Runtime observations stay out of the replicate's persisted scoring values.
+ Node-scoped runtime metrics have no equivalent fixed execution column, so
+ Results reads them from the immutable attempt measurement document.
+ """
+ raw_values = stored.get("metric_values")
+ projected = _normalize_metric_values(raw_values if isinstance(raw_values, dict) else None)
+ measurement = stored.get("measurement")
+ observations = measurement.get("observations") if isinstance(measurement, dict) else None
+ for observation in observations if isinstance(observations, list) else []:
+ if not isinstance(observation, dict) or observation.get("status") != "measured":
+ continue
+ producer = observation.get("producer")
+ name = observation.get("metric_name")
+ if (
+ not isinstance(producer, dict)
+ or producer.get("producer_id") != "asaree.node_runtime"
+ or not isinstance(name, str)
+ ):
+ continue
+ value = observation.get("value")
+ if value is not None:
+ projected.setdefault(name, int(value) if isinstance(value, bool) else value)
+ return projected
+
+
def _merge_legacy_facets(
metric_values: dict[str, Any],
raw_artifacts: Any,
@@ -95,8 +123,8 @@ def _merge_legacy_facets(
) -> LegacyResultFacets:
"""Add compatibility facts without duplicating current measurements.
- Opaque reported metrics are JSON values, which legacy migration also
- recognizes as non-scalar historical data. A matching current observation
+ Reported metrics may contain arbitrary JSON values, which legacy migration
+ also recognizes as non-scalar historical data. A matching current observation
is authoritative, so it must not leave a duplicate ``legacy_values``
entry that would hide the declared Results column.
"""
@@ -139,6 +167,7 @@ def _primary_metric(design_spec: dict[str, Any] | None) -> tuple[str | None, str
for metric in metrics:
if (
isinstance(metric, dict)
+ and metric.get("kind") == "runtime"
and metric.get("primary")
and isinstance(metric.get("name"), str)
and metric.get("valueType", "number") in {"number", "boolean"}
@@ -178,7 +207,7 @@ def _declared_metric_types(design_spec: dict[str, Any] | None) -> dict[str, str]
types: dict[str, str] = {}
for metric in normalize_metrics((design_spec or {}).get("metrics")):
key = metric.get("catalogKey") if metric["kind"] == "runtime" else metric.get("name")
- if isinstance(key, str) and metric["valueType"] in {"number", "boolean"}:
+ if isinstance(key, str) and metric.get("valueType") in {"number", "boolean"}:
types[key] = metric["valueType"]
return types
@@ -188,7 +217,7 @@ def _declared_metric_aggregations(design_spec: dict[str, Any] | None) -> dict[st
aggregations: dict[str, str] = {}
for metric in normalize_metrics((design_spec or {}).get("metrics")):
key = metric.get("catalogKey") if metric["kind"] == "runtime" else metric.get("name")
- if isinstance(key, str) and metric["valueType"] in {"number", "boolean"}:
+ if isinstance(key, str) and metric.get("valueType") in {"number", "boolean"}:
aggregations[key] = metric["aggregation"]
return aggregations
@@ -198,7 +227,7 @@ def _declared_metric_directions(design_spec: dict[str, Any] | None) -> dict[str,
directions: dict[str, str] = {}
for metric in normalize_metrics((design_spec or {}).get("metrics")):
key = metric.get("catalogKey") if metric["kind"] == "runtime" else metric.get("name")
- if isinstance(key, str) and metric["valueType"] in {"number", "boolean"}:
+ if isinstance(key, str) and metric.get("valueType") in {"number", "boolean"}:
directions[key] = metric["direction"]
return directions
@@ -456,11 +485,10 @@ def attempt_result(
) -> tuple[dict[str, Any], dict[str, Any] | None, list[dict[str, Any]], list[dict[str, Any]]]:
"""The immutable score/evaluation facts recorded by this attempt."""
stored = protocol_run.attempt_result if isinstance(protocol_run.attempt_result, dict) else {}
- metric_values = stored.get("metric_values")
evaluation = stored.get("metric_evaluation")
observations, artifacts = measurement_facets(stored.get("measurement"))
return (
- _normalize_metric_values(metric_values if isinstance(metric_values, dict) else None),
+ _attempt_metric_values(stored),
dict(evaluation) if isinstance(evaluation, dict) else None,
observations,
artifacts,
diff --git a/src/asaree/services/measurement_engine.py b/src/asaree/services/measurement_engine.py
index 9a36618f..3f706b4d 100644
--- a/src/asaree/services/measurement_engine.py
+++ b/src/asaree/services/measurement_engine.py
@@ -39,10 +39,10 @@
class MetricDefinition:
id: str
name: str
- value_type: MetricValueType
- direction: MetricDirection
- aggregation: MetricAggregation
- primary: bool = False
+ value_type: MetricValueType | None = None
+ direction: MetricDirection | None = None
+ aggregation: MetricAggregation | None = None
+ primary: bool | None = None
description: str | None = None
unit: str | None = None
@@ -96,7 +96,7 @@ def parse_measurement_plan(document: Any) -> MeasurementPlan:
def normalize_measurement_plan(document: Any) -> dict[str, Any]:
"""Return a JSON-compatible canonical plan document."""
plan = parse_measurement_plan(document)
- ensure_at_most_one_primary(tuple(metric.primary for metric in plan.metrics))
+ ensure_at_most_one_primary(tuple(bool(metric.primary) for metric in plan.metrics))
return cast(
dict[str, Any],
_MEASUREMENT_PLAN_ADAPTER.dump_python(plan, mode="json", exclude_none=True),
@@ -203,7 +203,7 @@ class ProducerProvenance:
class MetricObservation:
metric_id: str
metric_name: str
- value_type: MetricValueType
+ value_type: MetricValueType | None
status: ObservationStatus
value: ScalarValue | None
error: str | None
@@ -238,7 +238,7 @@ def to_document(self) -> dict[str, Any]:
{
"metric_id": item.metric_id,
"metric_name": item.metric_name,
- "value_type": item.value_type,
+ **({"value_type": item.value_type} if item.value_type is not None else {}),
"status": item.status,
"value": item.value,
"error": item.error,
@@ -415,7 +415,7 @@ def validate_measurement_plan_structure(
)
)
- primary_count = sum(metric.primary for metric in plan.metrics)
+ primary_count = sum(bool(metric.primary) for metric in plan.metrics)
if require_primary and plan.metrics and primary_count == 0:
issues.append(
ValidationIssue(
@@ -716,9 +716,10 @@ def normalize_binding(binding: ProducerBinding, produced: ProducerResult) -> Mea
output_key,
ProducedObservation.unavailable(f"Producer did not return output {output_key!r}."),
)
- if outcome.status == "measured" and not is_scalar_value(outcome.value, metric.value_type):
+ value_type = metric.value_type or "opaque"
+ if outcome.status == "measured" and not is_scalar_value(outcome.value, value_type):
outcome = ProducedObservation.failed(
- f"Producer returned an invalid {metric.value_type} for output {output_key!r}."
+ f"Producer returned an invalid {value_type} for output {output_key!r}."
)
normalized_observations.append(
MetricObservation(
diff --git a/src/asaree/services/measurement_migration.py b/src/asaree/services/measurement_migration.py
index 053dee18..24784d15 100644
--- a/src/asaree/services/measurement_migration.py
+++ b/src/asaree/services/measurement_migration.py
@@ -55,27 +55,60 @@ def normalize_experiment_measurement_plan(document: Any, metrics: Any) -> dict[s
if isinstance(producer, dict) and producer.get("kind") == "deterministic_evaluator":
producer["kind"] = "reported"
plan = parse_measurement_plan(migrated_document) if migrated_document is not None else MeasurementPlan((), (), ())
- reported_ids = {"asaree.agent_output", "asaree.python_script", "asaree.mcp_tool"}
+ reported_ids = {
+ "asaree.agent_output",
+ "asaree.python_script",
+ "asaree.mcp_tool",
+ "asaree.tool_step",
+ "asaree.feature_pipeline",
+ }
reported_bindings = tuple(binding for binding in plan.producers if binding.producer_id in reported_ids)
- reported_binding_ids = {binding.id for binding in reported_bindings}
- reported_metric_ids = {metric_id for binding in reported_bindings for metric_id in binding.outputs.values()}
- projected_metric_ids = {
- metric_id
- for binding in reported_bindings
+ # A node runtime metric is authored as a custom metric, and a definition
+ # saved down to {id, name} would fail the engine's type check. Its outputs
+ # are all numbers -- counts that sum, plus a rate that averages -- so fill
+ # whatever is missing rather than reject the plan.
+ node_runtime_outputs = {
+ metric_id: output_key
+ for binding in plan.producers
+ if binding.producer_id == "asaree.node_runtime"
for output_key, metric_id in binding.outputs.items()
- if isinstance(binding.config.get("projections"), Mapping)
- and output_key in binding.config["projections"]
}
+ plan = replace(
+ plan,
+ metrics=tuple(
+ replace(
+ metric,
+ value_type=metric.value_type or "number",
+ aggregation=metric.aggregation
+ or ("mean" if node_runtime_outputs[metric.id] == "tool_error_rate" else "sum"),
+ )
+ if metric.id in node_runtime_outputs
+ else metric
+ for metric in plan.metrics
+ ),
+ )
+ reported_binding_ids = {binding.id for binding in reported_bindings}
+ reported_metric_ids = {metric_id for binding in reported_bindings for metric_id in binding.outputs.values()}
normalized = replace(
plan,
metrics=tuple(
- replace(metric, value_type="opaque", direction="neutral", aggregation="none", primary=False)
- if metric.id in reported_metric_ids and metric.id not in projected_metric_ids
+ replace(
+ metric,
+ value_type=None,
+ direction=None,
+ aggregation=None,
+ primary=None,
+ description=None,
+ unit=None,
+ )
+ if metric.id in reported_metric_ids
else metric
for metric in plan.metrics
),
producers=tuple(
- replace(binding, kind="reported", artifacts=()) if binding.id in reported_binding_ids else binding
+ replace(binding, kind="reported", artifacts=())
+ if binding.id in reported_binding_ids
+ else binding
for binding in plan.producers
),
inputs=tuple(item for item in plan.inputs if item.producer_binding_id not in reported_binding_ids),
diff --git a/src/asaree/services/metrics.py b/src/asaree/services/metrics.py
index c2621b7b..51575b46 100644
--- a/src/asaree/services/metrics.py
+++ b/src/asaree/services/metrics.py
@@ -4,7 +4,6 @@
import uuid
from copy import deepcopy
-from math import isfinite
from typing import Any
from asaree.services.measurement_engine import ensure_at_most_one_primary, parse_measurement_plan
@@ -114,6 +113,105 @@
"aggregation": "sum",
"unit": "reviews",
},
+ {
+ "key": "critic_invocations",
+ "name": "Critic invocations",
+ "shortDescription": "Critic-gate reviews run during the run.",
+ "kind": "runtime",
+ "valueType": "number",
+ "defaultDirection": "neutral",
+ "aggregation": "sum",
+ "unit": "reviews",
+ },
+ {
+ "key": "critic_rejections_partial",
+ "name": "Partial critic rejections",
+ "shortDescription": "Rejections scoped to a targeted correction.",
+ "kind": "runtime",
+ "valueType": "number",
+ "defaultDirection": "minimize",
+ "aggregation": "sum",
+ "unit": "reviews",
+ },
+ {
+ "key": "critic_rejections_full",
+ "name": "Full critic rejections",
+ "shortDescription": "Rejections asking the stage to reconsider its approach (includes unscoped ones).",
+ "kind": "runtime",
+ "valueType": "number",
+ "defaultDirection": "minimize",
+ "aggregation": "sum",
+ "unit": "reviews",
+ },
+ {
+ "key": "revision_rounds",
+ "name": "Revision rounds",
+ "shortDescription": "Worker reruns triggered by critic rejections.",
+ "kind": "runtime",
+ "valueType": "number",
+ "defaultDirection": "minimize",
+ "aggregation": "sum",
+ "unit": "rounds",
+ },
+ {
+ "key": "prompt_sha256",
+ "name": "Prompt SHA-256",
+ "shortDescription": "Hash of the published stage prompts, critic criteria and output contracts.",
+ "kind": "runtime",
+ "valueType": "opaque",
+ "defaultDirection": "neutral",
+ "aggregation": "none",
+ },
+ {
+ "key": "capped_agent_runs",
+ "name": "Capped agent runs",
+ "shortDescription": "Reason+Act runs cut off by their iteration ceiling.",
+ "kind": "runtime",
+ "valueType": "number",
+ "defaultDirection": "minimize",
+ "aggregation": "sum",
+ "unit": "runs",
+ },
+ {
+ "key": "agent_runs",
+ "name": "Agent runs",
+ "shortDescription": "Every agent and critic run the attempt launched, revisions included.",
+ "kind": "runtime",
+ "valueType": "number",
+ "defaultDirection": "neutral",
+ "aggregation": "sum",
+ "unit": "runs",
+ },
+ {
+ "key": "react_runs",
+ "name": "Reason+Act runs",
+ "shortDescription": "Runs that went through a Reason+Act loop.",
+ "kind": "runtime",
+ "valueType": "number",
+ "defaultDirection": "neutral",
+ "aggregation": "sum",
+ "unit": "runs",
+ },
+ {
+ "key": "react_turns",
+ "name": "Reason+Act turns",
+ "shortDescription": "Loop iterations reported by Reason+Act runs.",
+ "kind": "runtime",
+ "valueType": "number",
+ "defaultDirection": "minimize",
+ "aggregation": "sum",
+ "unit": "turns",
+ },
+ {
+ "key": "react_tool_calls",
+ "name": "Reason+Act tool calls",
+ "shortDescription": "Tool calls counted by Reason+Act loops.",
+ "kind": "runtime",
+ "valueType": "number",
+ "defaultDirection": "neutral",
+ "aggregation": "sum",
+ "unit": "calls",
+ },
)
_CATALOG_BY_KEY = {str(entry["key"]): entry for entry in METRIC_CATALOG}
# Only metrics every run can produce. Tool calls is deliberately absent: an
@@ -203,13 +301,13 @@ def design_metrics_from_measurement_plan(document: Any) -> list[dict[str, Any]]:
"id": metric.id,
"name": metric.name,
"kind": "runtime" if catalog_key is not None else "custom",
- "valueType": metric.value_type,
- "direction": metric.direction,
- "aggregation": metric.aggregation,
- "primary": metric.primary,
}
if catalog_key is not None:
declaration["catalogKey"] = catalog_key
+ declaration["valueType"] = metric.value_type
+ declaration["direction"] = metric.direction
+ declaration["aggregation"] = metric.aggregation
+ declaration["primary"] = bool(metric.primary)
if metric.description is not None:
declaration["description"] = metric.description
if metric.unit is not None:
@@ -276,6 +374,9 @@ def normalize_metrics(metrics: Any, *, validate_custom_names: bool = False) -> l
metric["kind"] = (
metric.get("kind") if metric.get("kind") in _KINDS else catalog["kind"] if catalog else "custom"
)
+ if metric["kind"] == "custom":
+ normalized.append({"id": metric["id"], "name": name, "kind": "custom"})
+ continue
metric["valueType"] = (
metric.get("valueType")
if metric.get("valueType") in _VALUE_TYPES
@@ -300,20 +401,6 @@ def normalize_metrics(metrics: Any, *, validate_custom_names: bool = False) -> l
if catalog
else "mean"
)
- # Explicit scalar custom declarations are produced by structured
- # reported-output projections. Legacy/custom drafts without an
- # explicit scalar type keep the original opaque display-only
- # semantics.
- scalar_custom = (
- metric["kind"] == "custom"
- and raw.get("kind") == "custom"
- and raw.get("valueType") in {"number", "boolean"}
- )
- if metric["kind"] == "custom" and not scalar_custom:
- metric["valueType"] = "opaque"
- metric["direction"] = "neutral"
- metric["aggregation"] = "none"
- metric["primary"] = False
if metric.get("unit") is None and catalog and catalog.get("unit"):
metric["unit"] = catalog["unit"]
metric.pop("scoring", None)
@@ -322,7 +409,7 @@ def normalize_metrics(metrics: Any, *, validate_custom_names: bool = False) -> l
# "Untitled custom metric"), so the measurement-plan validator blocks
# production while still preserving every draft for repair.
normalized.append(metric)
- ensure_at_most_one_primary(tuple(bool(metric["primary"]) for metric in normalized))
+ ensure_at_most_one_primary(tuple(bool(metric.get("primary")) for metric in normalized))
return normalized
@@ -369,16 +456,15 @@ def normalize_design_spec(
def declared_primary_metric(metrics: Any) -> dict[str, Any] | None:
"""Return the one explicitly primary design metric, if one is declared."""
- return next((metric for metric in normalize_metrics(metrics) if metric["primary"]), None)
+ return next((metric for metric in normalize_metrics(metrics) if metric.get("primary")), None)
def validate_metric_values(metrics: Any, values: dict[str, Any] | None) -> dict[str, Any]:
"""Validate declared outcome values at the API boundary.
- Metric declarations are dynamic JSONB, so this is intentionally a
- declaration-driven validation rather than a database-column constraint.
+ Custom metrics capture producer output without imposing a value type.
Unknown keys stay allowed for backwards-compatible externally reported
- metrics; declared custom metrics must honor their selected value type.
+ metrics.
"""
declared = {metric["name"]: metric for metric in normalize_metrics(metrics) if metric["kind"] != "runtime"}
normalized: dict[str, Any] = {}
@@ -387,14 +473,5 @@ def validate_metric_values(metrics: Any, values: dict[str, Any] | None) -> dict[
if metric is None:
normalized[key] = value
continue
- value_type = metric["valueType"]
- if value_type == "boolean":
- if not isinstance(value, bool):
- raise ValueError(f"Metric {key!r} must be Boolean.")
- elif value_type == "number":
- if isinstance(value, bool) or not isinstance(value, int | float) or not isfinite(float(value)):
- raise ValueError(f"Metric {key!r} must be a finite number.")
- elif value_type == "string" and not isinstance(value, str):
- raise ValueError(f"Metric {key!r} must be text.")
normalized[key] = value
return normalized
diff --git a/src/asaree/services/prompt_references.py b/src/asaree/services/prompt_references.py
index 728c6fd1..0102517a 100644
--- a/src/asaree/services/prompt_references.py
+++ b/src/asaree/services/prompt_references.py
@@ -27,6 +27,10 @@
fragment. Only legal when the referenced node has an Output Parser
declaring that field, which is checked at design time -- a typo'd field
name is a wiring mistake, not an empty resolution.
+``{{node:.[, ...]}}``
+ A list-of-objects field narrowed to those item keys -- one key gives a list
+ of its values (``engineering_recipe[name]`` -> the step names), several give
+ smaller objects. Only legal on a field declared as an array.
``{{previous}}``
Every direct main-edge predecessor's output, fenced. Survives rewiring,
which a hardcoded id does not, so it is the right default for "just give me
@@ -88,6 +92,11 @@
+ _ID
+ r"(?:\."
+ _FIELD
+ + r"(?:\[\s*(?P"
+ + _FIELD
+ + r"(?:\s*,\s*"
+ + _FIELD
+ + r")*)\s*\])?"
+ r")?"
+ r"|"
+ "|".join(_BARE_TOKENS)
@@ -120,6 +129,9 @@ class PromptReference:
reference to that node, so everything asking "which senders does this
prompt name" keeps working without knowing fields exist."""
+ item_keys: tuple[str, ...] = ()
+ """``[key, ...]`` after the field: narrow each list item to these keys."""
+
def _reference_from(match: re.Match[str]) -> PromptReference:
target = match.group("target")
@@ -129,9 +141,10 @@ def _reference_from(match: re.Match[str]) -> PromptReference:
# case, because node ids are case-sensitive identifiers and lowercasing
# one would turn a valid reference into a missing node. Same for the
# field, which is an attribute name on the extracted model.
- rest = target[len(NODE_PREFIX) :]
+ rest = target[len(NODE_PREFIX) :].split("[", 1)[0]
node_id, _, field = rest.partition(".")
- return PromptReference(match.group(0), "node", node_id, bool(match.group("modifier")), field)
+ keys = tuple(k.strip() for k in (match.group("keys") or "").split(",") if k.strip())
+ return PromptReference(match.group(0), "node", node_id, bool(match.group("modifier")), field, keys)
return PromptReference(match.group(0), lowered, "", bool(match.group("modifier")))
@@ -199,8 +212,12 @@ def substitute(text: str, render: Callable[[PromptReference], str]) -> str:
return _REFERENCE_RE.sub(lambda m: render(_reference_from(m)), text or "")
-def serialize_node_reference(node_id: str, *, field: str = "", raw: bool = False) -> str:
+def serialize_node_reference(
+ node_id: str, *, field: str = "", raw: bool = False, item_keys: tuple[str, ...] = ()
+) -> str:
"""The stored form for a node reference. The picker's output, and the one
place the spelling is defined -- callers must not build it by hand."""
suffix = f".{field}" if field else ""
+ if field and item_keys:
+ suffix += f"[{', '.join(item_keys)}]"
return f"{{{{{NODE_PREFIX}{node_id}{suffix}{'|raw' if raw else ''}}}}}"
diff --git a/src/asaree/services/protocol_execution.py b/src/asaree/services/protocol_execution.py
index aa65cab9..d712a174 100644
--- a/src/asaree/services/protocol_execution.py
+++ b/src/asaree/services/protocol_execution.py
@@ -24,7 +24,7 @@
import logging
import re
import uuid
-from collections.abc import Collection, Iterable, Mapping
+from collections.abc import Collection, Iterable, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -101,6 +101,7 @@
WORKSPACE_AGENT_TOOLS,
WORKSPACE_SERVER_NAME,
)
+from asaree.services.tool_steps import TOOL_STEP_NODE_TYPES, execute_tool_step, validate_tool_step
logger = logging.getLogger(__name__)
@@ -357,6 +358,14 @@ class ProtocolValidationError(Exception):
# for one output is an ambiguity, not a richer declaration.
_OUTPUT_PARSER_NODE_TYPES = frozenset({"output_parser"})
_SUB_AGENT_NODE_TYPES = frozenset({"sub_agent"})
+# A Tool Step is the one main-flow node that isn't an LLM turn: it calls one
+# MCP tool directly with the upstream node's typed payload (see
+# asaree.services.tool_steps). It reverses the old "no standalone tool call"
+# rule above for exactly one case -- a harness-owned, fixed call such as
+# held-out scoring, where asking an Agent to make the call is what let a
+# malformed payload through unsanitized. Its Tool connector takes one MCP Tool
+# (what to call) and at most one Script (the code argument).
+_TOOL_STEP_NODE_TYPES = TOOL_STEP_NODE_TYPES
# Every node type skipped by the main pipeline walk and excluded as a final
# output. Most are pure config sources; Sub-Agent is the deliberate exception:
@@ -923,6 +932,7 @@ def resolve_conversation_entry_id(graph: dict[str, Any]) -> str:
"agent": "Agent",
"sub_agent": "Sub-Agent",
"critic_gate": "Critic Gate",
+ "tool_step": "Tool Step",
"mcp_tool": "MCP Tool",
"mcp_scikit_learn": "Scikit-learn MCP",
"mcp_client_tool": "MCP Client Tool",
@@ -1212,6 +1222,21 @@ def topological_order(graph: dict[str, Any], *, require_acyclic: bool = True) ->
)
elif sub_agent_edges:
raise ProtocolValidationError(f"Sub-Agent node {name!r} cannot own other Sub-Agents.")
+ elif node_type in _TOOL_STEP_NODE_TYPES:
+ if (
+ memory_edges
+ or pattern_edges
+ or dataset_slot_edges
+ or skill_edges
+ or knowledge_edges
+ or parser_edges
+ or sub_agent_edges
+ ):
+ raise ProtocolValidationError(
+ f"Tool Step node {name!r} can only have Tool connections (one MCP Tool, optionally one Script)."
+ )
+ if _is_node_active(node) and (problem := validate_tool_step(graph, node)):
+ raise ProtocolValidationError(f"Tool Step node {name!r} {problem}.")
elif (
tool_edges
or memory_edges
@@ -2790,10 +2815,98 @@ def _upstream_context(
if not text and not payload:
continue
label = f"{names[uid]} ({uid})" if names[uid] in ambiguous else names[uid]
- blocks.append(f"[{label}]\n{_sender_block(str(text or ''), payload)}")
+ handoff = _edge_handoff(graph, uid, node_id)
+ blocks.append(f"[{label}]\n{_handoff_block(str(text or ''), payload, handoff)}")
return "\n\n".join(blocks)
+#: What a main edge passes to the agent it feeds -- ``edge.data.handoff.mode``.
+#: Absent means ``full``. There is deliberately no "nothing": an edge that
+#: carried nothing would be the "graph looks wired, nothing flows" outcome
+#: :func:`_upstream_context` exists to rule out.
+HANDOFF_FULL = "full"
+HANDOFF_FIELDS = "fields"
+HANDOFF_SELECTED = "selected"
+HANDOFF_MODES = (HANDOFF_FULL, HANDOFF_FIELDS, HANDOFF_SELECTED)
+
+
+def _edge_handoff(graph: dict[str, Any], source: str, target: str) -> dict[str, Any] | None:
+ """The main edge *source* -> *target*'s narrowed handoff, or ``None`` for
+ the full output (the default, and what anything unrecognised falls back to
+ -- :func:`validate_edge_handoffs` has already refused it at publish)."""
+ for edge in graph.get("edges") or []:
+ if edge.get("source") != source or edge.get("target") != target:
+ continue
+ if edge.get("targetHandle") in _CONNECTOR_HANDLES:
+ continue
+ handoff = (edge.get("data") or {}).get("handoff")
+ if isinstance(handoff, dict) and handoff.get("mode") in (HANDOFF_FIELDS, HANDOFF_SELECTED):
+ return handoff
+ return None
+ return None
+
+
+def _handoff_field_specs(handoff: dict[str, Any]) -> list[tuple[str, list[str]]]:
+ specs: list[tuple[str, list[str]]] = []
+ for spec in handoff.get("fields") or []:
+ if isinstance(spec, str):
+ name, keys = spec, []
+ elif isinstance(spec, dict):
+ name = str(spec.get("name") or "")
+ keys = [str(k).strip() for k in spec.get("item_keys") or [] if str(k).strip()]
+ else:
+ continue
+ if name.strip():
+ specs.append((name.strip(), keys))
+ return specs
+
+
+def _project_handoff_payload(payload: dict[str, Any], handoff: dict[str, Any]) -> dict[str, Any]:
+ """The part of *payload* a narrowed edge passes on; ``item_keys`` narrow a
+ list field via :func:`_pluck_items`."""
+ if handoff.get("mode") == HANDOFF_FIELDS:
+ return dict(payload)
+ projected: dict[str, Any] = {}
+ for name, keys in _handoff_field_specs(handoff):
+ if name not in payload:
+ continue
+ projected[name] = _pluck_items(payload[name], keys)
+ return projected
+
+
+def _pluck_items(value: Any, keys: Sequence[str]) -> Any:
+ """A list of objects narrowed to *keys*: one key yields a list of its values
+ (``engineering_recipe`` -> just the step names), several a list of smaller
+ objects. Anything that is not a list of objects passes through whole,
+ since there is nothing to narrow. Shared by narrowed edges and
+ ``{{node:X.field[key]}}``."""
+ if not keys or not isinstance(value, list):
+ return value
+ return [
+ (item.get(keys[0]) if len(keys) == 1 else {k: item[k] for k in keys if k in item})
+ if isinstance(item, dict)
+ else item
+ for item in value
+ ]
+
+
+def _handoff_block(text: str, payload: dict[str, Any], handoff: dict[str, Any] | None, *, raw: bool = False) -> str:
+ """One sender's contribution as its edge allows: the whole
+ :func:`_sender_block`, or just the (selected) extracted fields.
+
+ A narrowed edge whose sender extracted none of what it asked for falls back
+ to the full output rather than passing nothing -- the notebook's
+ ``summarize_for_mlm`` makes the same call when a payload is missing. Fields
+ only carry no fence (there is no prose to set apart), but the delimiter is
+ still neutralized: the values are model output all the same."""
+ if handoff is None:
+ return _sender_block(text, payload, raw=raw)
+ projected = _project_handoff_payload(payload, handoff)
+ if not projected:
+ return _sender_block(text, payload, raw=raw)
+ return neutralize_delimiters(_payload_fields_line(projected))
+
+
def _reference_payload(text: str, *, raw: bool) -> str:
"""One referenced output, ready to sit in a prompt.
@@ -2901,7 +3014,9 @@ def _render_reference(
# produced nothing -- so this is an empty resolution, recorded like any
# other, not a failed run.
payload = (node_runs.get(ref.node_id) or {}).get("payload") or {}
- rendered = _format_payload_value(payload.get(ref.field)) if ref.field in payload else ""
+ rendered = (
+ _format_payload_value(_pluck_items(payload[ref.field], ref.item_keys)) if ref.field in payload else ""
+ )
if not rendered:
unresolved.append(f"{ref.node_id}.{ref.field}")
return ""
@@ -2919,7 +3034,10 @@ def _render_reference(
if not text and not payload:
unresolved.append(uid)
continue
- block = _sender_block(str(text or ""), payload, raw=ref.raw)
+ # `{{previous}}` stands for the edge, so it passes what the edge
+ # passes; `{{node:X}}` names the node itself and stays whole.
+ handoff = _edge_handoff(graph, uid, node_id) if ref.kind == prompt_references.PREVIOUS else None
+ block = _handoff_block(str(text or ""), payload, handoff, raw=ref.raw)
# Labelled only where the reference itself cannot say which sender is
# which: a `{{previous}}` that expanded to several predecessors. A
# single-node reference needs no label, because the experimenter named
@@ -2994,6 +3112,7 @@ def validate_prompt_references(*, graph: dict[str, Any]) -> None:
for something the edge does not already give them -- a different position, a
node further back, one extracted field.
"""
+ validate_edge_handoffs(graph=graph)
for node in graph.get("nodes") or []:
node_id = node.get("id")
if not node_id:
@@ -3058,6 +3177,19 @@ def validate_prompt_references(*, graph: dict[str, Any]) -> None:
f"Parser does not declare. It declares: {', '.join(declared)}."
)
+ # Item keys narrow a list of objects, so they only make sense on a
+ # field declared as one; elsewhere they would silently do nothing.
+ for ref in prompt_references.iter_references(prompt):
+ if not ref.item_keys or ref.node_id not in known or ref.node_id not in allowed:
+ continue
+ contract = _resolve_output_contract(graph, ref.node_id) or {}
+ spec = next((f for f in contract.get("fields") or [] if f.get("name") == ref.field), None)
+ if spec is not None and str(spec.get("type") or "") != "array":
+ other = _node_display_name(next(n for n in graph["nodes"] if str(n.get("id")) == ref.node_id))
+ raise ProtocolValidationError(
+ f"{name}'s {field} narrows {other!r}.{ref.field} to item keys, but it is not a list field."
+ )
+
if prompt_references.uses(prompt, prompt_references.PREVIOUS) and not _upstream_ids(graph, node_id):
raise ProtocolValidationError(
f"{name}'s {field} uses {{{{previous}}}} but nothing is connected to its input. "
@@ -3065,6 +3197,80 @@ def validate_prompt_references(*, graph: dict[str, Any]) -> None:
)
+def _handoff_sender_contract_fields(graph: dict[str, Any], sender_id: str) -> list[dict[str, Any]]:
+ """The fields a main edge out of *sender_id* can pass.
+
+ A Critic Gate forwards its worker's payload (``_handoff_payload``), so an
+ edge out of a gate offers the worker's fields."""
+ nodes = {str(n.get("id")): n for n in graph.get("nodes") or []}
+ if (nodes.get(sender_id) or {}).get("type") == "critic_gate":
+ workers = _upstream_ids(graph, sender_id)
+ if not workers:
+ return []
+ sender_id = workers[0]
+ contract = _resolve_output_contract(graph, sender_id)
+ return [
+ spec
+ for spec in (contract or {}).get("fields") or []
+ if isinstance(spec, dict) and str(spec.get("name") or "").strip()
+ ]
+
+
+def validate_edge_handoffs(*, graph: dict[str, Any]) -> None:
+ """Refuse a narrowed main-edge handoff that could never be honoured.
+
+ Called from :func:`validate_prompt_references`, so it lands everywhere that
+ does (publish/plan/run). Only an edge into an Agent may narrow: a Critic
+ Gate has to review the whole answer it gates, and the other node types
+ read their predecessor's payload directly."""
+ nodes = {str(n.get("id")): n for n in graph.get("nodes") or []}
+ for edge in graph.get("edges") or []:
+ if edge.get("targetHandle") in _CONNECTOR_HANDLES:
+ continue
+ handoff = (edge.get("data") or {}).get("handoff")
+ if handoff is None:
+ continue
+ sender = nodes.get(str(edge.get("source"))) or {"id": edge.get("source")}
+ target = nodes.get(str(edge.get("target"))) or {"id": edge.get("target")}
+ where = f"The edge {_node_display_name(sender)} -> {_node_display_name(target)}"
+ mode = handoff.get("mode") if isinstance(handoff, dict) else None
+ if mode not in HANDOFF_MODES:
+ raise ProtocolValidationError(
+ f"{where} has an unknown handoff {mode!r}. Choose Full output, Extracted fields only, or "
+ "Selected fields."
+ )
+ if mode == HANDOFF_FULL:
+ continue
+ if target.get("type") != "agent":
+ raise ProtocolValidationError(
+ f"{where} can only pass the full output: only an Agent can receive a narrowed handoff, "
+ "and a Critic Gate always reviews the whole answer."
+ )
+ declared = {str(spec["name"]).strip(): spec for spec in _handoff_sender_contract_fields(graph, sender["id"])}
+ if not declared:
+ raise ProtocolValidationError(
+ f"{where} passes extracted fields, but {_node_display_name(sender)} has no Output Parser "
+ "declaring any. Connect one, or set the edge back to Full output."
+ )
+ if mode == HANDOFF_FIELDS:
+ continue
+ specs = _handoff_field_specs(handoff)
+ if not specs:
+ raise ProtocolValidationError(f"{where} passes selected fields, but none are selected.")
+ seen: set[str] = set()
+ for name, keys in specs:
+ if name not in declared:
+ raise ProtocolValidationError(
+ f"{where} passes {name!r}, which the sender's Output Parser does not declare. "
+ f"It declares: {', '.join(declared)}."
+ )
+ if name in seen:
+ raise ProtocolValidationError(f"{where} selects {name!r} twice.")
+ seen.add(name)
+ if keys and str(declared[name].get("type") or "") != "array":
+ raise ProtocolValidationError(f"{where} narrows {name!r} to item keys, but it is not a list field.")
+
+
def _resource_catalog(graph: dict[str, Any], node_id: str) -> str:
"""Compact semantic metadata for references wired into one agent.
@@ -4920,6 +5126,35 @@ async def run_protocol(protocol_run_id: uuid.UUID) -> None:
failed = True
continue
+ if node.get("type") in _TOOL_STEP_NODE_TYPES and _is_node_active(node):
+
+ async def record_step_provenance(step: dict[str, Any], *, step_node_id: str = node_id) -> None:
+ # Before the call, like the notebook's pre-scoring upsert: a
+ # crash mid-call still leaves the approved payload on record.
+ async with get_session() as db:
+ await update_node_run(db, protocol_run_id, step_node_id, {"tool_step": step})
+ if replicate_label and experiment_id and await is_current_replicate_attempt(db, protocol_run_id):
+ await upsert_replicate(
+ db,
+ experiment_id=experiment_id,
+ replicate_label=replicate_label,
+ fields={"artifacts": {f"tool_step:{step_node_id}": step}},
+ revision_id=design_revision_id,
+ )
+
+ node_runs[node_id] = await execute_tool_step(
+ node,
+ graph=graph,
+ upstream_runs=[node_runs.get(uid) or {} for uid in _upstream_ids(graph, node_id)],
+ workspace_id=workspace_id,
+ record_provenance=record_step_provenance,
+ )
+ if node_runs[node_id]["status"] == "failed":
+ failed = True
+ async with get_session() as db:
+ await update_node_run(db, protocol_run_id, node_id, node_runs[node_id])
+ continue
+
output_text: str | None
error: str | None
run_id: uuid.UUID | None
diff --git a/src/asaree/services/reported_metrics.py b/src/asaree/services/reported_metrics.py
index 214c8028..19d9f9d6 100644
--- a/src/asaree/services/reported_metrics.py
+++ b/src/asaree/services/reported_metrics.py
@@ -7,7 +7,6 @@
from __future__ import annotations
-import json
import uuid
from collections.abc import Mapping, Sequence
from copy import deepcopy
@@ -18,6 +17,16 @@
from motoro.services import mcp_service
from asaree.models.protocol_run import ProtocolRun
+from asaree.services.derived_metrics import (
+ DERIVED_PRODUCER_IDS,
+ FEATURE_PIPELINE_PRODUCER_ID,
+ TOOL_STEP_PRODUCER_ID,
+ collect_derived_observations,
+ derived_binding_issue,
+ output_projection,
+ project_value,
+ projection_issue,
+)
from asaree.services.measurement_engine import (
MeasurementEvaluation,
MeasurementPlan,
@@ -26,66 +35,22 @@
ProducerProvenance,
ValidationIssue,
ValidationReport,
- is_scalar_value,
preserved_binding_severity,
)
from asaree.services.protocol_graph import directly_connected_tool_pair, node_map
+from asaree.services.tool_steps import flatten_paths, parse_json_object
AGENT_OUTPUT_PRODUCER_ID = "asaree.agent_output"
PYTHON_SCRIPT_PRODUCER_ID = "asaree.python_script"
MCP_TOOL_PRODUCER_ID = "asaree.mcp_tool"
-REPORTED_PRODUCER_IDS = frozenset({AGENT_OUTPUT_PRODUCER_ID, PYTHON_SCRIPT_PRODUCER_ID, MCP_TOOL_PRODUCER_ID})
+REPORTED_PRODUCER_IDS = frozenset(
+ {AGENT_OUTPUT_PRODUCER_ID, PYTHON_SCRIPT_PRODUCER_ID, MCP_TOOL_PRODUCER_ID, *DERIVED_PRODUCER_IDS}
+)
MCP_TOOL_NODE_TYPES = frozenset({"mcp_tool", "mcp_scikit_learn", "mcp_client_tool"})
_SCRIPT_SERVER = "asaree-script"
_SCRIPT_TOOL = "run_wired_script"
-def _structured_value(value: Any) -> Any:
- """Decode a JSON transport string while preserving already-typed values."""
- if not isinstance(value, str):
- return value
- try:
- return json.loads(value)
- except json.JSONDecodeError:
- return value
-
-
-def _project_reported_value(value: Any, projection: Any) -> tuple[Any, str | None]:
- """Select one typed value from a reported JSON document.
-
- Projection paths use dot-separated object keys (and integer list indexes).
- ``length`` is intentionally the only transform: it covers collection counts
- without turning measurement plans into an expression language.
- """
- if not isinstance(projection, Mapping):
- return None, "The reported metric projection is invalid."
- path = projection.get("path")
- if not isinstance(path, str):
- return None, "The reported metric projection has no path."
- current = _structured_value(value)
- segments = (
- [part.replace("~1", "/").replace("~0", "~") for part in path.removeprefix("/").split("/")]
- if path.startswith("/")
- else [part for part in path.split(".") if part]
- )
- for segment in segments:
- if isinstance(current, Mapping) and segment in current:
- current = current[segment]
- elif isinstance(current, Sequence) and not isinstance(current, str):
- try:
- current = current[int(segment)]
- except (ValueError, IndexError):
- return None, f"Reported metric path {path!r} is unavailable."
- else:
- return None, f"Reported metric path {path!r} is unavailable."
- transform = projection.get("transform")
- if transform is None:
- return current, None
- if transform == "length" and isinstance(current, Mapping | Sequence) and not isinstance(current, str):
- return len(current), None
- return None, f"Reported metric transform {transform!r} cannot be applied."
-
-
def _tool_matches(recorded: Any, configured: str) -> bool:
return isinstance(recorded, str) and (recorded == configured or recorded.endswith(f".{configured}"))
@@ -184,23 +149,31 @@ async def collect_reported_metrics(
for binding in plan.producers:
if binding.producer_id not in REPORTED_PRODUCER_IDS:
continue
+ if binding.producer_id in DERIVED_PRODUCER_IDS:
+ observations.extend(
+ await collect_derived_observations(
+ binding, metrics, node_runs=run.node_runs or {}, graph=graph, attempt_id=attempt_id
+ )
+ )
+ continue
node_run = (run.node_runs or {}).get(str(binding.config.get("agent_node_id") or ""))
successful_output = (
node_run.get("last_successful_output_text", node_run.get("output_text"))
if isinstance(node_run, Mapping)
else None
)
- structured_agent_output = (
- node_run.get("last_successful_payload", node_run.get("payload"))
- if isinstance(node_run, Mapping)
- else None
- )
agent_output_available = (
binding.producer_id == AGENT_OUTPUT_PRODUCER_ID
and isinstance(node_run, Mapping)
and (node_run.get("status") == "completed" or "last_successful_output_text" in node_run)
- and (successful_output is not None or structured_agent_output is not None)
+ and successful_output is not None
)
+ # Field projections read the Agent's typed output: its Output Parser
+ # payload, else a JSON object in its final answer.
+ structured = node_run.get("payload") if agent_output_available and isinstance(node_run, Mapping) else None
+ if agent_output_available and not isinstance(structured, Mapping):
+ structured = parse_json_object(successful_output)
+ structured_paths = flatten_paths(structured) if isinstance(structured, Mapping) else {}
call = (
None if binding.producer_id == AGENT_OUTPUT_PRODUCER_ID else await _last_matching_call(run, binding, graph)
)
@@ -225,37 +198,25 @@ async def collect_reported_metrics(
),
},
)
- projections = binding.config.get("projections")
- projections = projections if isinstance(projections, Mapping) else {}
for output_key, metric_id in binding.outputs.items():
metric = metrics.get(metric_id)
if metric is None:
continue
- raw_value = (
- structured_agent_output
- if agent_output_available and output_key in projections and structured_agent_output is not None
- else successful_output
- if agent_output_available
- else call.get("result")
- if call is not None
- else None
- )
- value, projection_error = (
- _project_reported_value(raw_value, projections[output_key])
- if output_key in projections
- else (raw_value, None)
- )
- type_error = (
- None
- if projection_error is not None or not measured or is_scalar_value(value, metric.value_type)
- else f"Projected value does not match declared type {metric.value_type!r}."
- )
- observation_error = projection_error or type_error
+ projection = output_projection(binding, output_key) if agent_output_available else None
+ value: Any
+ observation_error: str | None
+ if projection is not None:
+ value, observation_error = project_value(structured_paths, projection)
+ else:
+ value = (
+ successful_output if agent_output_available else call.get("result") if call is not None else None
+ )
+ observation_error = None
observations.append(
MetricObservation(
metric_id=metric.id,
metric_name=metric.name,
- value_type=metric.value_type,
+ value_type=None,
value=value if observation_error is None else None,
status="measured" if measured and observation_error is None else "unavailable",
error=(
@@ -361,39 +322,31 @@ async def validate_reported_measurement_plan(
if binding.producer_id not in REPORTED_PRODUCER_IDS:
continue
path = f"producers[{index}]"
- raw_projections = binding.config.get("projections")
- projections = raw_projections if isinstance(raw_projections, Mapping) else {}
- if raw_projections is not None and not isinstance(raw_projections, Mapping):
- issues.append(
- ValidationIssue(
- "reported_projections_invalid",
- "Reported metric projections must be an object keyed by producer output.",
- f"{path}.config.projections",
- )
- )
for output_key, metric_id in binding.outputs.items():
metric = metrics.get(metric_id)
- projection = projections.get(output_key)
- if metric is not None and metric.value_type != "opaque" and projection is None:
+ if metric is not None and metric.value_type is not None:
issues.append(
ValidationIssue(
- "reported_projection_missing",
- f"Scalar reported metric {metric.name!r} requires a structured field projection.",
+ "reported_metric_semantics_not_supported",
+ f"Custom metric {metric.name!r} captures the producer output without value semantics.",
f"{path}.outputs.{output_key}",
)
)
- if projection is not None and (
- not isinstance(projection, Mapping)
- or not isinstance(projection.get("path"), str)
- or projection.get("transform") not in {None, "length"}
- ):
+ if projected := projection_issue(binding):
+ code, message, key = projected
+ issues.append(ValidationIssue(code, message, f"{path}.config.{key}"))
+ if binding.producer_id in DERIVED_PRODUCER_IDS:
+ if derived := derived_binding_issue(binding, graph):
+ code, message, key = derived
issues.append(
ValidationIssue(
- "reported_projection_invalid",
- "A reported metric projection needs a path and an optional supported transform.",
- f"{path}.config.projections.{output_key}",
+ code,
+ message,
+ f"{path}.{key}" if key == "outputs" else f"{path}.config.{key}",
+ preserved_binding_severity(binding.id, preserved_binding_ids),
)
)
+ continue
if agent_issue := _agent_issue(binding, index, graph, preserved_binding_ids):
issues.append(agent_issue)
if binding.producer_id == AGENT_OUTPUT_PRODUCER_ID:
@@ -523,10 +476,12 @@ async def validate_reported_measurement_plan(
__all__ = [
"AGENT_OUTPUT_PRODUCER_ID",
+ "FEATURE_PIPELINE_PRODUCER_ID",
"MCP_TOOL_NODE_TYPES",
"MCP_TOOL_PRODUCER_ID",
"PYTHON_SCRIPT_PRODUCER_ID",
"REPORTED_PRODUCER_IDS",
+ "TOOL_STEP_PRODUCER_ID",
"collect_reported_metrics",
"resolve_reported_metric_graph",
"validate_reported_measurement_plan",
diff --git a/src/asaree/services/runtime_metrics.py b/src/asaree/services/runtime_metrics.py
index 2590053c..fb4d5632 100644
--- a/src/asaree/services/runtime_metrics.py
+++ b/src/asaree/services/runtime_metrics.py
@@ -3,6 +3,8 @@
from __future__ import annotations
import asyncio
+import hashlib
+import json
import re
import uuid
from collections.abc import Mapping, Sequence
@@ -42,9 +44,11 @@
ValidationIssue,
ValidationReport,
parse_measurement_plan,
+ preserved_binding_severity,
)
from asaree.services.measurement_migration import normalize_experiment_measurement_plan
from asaree.services.metrics import design_metrics_from_measurement_plan
+from asaree.services.protocol_graph import node_map
from asaree.services.protocol_runs import (
TERMINAL_PROTOCOL_RUN_STATUSES,
get_cancel_requested_at,
@@ -58,7 +62,7 @@
_SUM_OR_MEAN: set[MetricAggregation] = {"sum", "mean"}
_RATE_AGGREGATIONS: set[MetricAggregation] = {"mean", "rate", "pooled"}
-_SUPPORTED_PRODUCER_IDS = frozenset({"asaree.runtime", *REPORTED_PRODUCER_IDS})
+_SUPPORTED_PRODUCER_IDS = frozenset({"asaree.runtime", "asaree.node_runtime", *REPORTED_PRODUCER_IDS})
def _merge_evaluations(
@@ -161,6 +165,61 @@ def _reported_usage(run: Any, steps: Sequence[Any]) -> dict[str, int | float]:
return usage
+#: Node types whose authored text is a prompt the run sent to a model: the
+#: stage agents, the critics (their system prompts carry the review criteria),
+#: and the Output Parser contracts that shape what each stage returns.
+_PROMPT_NODE_TYPES = frozenset({"agent", "critic_gate", "output_parser"})
+_PROMPT_CONFIG_KEYS = ("system_prompt", "goal", "prompt", "output_contract")
+
+
+def authored_prompt_sha256(graph: Mapping[str, Any]) -> str | None:
+ """SHA-256 over the authored prompts of the graph a replicate ran.
+
+ The notebook's ``prompt_sha256``: tamper evidence that the stage prompts and
+ review criteria are the ones the experiment claims. Computed from the
+ factor-resolved published revision, so a prompt bound to a factor hashes
+ per cell, and a canvas edit after publishing cannot change it. Canonical
+ JSON sorted by node id, so moving a node or reordering the graph is not a
+ prompt change. ``None`` when the graph has no prompt text at all."""
+ entries = []
+ for node in graph.get("nodes") or []:
+ if not isinstance(node, Mapping) or node.get("type") not in _PROMPT_NODE_TYPES:
+ continue
+ config = (node.get("data") or {}).get("config") or {}
+ fields = {key: config[key] for key in _PROMPT_CONFIG_KEYS if config.get(key)}
+ if fields:
+ entries.append({"id": str(node.get("id")), "type": node.get("type"), **fields})
+ if not entries:
+ return None
+ entries.sort(key=lambda entry: entry["id"])
+ canonical = json.dumps(entries, sort_keys=True, separators=(",", ":"), default=str)
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
+
+
+def _max_iterations_hit(run: Any) -> bool:
+ """Whether a Reason+Act run was cut off by its iteration ceiling (see
+ ``protocol_execution._truncation_fields`` for why this is read from
+ ``pattern_overrides`` rather than the steps)."""
+ state = _reason_act_state(run)
+ return state is not None and bool(state.get("max_iterations_hit"))
+
+
+def _reason_act_state(run: Any) -> Mapping[str, Any] | None:
+ """The loop summary Reason+Act mirrors onto its run; absent on single-pass runs."""
+ state = (getattr(run, "pattern_overrides", None) or {}).get("reason_act_state")
+ return state if isinstance(state, Mapping) and state else None
+
+
+def _reason_act_facts(run: Any) -> dict[str, int] | None:
+ state = _reason_act_state(run)
+ if state is None:
+ return None
+ return {
+ "iterations": int(_number(state.get("iterations")) or 0),
+ "tool_calls": int(_number(state.get("tool_calls")) or 0),
+ }
+
+
async def collect_runtime_facts(protocol_run: Any) -> dict[str, Any]:
"""Snapshot every Motoro run causally attributed to one ProtocolRun."""
# Motoro applies ``limit`` after its exact JSON metadata filter. Passing
@@ -178,9 +237,14 @@ async def collect_runtime_facts(protocol_run: Any) -> dict[str, Any]:
runs = []
critic_reviews = []
for run, steps in zip(ordered, step_results, strict=True):
+ metadata = getattr(run, "run_metadata", None)
+ node_id = metadata.get("node_id") if isinstance(metadata, Mapping) else None
runs.append(
{
"run_id": str(run.id),
+ # The canvas node that launched the run -- a critic run carries
+ # its gate's id -- so a node-scoped binding can split totals.
+ "node_id": str(node_id) if node_id is not None else None,
# Same v0.6 normalization issue as token usage: 0.0 is also
# the fallback when pricing was unavailable. Prefer an
# unavailable observation to a false claim that the run was
@@ -197,14 +261,26 @@ async def collect_runtime_facts(protocol_run: Any) -> dict[str, Any]:
}
for step in steps
],
+ "max_iterations_hit": _max_iterations_hit(run),
+ # Present (possibly None) on every run so an attempt collected
+ # before this field existed reads as unrecorded, not zero.
+ "reason_act": _reason_act_facts(run),
}
)
- metadata = getattr(run, "run_metadata", None)
if isinstance(metadata, Mapping) and metadata.get("runtime_role") == "critic":
envelope = parse_envelope(getattr(run, "output", None))
payload = envelope.payload if envelope is not None else None
- if isinstance(payload, Mapping) and isinstance(payload.get("approved"), bool):
- critic_reviews.append({"approved": payload["approved"], "run_id": str(run.id)})
+ # Every verdict the gate acted on, including ``approved: null`` --
+ # the revision loop treats anything but ``true`` as a rejection.
+ if isinstance(payload, Mapping):
+ approved = payload.get("approved")
+ critic_reviews.append(
+ {
+ "approved": approved if isinstance(approved, bool) else None,
+ "rejection_scope": payload.get("rejection_scope"),
+ "run_id": str(run.id),
+ }
+ )
gates = []
for node_run in (protocol_run.node_runs or {}).values():
if not isinstance(node_run, Mapping) or not any(
@@ -250,6 +326,68 @@ def _duration(facts: Mapping[str, Any]) -> ProducedObservation:
return ProducedObservation.measured(max(0.0, (completion - start).total_seconds()))
+def _run_observations(runs: Sequence[Mapping[str, Any]]) -> dict[str, ProducedObservation]:
+ """Observations that are plain sums over a set of attributed Motoro runs."""
+ observations = {
+ key: _sum_reported(runs, key)
+ for key in (
+ "cost_usd",
+ "input_tokens",
+ "output_tokens",
+ "total_tokens",
+ "cache_read_tokens",
+ "cache_creation_tokens",
+ "thinking_tokens",
+ )
+ }
+ tool_attempts: list[Mapping[str, Any]] = []
+ iterations: set[tuple[str, int]] = set()
+ for run in runs:
+ run_id = str(run.get("run_id") or "")
+ steps = run.get("steps")
+ if not isinstance(steps, list):
+ continue
+ for step in steps:
+ if not isinstance(step, Mapping):
+ continue
+ tool_attempts.extend(_tool_attempts(step.get("tool_call")))
+ iteration = step.get("iteration")
+ if run_id and isinstance(iteration, int) and not isinstance(iteration, bool):
+ iterations.add((run_id, iteration))
+
+ observations["tool_calls"] = ProducedObservation.measured(len(tool_attempts))
+ if tool_attempts:
+ failed = sum(call.get("success") is not True for call in tool_attempts)
+ observations["tool_error_rate"] = ProducedObservation.measured(failed / len(tool_attempts))
+ else:
+ observations["tool_error_rate"] = ProducedObservation.unavailable(
+ "Tool error rate is undefined because no tool calls were attempted."
+ )
+ observations["agent_loop_iterations"] = ProducedObservation.measured(len(iterations))
+ observations["capped_agent_runs"] = ProducedObservation.measured(
+ sum(run.get("max_iterations_hit") is True for run in runs)
+ )
+ observations["agent_runs"] = ProducedObservation.measured(len(runs))
+ # The loop's own counters (the notebook's react_* columns), as opposed to
+ # the step-derived turns and tool calls above, which cover every run.
+ if runs and not any("reason_act" in run for run in runs):
+ unrecorded = ProducedObservation.unavailable("This attempt did not record Reason+Act loop state.")
+ for key in ("react_runs", "react_turns", "react_tool_calls"):
+ observations[key] = unrecorded
+ return observations
+ react = [run["reason_act"] for run in runs if isinstance(run.get("reason_act"), Mapping)]
+ observations["react_runs"] = ProducedObservation.measured(len(react))
+ if react:
+ observations["react_turns"] = ProducedObservation.measured(sum(int(r.get("iterations") or 0) for r in react))
+ observations["react_tool_calls"] = ProducedObservation.measured(
+ sum(int(r.get("tool_calls") or 0) for r in react)
+ )
+ else:
+ for key in ("react_turns", "react_tool_calls"):
+ observations[key] = ProducedObservation.unavailable("No Reason+Act run took part in this attempt.")
+ return observations
+
+
class RuntimeMetricProducer:
"""Compute runtime observations from a previously collected attempt snapshot."""
@@ -268,6 +406,16 @@ class RuntimeMetricProducer:
"agent_loop_iterations",
"critic_rejections",
"critic_approvals",
+ "critic_invocations",
+ "critic_rejections_partial",
+ "critic_rejections_full",
+ "revision_rounds",
+ "capped_agent_runs",
+ "agent_runs",
+ "react_runs",
+ "react_turns",
+ "react_tool_calls",
+ "prompt_sha256",
)
def capability_for(self, snapshot: ExperimentSnapshot) -> ProducerCapability | None:
@@ -277,6 +425,7 @@ def capability_for(self, snapshot: ExperimentSnapshot) -> ProducerCapability | N
key: ScalarOutputCapability(value_type="number", aggregations=_SUM_OR_MEAN) for key in self.output_keys
}
scalar_outputs["tool_error_rate"] = ScalarOutputCapability(value_type="number", aggregations=_RATE_AGGREGATIONS)
+ scalar_outputs["prompt_sha256"] = ScalarOutputCapability(value_type="opaque", aggregations={"none"})
return ProducerCapability(
producer_id=self.producer_id,
kind="runtime",
@@ -303,80 +452,181 @@ async def evaluate(
runs = facts.value.get("runs")
attributed_runs = [run for run in runs if isinstance(run, Mapping)] if isinstance(runs, list) else []
- observations = {
- key: _sum_reported(attributed_runs, key)
- for key in (
- "cost_usd",
- "input_tokens",
- "output_tokens",
- "total_tokens",
- "cache_read_tokens",
- "cache_creation_tokens",
- "thinking_tokens",
- )
- }
+ observations = _run_observations(attributed_runs)
observations["duration_seconds"] = _duration(facts.value)
- tool_attempts: list[Mapping[str, Any]] = []
- iterations: set[tuple[str, int]] = set()
- for run in attributed_runs:
- run_id = str(run.get("run_id") or "")
- steps = run.get("steps")
- if not isinstance(steps, list):
- continue
- for step in steps:
- if not isinstance(step, Mapping):
- continue
- tool_attempts.extend(_tool_attempts(step.get("tool_call")))
- iteration = step.get("iteration")
- if run_id and isinstance(iteration, int) and not isinstance(iteration, bool):
- iterations.add((run_id, iteration))
-
- observations["tool_calls"] = ProducedObservation.measured(len(tool_attempts))
- if tool_attempts:
- failed = sum(call.get("success") is not True for call in tool_attempts)
- observations["tool_error_rate"] = ProducedObservation.measured(failed / len(tool_attempts))
- else:
- observations["tool_error_rate"] = ProducedObservation.unavailable(
- "Tool error rate is undefined because no tool calls were attempted."
- )
- observations["agent_loop_iterations"] = ProducedObservation.measured(len(iterations))
-
reviews = facts.value.get("critic_reviews")
critic_reviews = (
[review for review in reviews if isinstance(review, Mapping)] if isinstance(reviews, list) else []
)
+ gates = facts.value.get("critic_gates")
+ critic_gates = [gate for gate in gates if isinstance(gate, Mapping)] if isinstance(gates, list) else []
+ revision_rounds = sum(max(int(gate.get("revisions_used") or 0), 0) for gate in critic_gates)
if critic_reviews:
- observations["critic_rejections"] = ProducedObservation.measured(
- sum(review.get("approved") is False for review in critic_reviews)
- )
- observations["critic_approvals"] = ProducedObservation.measured(
- sum(review.get("approved") is True for review in critic_reviews)
- )
+ # The gate revises on anything but an explicit approval, so a null
+ # verdict is a rejection here too.
+ rejections = [review for review in critic_reviews if review.get("approved") is not True]
+ partial = sum(review.get("rejection_scope") == "partial" for review in rejections)
+ observations["critic_rejections"] = ProducedObservation.measured(len(rejections))
+ observations["critic_approvals"] = ProducedObservation.measured(len(critic_reviews) - len(rejections))
+ observations["critic_invocations"] = ProducedObservation.measured(len(critic_reviews))
+ # An absent or unrecognized scope revises as "full" (see
+ # _build_revision_instruction), so the partial count is a floor.
+ observations["critic_rejections_partial"] = ProducedObservation.measured(partial)
+ observations["critic_rejections_full"] = ProducedObservation.measured(len(rejections) - partial)
else:
# Compatibility for an attempt collected before critic runs were
- # explicitly role-tagged in Motoro metadata.
- gates = facts.value.get("critic_gates")
- critic_gates = [gate for gate in gates if isinstance(gate, Mapping)] if isinstance(gates, list) else []
- observations["critic_rejections"] = ProducedObservation.measured(
- sum(max(int(gate.get("revisions_used") or 0), 0) for gate in critic_gates)
+ # explicitly role-tagged in Motoro metadata -- or a run whose gates
+ # never called a critic, where every count is genuinely zero.
+ approvals = sum(gate.get("approved") is True and gate.get("forced") is not True for gate in critic_gates)
+ observations["critic_rejections"] = ProducedObservation.measured(revision_rounds)
+ observations["critic_approvals"] = ProducedObservation.measured(approvals)
+ observations["critic_invocations"] = ProducedObservation.measured(revision_rounds + approvals)
+ if revision_rounds:
+ for key in ("critic_rejections_partial", "critic_rejections_full"):
+ observations[key] = ProducedObservation.unavailable(
+ "This attempt did not record each rejection's scope."
+ )
+ else:
+ observations["critic_rejections_partial"] = ProducedObservation.measured(0)
+ observations["critic_rejections_full"] = ProducedObservation.measured(0)
+ observations["revision_rounds"] = ProducedObservation.measured(revision_rounds)
+ prompt_sha = facts.value.get("prompt_sha256")
+ observations["prompt_sha256"] = (
+ ProducedObservation.measured(prompt_sha)
+ if isinstance(prompt_sha, str)
+ else ProducedObservation.unavailable("This attempt's published protocol revision has no prompt text.")
+ )
+ return ProducerResult(observations=observations)
+
+
+class NodeRuntimeMetricProducer:
+ """Runtime totals over only the Motoro runs launched by chosen canvas nodes.
+
+ The notebook's per-stage ``tokens_`` / ``n_turns_`` columns:
+ ``config.node_ids`` names the nodes a stage is made of -- one Agent, or
+ every Critic Gate for a "critic" stage -- and every attempt each of them
+ made counts, revisions included. A separate producer rather than a scoped
+ ``asaree.runtime`` binding so the built-in runtime metrics stay one
+ unscoped binding with one metric per output."""
+
+ producer_id = "asaree.node_runtime"
+ output_keys = (
+ "agent_runs",
+ "cost_usd",
+ "input_tokens",
+ "output_tokens",
+ "total_tokens",
+ "cache_read_tokens",
+ "cache_creation_tokens",
+ "thinking_tokens",
+ "tool_calls",
+ "tool_error_rate",
+ "agent_loop_iterations",
+ "capped_agent_runs",
+ "react_runs",
+ "react_turns",
+ "react_tool_calls",
+ )
+
+ def capability_for(self, snapshot: ExperimentSnapshot) -> ProducerCapability | None:
+ if not any(value.value_type == "runtime_facts" for value in snapshot.inputs.values()):
+ return None
+ scalar_outputs = {
+ key: ScalarOutputCapability(value_type="number", aggregations=_SUM_OR_MEAN) for key in self.output_keys
+ }
+ scalar_outputs["tool_error_rate"] = ScalarOutputCapability(value_type="number", aggregations=_RATE_AGGREGATIONS)
+ return ProducerCapability(
+ producer_id=self.producer_id,
+ kind="runtime",
+ version="1",
+ inputs={"facts": "runtime_facts"},
+ scalar_outputs=scalar_outputs,
+ )
+
+ async def evaluate(
+ self,
+ binding: ProducerBinding,
+ inputs: dict[str, MeasurementInput],
+ attempt: CompletedReplicate,
+ ) -> ProducerResult:
+ del attempt
+ facts = inputs.get("facts")
+ if facts is None or not isinstance(facts.value, Mapping):
+ raise ValueError("node runtime producer requires a runtime_facts input")
+ collection_error = facts.value.get("collection_error")
+ if isinstance(collection_error, str):
+ return ProducerResult(
+ observations={key: ProducedObservation.failed(collection_error) for key in binding.outputs}
)
- observations["critic_approvals"] = ProducedObservation.measured(
- sum(gate.get("approved") is True and gate.get("forced") is not True for gate in critic_gates)
+ node_ids = set(binding_node_ids(binding.config))
+ runs = facts.value.get("runs")
+ all_runs = [run for run in runs if isinstance(run, Mapping)] if isinstance(runs, list) else []
+ if all_runs and not any("node_id" in run for run in all_runs):
+ return ProducerResult(
+ observations={
+ key: ProducedObservation.unavailable("This attempt did not record which node launched each run.")
+ for key in binding.outputs
+ }
)
+ scoped = [run for run in all_runs if run.get("node_id") in node_ids]
+ observations = _run_observations(scoped)
return ProducerResult(observations=observations)
+def binding_node_ids(config: Mapping[str, Any]) -> list[str]:
+ node_ids = config.get("node_ids")
+ if not isinstance(node_ids, list):
+ return []
+ return [node_id for node_id in node_ids if isinstance(node_id, str) and node_id]
+
+
+_RUNTIME_PRODUCER_IDS = frozenset({RuntimeMetricProducer.producer_id, NodeRuntimeMetricProducer.producer_id})
+
+
+def _node_scope_issues(
+ declared: MeasurementPlan,
+ graph: Mapping[str, Any] | None,
+ preserved_binding_ids: set[str] | None,
+) -> list[ValidationIssue]:
+ issues = []
+ nodes = node_map(graph) if graph is not None else None
+ for index, binding in enumerate(declared.producers):
+ if binding.producer_id != NodeRuntimeMetricProducer.producer_id:
+ continue
+ path = f"producers[{index}].config.node_ids"
+ node_ids = binding_node_ids(binding.config)
+ severity = preserved_binding_severity(binding.id, preserved_binding_ids, has_preserved_value=bool(node_ids))
+ if not node_ids:
+ issues.append(ValidationIssue("node_runtime_nodes_missing", "Choose at least one node to measure.", path))
+ continue
+ missing = [node_id for node_id in node_ids if nodes is not None and node_id not in nodes]
+ if missing:
+ issues.append(
+ ValidationIssue(
+ "node_runtime_node_unavailable",
+ f"Measured node(s) {', '.join(missing)} are not on the canvas.",
+ path,
+ severity,
+ )
+ )
+ return issues
+
+
def validate_runtime_measurement_plan(
- document: Any, *, preserved_binding_ids: set[str] | None = None
+ document: Any,
+ *,
+ preserved_binding_ids: set[str] | None = None,
+ graph: Mapping[str, Any] | None = None,
) -> ValidationReport:
- """Validate the runtime-producer slice of a complete measurement plan."""
+ """Validate the runtime-producer slice of a complete measurement plan.
+
+ ``graph``, when given, is checked against node-scoped bindings' node ids."""
if not document:
return ValidationReport(())
declared = parse_measurement_plan(normalize_experiment_measurement_plan(document, ()))
- bindings = tuple(
- binding for binding in declared.producers if binding.producer_id == RuntimeMetricProducer.producer_id
- )
+ scope_issues = _node_scope_issues(declared, graph, preserved_binding_ids)
+ bindings = tuple(binding for binding in declared.producers if binding.producer_id in _RUNTIME_PRODUCER_IDS)
if not bindings:
return ValidationReport(())
binding_ids = {binding.id for binding in bindings}
@@ -393,7 +643,9 @@ def validate_runtime_measurement_plan(
**{item.source_key: MeasurementInput(value_type="runtime_facts", value={}) for item in plan.inputs},
},
)
- report = MeasurementEngine([RuntimeMetricProducer()]).validate(plan, snapshot, require_primary=False)
+ report = MeasurementEngine([RuntimeMetricProducer(), NodeRuntimeMetricProducer()]).validate(
+ plan, snapshot, require_primary=False
+ )
producer_path = re.compile(r"^producers\[(\d+)\]")
input_path = re.compile(r"^inputs\[(\d+)\]")
@@ -417,7 +669,7 @@ def restore_full_plan_path(issue: ValidationIssue) -> ValidationIssue:
return replace(issue, path=input_path.sub(f"inputs[{full_index}]", issue.path, count=1))
return issue
- return ValidationReport(tuple(restore_full_plan_path(issue) for issue in report.issues))
+ return ValidationReport((*scope_issues, *(restore_full_plan_path(issue) for issue in report.issues)))
async def finalize_attempt_measurement(
@@ -560,9 +812,7 @@ async def finalize_attempt_measurement(
if "*" not in unavailable_outputs.get(binding.id, {})
and any(output_key not in unavailable_outputs.get(binding.id, {}) for output_key in binding.outputs)
)
- runtime_bindings = tuple(
- binding for binding in ready_bindings if binding.producer_id == RuntimeMetricProducer.producer_id
- )
+ runtime_bindings = tuple(binding for binding in ready_bindings if binding.producer_id in _RUNTIME_PRODUCER_IDS)
runtime_binding_ids = {binding.id for binding in runtime_bindings}
runtime_metric_ids = {metric_id for binding in runtime_bindings for metric_id in binding.outputs.values()}
runtime_plan = replace(
@@ -582,6 +832,7 @@ async def finalize_attempt_measurement(
"protocol_run_id": str(run.id),
"collection_error": f"{type(exc).__name__}: {exc}",
}
+ facts["prompt_sha256"] = authored_prompt_sha256(measurement_graph)
task_completed_at = (run.attempt_result or {}).get("task_completed_at")
if isinstance(task_completed_at, str):
# Runtime duration is task execution only. Evaluation has its own
@@ -602,7 +853,7 @@ async def finalize_attempt_measurement(
)
adapters: list[MeasurementProducerAdapter] = []
if has_runtime:
- adapters.append(RuntimeMetricProducer())
+ adapters.extend((RuntimeMetricProducer(), NodeRuntimeMetricProducer()))
settings = get_settings()
# Persist a claim before collecting runtime facts. A concurrent finalizer
# blocks on this row lock, then observes the durable claim and returns
@@ -688,7 +939,9 @@ async def finalize_attempt_measurement(
__all__ = [
+ "NodeRuntimeMetricProducer",
"RuntimeMetricProducer",
+ "authored_prompt_sha256",
"collect_runtime_facts",
"finalize_attempt_measurement",
"validate_runtime_measurement_plan",
diff --git a/src/asaree/services/tool_steps.py b/src/asaree/services/tool_steps.py
new file mode 100644
index 00000000..d56680cf
--- /dev/null
+++ b/src/asaree/services/tool_steps.py
@@ -0,0 +1,325 @@
+"""Deterministic Tool Step nodes: one MCP tool call in the main flow, no LLM.
+
+A Tool Step is the canvas form of a harness-owned call like the spinal
+notebook's ``score_payload``: nothing about the call is left to a model, so
+the same upstream payload always produces the same call. It is deliberately
+use-case agnostic -- it knows nothing about any tool's parameters. Each
+argument the node sends names where its value comes from:
+
+* ``{"source": "value", "value": ...}`` -- a fixed value.
+* ``{"source": "upstream_payload", "format": "json_string" | "object"}`` --
+ the typed payload the upstream node handed on, either as canonical JSON
+ text or as the object itself. ``null`` when upstream produced none; whether
+ that's acceptable is the tool's call, not the step's.
+* ``{"source": "script_code"}`` -- the wired Script node's source.
+* ``{"source": "workspace_id"}`` -- the experiment replicate's workspace.
+
+An argument that isn't mapped isn't sent. Anything a tool needs to do to its
+inputs (e.g. sanitizing a hyperparameter payload) is the tool's own job.
+
+``hash_checks`` (``{result_field: argument_name}``) makes the call checkable
+after the fact: the tool must report, in *result_field*, the SHA-256 of that
+argument exactly as sent -- e.g. that the script it executed is verbatim the
+wired one. A mismatch fails the node rather than record a result that can't be
+trusted.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import json
+import math
+import re
+import uuid
+from collections.abc import Awaitable, Callable, Mapping
+from typing import Any, TypeGuard
+
+from motoro.services import mcp_service
+
+TOOL_STEP_NODE_TYPES = frozenset({"tool_step"})
+_MCP_TOOL_NODE_TYPES = frozenset({"mcp_tool", "mcp_scikit_learn", "mcp_client_tool"})
+ARGUMENT_SOURCES = frozenset({"value", "upstream_payload", "script_code", "workspace_id"})
+PAYLOAD_FORMATS = frozenset({"json_string", "object"})
+
+
+def canonical_payload_json(payload: Any) -> str:
+ """Deterministic JSON serialization used for both the call and its hash check."""
+ return json.dumps(payload, sort_keys=True, separators=(",", ":"))
+
+
+def sha256_text(text: str) -> str:
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
+
+
+def _is_number(value: Any) -> TypeGuard[int | float]:
+ return isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(float(value))
+
+
+_FENCED_BLOCK = re.compile(r"```(?:json)?\s*(.*?)```", re.S | re.I)
+
+
+def parse_json_object(text: str | None) -> dict[str, Any] | None:
+ """Best-effort read of a JSON object out of agent prose.
+
+ Last fenced block first (an agent's final answer), then the whole text, then
+ the outermost ``{...}`` span. ``None`` when nothing parses to an object.
+ """
+ if not text:
+ return None
+ candidates = [block.strip() for block in reversed(_FENCED_BLOCK.findall(text))]
+ stripped = text.strip()
+ candidates.append(stripped)
+ start, end = stripped.find("{"), stripped.rfind("}")
+ if 0 <= start < end:
+ candidates.append(stripped[start : end + 1])
+ for candidate in candidates:
+ try:
+ parsed = json.loads(candidate)
+ except (ValueError, TypeError):
+ continue
+ if isinstance(parsed, dict):
+ return parsed
+ return None
+
+
+def resolve_upstream_payload(upstream_runs: list[Mapping[str, Any]]) -> dict[str, Any] | None:
+ """The typed payload the step consumes: the Output-Parser payload the
+ upstream node (or the gate forwarding it) handed on, else one scraped from
+ its prose -- the notebook's ``resolve_payload``."""
+ for run in upstream_runs:
+ payload = run.get("payload")
+ if isinstance(payload, dict) and payload:
+ return payload
+ for run in upstream_runs:
+ parsed = parse_json_object(run.get("output_text"))
+ if parsed:
+ return parsed
+ return None
+
+
+def _node_config(node: Mapping[str, Any] | None) -> Mapping[str, Any]:
+ data = node.get("data") if isinstance(node, Mapping) else None
+ config = data.get("config") if isinstance(data, Mapping) else None
+ return config if isinstance(config, Mapping) else {}
+
+
+def wired_tool_sources(
+ graph: Mapping[str, Any], node_id: str
+) -> tuple[list[Mapping[str, Any]], list[Mapping[str, Any]]]:
+ """``(mcp_nodes, script_nodes)`` wired into the step's Tool connector, in wiring order."""
+ nodes = {str(node.get("id")): node for node in graph.get("nodes") or () if isinstance(node, Mapping)}
+ mcp_nodes: list[Mapping[str, Any]] = []
+ script_nodes: list[Mapping[str, Any]] = []
+ for edge in graph.get("edges") or ():
+ if str(edge.get("target")) != node_id or edge.get("targetHandle") != "tool":
+ continue
+ source = nodes.get(str(edge.get("source")))
+ if source is None:
+ continue
+ if source.get("type") in _MCP_TOOL_NODE_TYPES:
+ mcp_nodes.append(source)
+ elif source.get("type") == "script":
+ script_nodes.append(source)
+ return mcp_nodes, script_nodes
+
+
+def validate_tool_step(graph: Mapping[str, Any], node: Mapping[str, Any]) -> str | None:
+ """Why this Tool Step can't run, or ``None``. Shared by graph validation and execution."""
+ node_id = str(node.get("id"))
+ config = _node_config(node)
+ mcp_nodes, script_nodes = wired_tool_sources(graph, node_id)
+ tool_edges = [
+ edge
+ for edge in graph.get("edges") or ()
+ if str(edge.get("target")) == node_id and edge.get("targetHandle") == "tool"
+ ]
+ if len(tool_edges) != len(mcp_nodes) + len(script_nodes):
+ return "can only take MCP Tool and Script nodes on its Tool connection"
+ if len(mcp_nodes) != 1:
+ return f"needs exactly one MCP Tool connection (found {len(mcp_nodes)})"
+ if len(script_nodes) > 1:
+ return f"can have at most one Script connection (found {len(script_nodes)})"
+ tool_name = config.get("tool_name")
+ if not isinstance(tool_name, str) or not tool_name:
+ return "has no tool selected"
+ mcp_config = _node_config(mcp_nodes[0])
+ if mcp_config.get("enabled") is False:
+ return "is wired to a disabled MCP Tool"
+ if tool_name not in (mcp_config.get("tool_names") or ()):
+ return f"calls {tool_name!r}, which its MCP Tool node does not enable"
+ arguments = config.get("arguments") or {}
+ if not isinstance(arguments, Mapping):
+ return "has arguments that are not an object"
+ if "sanitizer" in config or "verify_hashes" in config:
+ return (
+ "uses the old Tool Step format (sanitizer/verify_hashes); re-import the experiment definition "
+ "or re-map its arguments in the Tool Step inspector, then publish"
+ )
+ for name, spec in arguments.items():
+ source = spec.get("source") if isinstance(spec, Mapping) else None
+ if source not in ARGUMENT_SOURCES:
+ return f"argument {name!r} has no valid source"
+ if source == "upstream_payload" and spec.get("format", "json_string") not in PAYLOAD_FORMATS:
+ return f"argument {name!r} has an unknown payload format {spec.get('format')!r}"
+ if source == "script_code" and not script_nodes:
+ return f"argument {name!r} takes the Script's code, but no Script is wired"
+ hash_checks = config.get("hash_checks") or {}
+ if not isinstance(hash_checks, Mapping):
+ return "has hash checks that are not an object"
+ for field, argument in hash_checks.items():
+ if argument not in arguments:
+ return f"hash check {field!r} names {argument!r}, which is not a sent argument"
+ return None
+
+
+def _argument_text(value: Any) -> str:
+ """The exact string a hash check hashes: strings as sent, anything else as canonical JSON."""
+ return value if isinstance(value, str) else canonical_payload_json(value)
+
+
+async def execute_tool_step(
+ node: Mapping[str, Any],
+ *,
+ graph: Mapping[str, Any],
+ upstream_runs: list[Mapping[str, Any]],
+ workspace_id: str | None,
+ record_provenance: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
+) -> dict[str, Any]:
+ """Run one Tool Step and return its node-run record.
+
+ The record carries the parsed tool result as ``payload`` (so downstream
+ nodes and the ``asaree.tool_step`` metric producer read it like any typed
+ handoff) and the step's own provenance under ``tool_step``.
+ *record_provenance* is awaited with that provenance BEFORE the call, so a
+ crash or timeout mid-call still leaves what was sent on record -- the
+ notebook's pre-scoring upsert.
+ """
+ node_id = str(node.get("id"))
+ config = _node_config(node)
+ step: dict[str, Any] = {"tool_name": config.get("tool_name")}
+
+ def failed(message: str) -> dict[str, Any]:
+ return {"status": "failed", "output_text": None, "error": message, "run_id": None, "tool_step": step}
+
+ if problem := validate_tool_step(graph, node):
+ return failed(f"Tool Step {problem}.")
+ mcp_nodes, script_nodes = wired_tool_sources(graph, node_id)
+ mcp_config = _node_config(mcp_nodes[0])
+ step["server_id"] = mcp_config.get("server_id")
+ step["server_name"] = mcp_config.get("server_name")
+
+ arguments: dict[str, Any] = {}
+ # Large or sensitive inputs (script source, upstream payload) are recorded
+ # once by value/hash below rather than again inside `arguments`.
+ recorded_arguments: dict[str, Any] = {}
+ argument_sha256: dict[str, str] = {}
+ for name, spec in (config.get("arguments") or {}).items():
+ source = spec["source"]
+ if source == "value":
+ arguments[name] = spec.get("value")
+ recorded_arguments[name] = arguments[name]
+ elif source == "workspace_id":
+ if not workspace_id:
+ return failed(f"Tool Step argument {name!r} needs a workspace, and this run has none.")
+ arguments[name] = workspace_id
+ recorded_arguments[name] = workspace_id
+ elif source == "script_code":
+ script_config = _node_config(script_nodes[0])
+ if script_config.get("enabled") is False:
+ return failed("Tool Step's Script is disabled.")
+ code = str(script_config.get("code") or "").strip()
+ if not code:
+ return failed("Tool Step's Script has no code.")
+ step["script_node_id"] = str(script_nodes[0].get("id"))
+ arguments[name] = code
+ else:
+ payload = resolve_upstream_payload(upstream_runs)
+ step["payload"] = payload
+ arguments[name] = (
+ payload if spec.get("format", "json_string") == "object" else canonical_payload_json(payload)
+ )
+ if source in {"script_code", "upstream_payload"}:
+ argument_sha256[name] = sha256_text(_argument_text(arguments[name]))
+ step["arguments"] = recorded_arguments
+ step["argument_sha256"] = argument_sha256
+ if record_provenance is not None:
+ await record_provenance(dict(step))
+
+ try:
+ server_id = uuid.UUID(str(step["server_id"]))
+ except (TypeError, ValueError):
+ return failed("Tool Step's MCP Tool has no registered server.")
+ timeout = config.get("timeout_seconds")
+ try:
+ call = mcp_service.call_server_tool(server_id, str(config["tool_name"]), arguments)
+ outcome = await (asyncio.wait_for(call, float(timeout)) if _is_number(timeout) else call)
+ except TimeoutError:
+ return failed(f"Tool Step's call to {config['tool_name']!r} timed out after {timeout}s.")
+ except RuntimeError as exc:
+ return failed(f"Tool Step could not call {config['tool_name']!r}: {exc}")
+ if outcome is None:
+ return failed("Tool Step's MCP server is not registered.")
+ is_error, content = outcome
+ record: dict[str, Any] = {
+ "status": "completed",
+ "output_text": content,
+ "error": None,
+ "run_id": None,
+ "tool_step": step,
+ }
+ if is_error:
+ return {**record, "status": "failed", "error": f"{config['tool_name']} returned an error: {content[:2000]}"}
+ try:
+ result = json.loads(content)
+ except (ValueError, TypeError):
+ result = None
+ if isinstance(result, dict):
+ record["payload"] = result
+ if result.get("error"):
+ return {**record, "status": "failed", "error": f"{config['tool_name']} error: {result['error']}"}
+ reported = result if isinstance(result, dict) else {}
+ for field, argument in (config.get("hash_checks") or {}).items():
+ expected = argument_sha256.get(argument) or sha256_text(_argument_text(arguments.get(argument)))
+ if reported.get(field) != expected:
+ return {
+ **record,
+ "status": "failed",
+ "error": f"Hash check failed: the tool's {field!r} does not match the {argument!r} this step sent.",
+ }
+ return record
+
+
+def _flatten(value: Any, prefix: str, into: dict[str, Any]) -> None:
+ if prefix:
+ into[prefix] = value
+ if isinstance(value, Mapping):
+ for key, child in value.items():
+ _flatten(child, f"{prefix}.{key}" if prefix else str(key), into)
+
+
+def flatten_paths(document: Any) -> dict[str, Any]:
+ """Every node of *document* keyed by its dotted path.
+
+ Exact-key lookup rather than a split on ``.``: result keys like
+ ``metrics_at_0.5`` contain dots themselves, so ``test_metrics.metrics_at_0.5.f1``
+ is only unambiguous as a whole string.
+ """
+ paths: dict[str, Any] = {}
+ _flatten(document, "", paths)
+ return paths
+
+
+__all__ = [
+ "ARGUMENT_SOURCES",
+ "PAYLOAD_FORMATS",
+ "TOOL_STEP_NODE_TYPES",
+ "canonical_payload_json",
+ "execute_tool_step",
+ "flatten_paths",
+ "parse_json_object",
+ "resolve_upstream_payload",
+ "sha256_text",
+ "validate_tool_step",
+ "wired_tool_sources",
+]
diff --git a/tests/test_bdm_use_cases.py b/tests/test_bdm_use_cases.py
index b93e8b10..a2b32540 100644
--- a/tests/test_bdm_use_cases.py
+++ b/tests/test_bdm_use_cases.py
@@ -4,17 +4,20 @@
from pathlib import Path
from asaree.services.csv_export import result_rows_to_csv
+from asaree.services.derived_metrics import FEATURE_PIPELINE_OUTPUTS
from asaree.services.measurement_migration import normalize_experiment_measurement_plan
from asaree.services.metrics import (
design_metrics_from_measurement_plan,
normalize_metrics,
)
+from asaree.services.protocol_execution import topological_order, validate_prompt_references
+from asaree.services.runtime_metrics import validate_runtime_measurement_plan
BDM = Path(__file__).parents[1] / "publications" / "BDM"
-USE_CASES = sorted(BDM.glob("myocardial-*-v0.8.0.json"))
+USE_CASES = sorted(BDM.glob("myocardial-*.json"))
-def test_myocardial_use_cases_declare_the_same_analysis_ready_measurements() -> None:
+def test_myocardial_use_cases_normalize_reported_metrics_as_named_observations() -> None:
documents = [json.loads(path.read_text()) for path in USE_CASES]
expected_metrics = documents[0]["design_spec"]["metrics"]
expected_plan_metrics = documents[0]["measurement_plan"]["metrics"]
@@ -26,10 +29,19 @@ def test_myocardial_use_cases_declare_the_same_analysis_ready_measurements() ->
assert metrics == expected_metrics
assert plan["metrics"] == expected_plan_metrics
assert normalize_metrics(metrics) == design_metrics_from_measurement_plan(plan)
- assert normalize_experiment_measurement_plan(plan, metrics) == plan
+ normalized_plan = normalize_experiment_measurement_plan(plan, metrics)
+ reported = next(
+ producer for producer in normalized_plan["producers"] if producer["id"] == "model-evaluation-source"
+ )
+ reported_metric_ids = set(reported["outputs"].values())
+ assert reported["config"]["projections"]["pr_auc"] == {"path": "test_metrics.average_precision"}
+ assert all(
+ set(metric) <= {"id", "name"}
+ for metric in normalized_plan["metrics"]
+ if metric["id"] in reported_metric_ids
+ )
+ assert not any(metric.get("primary") for metric in normalize_metrics(metrics) if metric["kind"] == "custom")
- primary = [metric for metric in metrics if metric["primary"]]
- assert [(metric["name"], metric["direction"]) for metric in primary] == [("pr_auc", "maximize")]
runtime = next(producer for producer in plan["producers"] if producer["producer_id"] == "asaree.runtime")
assert set(runtime["outputs"]) == {
"cost_usd",
@@ -42,12 +54,32 @@ def test_myocardial_use_cases_declare_the_same_analysis_ready_measurements() ->
"agent_loop_iterations",
"critic_rejections",
"critic_approvals",
+ "critic_invocations",
+ "critic_rejections_partial",
+ "critic_rejections_full",
+ "revision_rounds",
+ "capped_agent_runs",
+ "agent_runs",
+ "react_runs",
+ "react_turns",
+ "react_tool_calls",
+ "prompt_sha256",
}
- score = next(producer for producer in plan["producers"] if producer["id"] == "model-evaluation-source")
- assert score["config"]["projections"]["pr_auc"] == {"path": "test_metrics.average_precision"}
- assert score["config"]["projections"]["accuracy_at_0_5"] == {
- "path": "/test_metrics/metrics_at_0.5/accuracy"
+ graph_ids = {node["id"] for node in document["graph"]["nodes"]}
+ stages = {
+ producer["id"]: producer
+ for producer in plan["producers"]
+ if producer["producer_id"] == "asaree.node_runtime"
}
+ assert set(stages) == {f"runtime-{stage}" for stage in ("dc", "fte", "fs", "mlm", "critic")}
+ assert stages["runtime-critic"]["config"]["node_ids"] == ["gate-dc", "gate-fte", "gate-fs", "gate-mlm"]
+ for stage, producer in stages.items():
+ assert set(producer["config"]["node_ids"]) <= graph_ids
+ assert set(producer["outputs"]) == {"total_tokens", "agent_loop_iterations"}
+ assert {"producer_binding_id": stage, "input_key": "facts", "source_key": "attempt.runtime"} in plan[
+ "inputs"
+ ]
+ assert not validate_runtime_measurement_plan(plan, graph=document["graph"]).issues
def test_myocardial_output_parser_types_match_the_prompted_payloads() -> None:
@@ -62,10 +94,86 @@ def test_myocardial_output_parser_types_match_the_prompted_payloads() -> None:
assert parsers["output-parser-dc"]["imputation"] == "array"
assert parsers["output-parser-fte"]["engineering_recipe"] == "array"
assert parsers["output-parser-fs"]["class_balance_check"] == "string"
- assert parsers["output-parser-fs"]["n_engineered_features_selected"] == "integer"
+ assert "n_engineered_features_selected" not in parsers["output-parser-fs"]
+ assert parsers["output-parser-fte"]["encoding_map"] == "array"
assert parsers["output-parser-mlm"]["search_space"] == "array"
- assert "model_decisions" in parsers["output-parser-score"]
- assert "result" not in parsers["output-parser-score"]
+ assert "output-parser-score" not in parsers
+
+
+def test_myocardial_mlm_receives_only_the_notebooks_fs_brief_fields() -> None:
+ for path in USE_CASES:
+ graph = json.loads(path.read_text())["graph"]
+ validate_prompt_references(graph=graph)
+ edge = next(e for e in graph["edges"] if e["source"] == "gate-fs" and e["target"] == "agent-mlm")
+ assert edge["data"]["handoff"] == {
+ "mode": "selected",
+ "fields": [
+ {"name": name}
+ for name in (
+ "selected_features",
+ "n_features_out",
+ "observed_class_distribution",
+ "class_balance_check",
+ "notes_for_mlm",
+ )
+ ],
+ }
+ goal = next(n for n in graph["nodes"] if n["id"] == "agent-mlm")["data"]["config"]["goal"]
+ # The edge carries FS's fields; the brief only reaches further back.
+ assert "{{node:agent-fs" not in goal
+ assert "{{node:agent-fte.engineering_recipe[name]}}" in goal
+ assert "{{node:agent-fte.encoding_map[feature, encoding]}}" in goal
+
+
+def test_myocardial_scoring_is_a_deterministic_tool_step() -> None:
+ for path in USE_CASES:
+ document = json.loads(path.read_text())
+ nodes = {node["id"]: node for node in document["graph"]["nodes"]}
+ assert "agent-score" not in nodes
+ step = nodes["tool-step-score"]
+ assert step["type"] == "tool_step"
+ config = step["data"]["config"]
+ script = nodes["script-model"]["data"]["config"]["code"]
+ compile(script, path.name, "exec")
+ assert '"reg_alpha": 0.0' in script
+ assert '_fixed.update(hp["fixed_params"])' in script
+ assert "sanitizer" not in config
+ assert config["arguments"]["selection_metric"] == {"source": "value", "value": "average_precision"}
+ assert config["arguments"]["payload_json"] == {"source": "upstream_payload", "format": "json_string"}
+ assert config["arguments"]["code"] == {"source": "script_code"}
+ assert set(json.loads(config["arguments"]["param_spec_json"]["value"])) == {
+ "n_estimators",
+ "max_depth",
+ "learning_rate",
+ "min_child_weight",
+ "gamma",
+ "subsample",
+ "colsample_bytree",
+ "reg_lambda",
+ "reg_alpha",
+ }
+ assert config["hash_checks"] == {"code_sha256": "code", "payload_sha256": "payload_json"}
+ assert [node["id"] for node in topological_order(document["graph"])][-1] == "tool-step-score"
+ assert document["design_spec"]["replicates"] == 10
+ assert {
+ node["data"]["config"]["max_iterations"] for node in nodes.values() if node["type"] == "pattern_reason_act"
+ } == {12}
+
+ producers = {producer["id"]: producer for producer in document["measurement_plan"]["producers"]}
+ scoring = producers["model-evaluation-source"]
+ assert scoring["producer_id"] == "asaree.tool_step"
+ assert scoring["config"]["node_id"] == "tool-step-score"
+ projections = scoring["config"]["projections"]
+ assert set(projections) == set(scoring["outputs"])
+ assert scoring["outputs"]["pr_auc"] == "pr-auc"
+ assert projections["pr_auc"] == {"path": "test_metrics.average_precision"}
+ assert projections["f1_at_0_5"] == {"path": "test_metrics.metrics_at_0.5.f1"}
+ assert projections["n_schema_violations"] == {"path": "n_sanitize_notes"}
+ pipeline = producers["feature-pipeline-source"]
+ assert pipeline["producer_id"] == "asaree.feature_pipeline"
+ assert set(pipeline["outputs"]) == set(FEATURE_PIPELINE_OUTPUTS)
+ bound = [metric_id for producer in producers.values() for metric_id in producer["outputs"].values()]
+ assert len(bound) == len(set(bound)), "each metric has exactly one producer"
def test_myocardial_results_export_has_analysis_ready_custom_columns() -> None:
@@ -92,7 +200,9 @@ def test_myocardial_results_export_has_analysis_ready_custom_columns() -> None:
"n_engineered_features",
"n_features_after_fs",
"n_engineered_features_selected",
- "pct_engineered_features_selected",
+ "n_features_created",
+ "frac_created_selected",
+ "n_schema_violations",
"optuna_best_inner_cv_score",
"hp_n_estimators",
"code_sha256",
diff --git a/tests/test_experiment_run_results.py b/tests/test_experiment_run_results.py
index 2010d73b..d6fd6f8e 100644
--- a/tests/test_experiment_run_results.py
+++ b/tests/test_experiment_run_results.py
@@ -9,6 +9,7 @@
from asaree.services.csv_export import result_rows_schema, result_rows_to_csv
from asaree.services.experiment_run_results import (
_aggregate_metric_values,
+ _attempt_metric_values,
_declared_metric_aggregations,
_declared_metric_directions,
_declared_runtime_metrics,
@@ -22,6 +23,36 @@
)
+def test_attempt_metric_values_include_measured_node_runtime_observations() -> None:
+ stored = {
+ "metric_values": {"pr_auc": 0.72},
+ "measurement": {
+ "observations": [
+ {
+ "metric_name": "tokens_dc",
+ "status": "measured",
+ "value": 1200,
+ "producer": {"producer_id": "asaree.node_runtime", "kind": "runtime"},
+ },
+ {
+ "metric_name": "n_turns_critic",
+ "status": "measured",
+ "value": 0,
+ "producer": {"producer_id": "asaree.node_runtime", "kind": "runtime"},
+ },
+ {
+ "metric_name": "tokens_critic",
+ "status": "unavailable",
+ "value": None,
+ "producer": {"producer_id": "asaree.node_runtime", "kind": "runtime"},
+ },
+ ]
+ },
+ }
+
+ assert _attempt_metric_values(stored) == {"pr_auc": 0.72, "tokens_dc": 1200, "n_turns_critic": 0}
+
+
def test_usage_normalizes_provider_token_names_and_derives_total() -> None:
agent_run = SimpleNamespace(
token_usage={"prompt_tokens": 120, "completion_tokens": 30},
@@ -78,7 +109,7 @@ def test_declared_runtime_metrics_are_projected_from_execution_telemetry() -> No
assert _primary_metric(spec) == ("cost_usd", "maximize")
-def test_builtin_and_reported_scalar_metrics_declare_aggregations() -> None:
+def test_only_builtin_metrics_declare_aggregations() -> None:
assert _declared_metric_aggregations(
{
"metrics": [
@@ -92,10 +123,10 @@ def test_builtin_and_reported_scalar_metrics_declare_aggregations() -> None:
},
]
}
- ) == {"Quality": "mean", "cost_usd": "sum"}
+ ) == {"cost_usd": "sum"}
-def test_builtin_and_reported_scalar_metrics_declare_ranking_directions() -> None:
+def test_only_builtin_metrics_declare_ranking_directions() -> None:
assert _declared_metric_directions(
{
"metrics": [
@@ -109,7 +140,7 @@ def test_builtin_and_reported_scalar_metrics_declare_ranking_directions() -> Non
},
]
}
- ) == {"Quality": "maximize", "cost_usd": "minimize"}
+ ) == {"cost_usd": "minimize"}
def test_cell_metric_aggregations_apply_the_declared_operation() -> None:
@@ -166,10 +197,10 @@ def test_results_csv_keeps_an_empty_column_for_an_unreported_custom_metric() ->
design_spec=design_spec,
)
reported = next(column for column in schema["columns"] if column["name"] == "Reviewer report")
- assert reported == {"name": "Reviewer report", "role": "reported", "value_type": "opaque"}
+ assert reported == {"name": "Reviewer report", "role": "reported"}
-def test_results_csv_describes_a_reported_scalar_as_numeric() -> None:
+def test_results_csv_ignores_legacy_custom_metric_semantics() -> None:
design_spec = {
"metrics": [
{
@@ -195,12 +226,8 @@ def test_results_csv_describes_a_reported_scalar_as_numeric() -> None:
schema = result_rows_schema(rows, metric_types={"pr_auc": "number"}, design_spec=design_spec)
reported = next(column for column in schema["columns"] if column["name"] == "pr_auc")
- assert reported == {
- "name": "pr_auc",
- "role": "reported",
- "value_type": "number",
- }
- assert _primary_metric(design_spec) == ("pr_auc", "maximize")
+ assert reported == {"name": "pr_auc", "role": "reported"}
+ assert _primary_metric(design_spec) == (None, None)
def test_results_csv_projects_script_stdout_and_execution_metadata() -> None:
@@ -255,7 +282,6 @@ def test_results_csv_projects_script_stdout_and_execution_metadata() -> None:
assert columns["Script custom metric"] == {
"name": "Script custom metric",
"role": "reported",
- "value_type": "string",
}
assert columns["Script custom metric__raw_result"]["value_type"] == "json"
assert columns["Script custom metric__exit_code"]["value_type"] == "integer"
diff --git a/tests/test_measurement_engine.py b/tests/test_measurement_engine.py
index 0abb89fd..f8a6b140 100644
--- a/tests/test_measurement_engine.py
+++ b/tests/test_measurement_engine.py
@@ -108,7 +108,7 @@ def test_measurement_plan_document_round_trips_in_normalized_collections() -> No
**document,
"metrics": [{key: value for key, value in document["metrics"][0].items() if key != "primary"}],
}
- assert normalize_measurement_plan(without_primary)["metrics"][0]["primary"] is False
+ assert "primary" not in normalize_measurement_plan(without_primary)["metrics"][0]
multiple_primary = {
**document,
diff --git a/tests/test_measurement_migration.py b/tests/test_measurement_migration.py
index 3bc6f7e0..de34669a 100644
--- a/tests/test_measurement_migration.py
+++ b/tests/test_measurement_migration.py
@@ -16,7 +16,7 @@ def test_legacy_declarations_are_not_migrated_into_measurement_plans() -> None:
assert normalize_experiment_measurement_plan(None, metrics) == {"metrics": [], "producers": [], "inputs": []}
-def test_projected_reported_metrics_keep_their_scalar_contract() -> None:
+def test_reported_metrics_drop_scalar_semantics_but_keep_projections() -> None:
plan = normalize_experiment_measurement_plan(
{
"metrics": [
@@ -46,13 +46,10 @@ def test_projected_reported_metrics_keep_their_scalar_contract() -> None:
(),
)
- assert plan["metrics"][0] == {
- "id": "pr-auc",
- "name": "pr_auc",
- "value_type": "number",
- "direction": "maximize",
- "aggregation": "mean",
- "primary": True,
+ assert plan["metrics"][0] == {"id": "pr-auc", "name": "pr_auc"}
+ assert plan["producers"][0]["config"] == {
+ "agent_node_id": "agent",
+ "projections": {"pr_auc": {"path": "test_metrics.average_precision"}},
}
diff --git a/tests/test_metrics.py b/tests/test_metrics.py
index befbefb1..40e4066e 100644
--- a/tests/test_metrics.py
+++ b/tests/test_metrics.py
@@ -38,23 +38,22 @@ def test_frontend_metric_catalog_matches_the_backend_contract() -> None:
("aggregation", "aggregation"),
("unit", "unit"),
):
+ if backend_field not in entry:
+ # Unitless (an opaque hash); the frontend must not invent one.
+ assert f"{frontend_field}:" not in line
+ continue
assert f"{frontend_field}: '{entry[backend_field]}'" in line
assert {key for key, line in frontend_lines.items() if "recommended: true" in line} == set(
RECOMMENDED_RUNTIME_METRIC_KEYS
)
-def test_legacy_metric_is_normalized_as_an_opaque_display_only_custom_metric() -> None:
+def test_legacy_metric_is_normalized_as_a_named_custom_observation() -> None:
legacy = [{"name": "Accuracy", "primary": True, "direction": "maximize"}]
first = normalize_metrics(legacy)
second = normalize_metrics(legacy)
assert first[0]["id"] == second[0]["id"]
- assert first[0]["kind"] == "custom"
- assert first[0]["description"] == "Legacy metric declaration for Accuracy."
- assert first[0]["valueType"] == "opaque"
- assert first[0]["direction"] == "neutral"
- assert first[0]["aggregation"] == "none"
- assert first[0]["primary"] is False
+ assert first[0] == {"id": first[0]["id"], "name": "Accuracy", "kind": "custom"}
def test_legacy_metric_id_is_stable_when_an_unrelated_declaration_is_inserted() -> None:
@@ -73,7 +72,7 @@ def test_normalization_preserves_an_explicitly_primary_less_metric_set() -> None
]
)
- assert [metric["primary"] for metric in normalized] == [False, False]
+ assert all("primary" not in metric for metric in normalized)
def test_normalization_rejects_multiple_primary_metrics() -> None:
@@ -169,7 +168,7 @@ def test_design_metrics_are_derived_from_a_caller_supplied_plan() -> None:
]
-def test_explicit_scalar_custom_metric_values_are_type_checked() -> None:
+def test_custom_metric_values_are_preserved_without_type_checks() -> None:
metrics = [
{"name": "Passed", "kind": "custom", "valueType": "boolean", "primary": True},
{"name": "Score", "kind": "custom", "valueType": "number", "primary": False},
@@ -178,26 +177,22 @@ def test_explicit_scalar_custom_metric_values_are_type_checked() -> None:
"Passed": True,
"Score": 0.8,
}
- with pytest.raises(ValueError, match="must be Boolean"):
- validate_metric_values(metrics, {"Passed": 1})
- with pytest.raises(ValueError, match="finite number"):
- validate_metric_values(metrics, {"Score": {"grade": "A"}})
+ assert validate_metric_values(metrics, {"Passed": 1, "Score": {"grade": "A"}}) == {
+ "Passed": 1,
+ "Score": {"grade": "A"},
+ }
-def test_explicit_custom_boolean_declarations_remain_scalar() -> None:
+def test_explicit_custom_semantics_are_removed() -> None:
metrics = normalize_metrics(
[{"name": "Passed", "kind": "custom", "valueType": "boolean", "aggregation": "sum", "primary": True}]
)
- assert metrics[0]["valueType"] == "boolean"
- assert metrics[0]["aggregation"] == "mean"
- assert metrics[0]["primary"] is True
+ assert metrics[0] == {"id": metrics[0]["id"], "name": "Passed", "kind": "custom"}
-def test_custom_draft_without_an_explicit_scalar_type_remains_opaque() -> None:
+def test_custom_draft_has_no_value_semantics() -> None:
metrics = normalize_metrics([{"name": "Reviewer report", "kind": "custom", "primary": True}])
- assert metrics[0]["valueType"] == "opaque"
- assert metrics[0]["aggregation"] == "none"
- assert metrics[0]["primary"] is False
+ assert metrics[0] == {"id": metrics[0]["id"], "name": "Reviewer report", "kind": "custom"}
def test_duplicate_custom_labels_remain_persistable_drafts() -> None:
diff --git a/tests/test_prompt_references.py b/tests/test_prompt_references.py
index 3f386915..290c06c6 100644
--- a/tests/test_prompt_references.py
+++ b/tests/test_prompt_references.py
@@ -22,6 +22,8 @@
from asaree.services.protocol_execution import (
ProtocolValidationError,
_build_system_prompt,
+ _resolve_prompt_references,
+ _upstream_context,
referenceable_node_ids,
validate_prompt_references,
)
@@ -366,8 +368,6 @@ def test_an_out_of_scope_field_reference_reports_the_scope_problem_first() -> No
_validate("{{node:side.n_rows}}")
-
-
# ----------------------------------------------------------------------
# The System prompt field
# ----------------------------------------------------------------------
@@ -414,8 +414,6 @@ def test_a_system_prompt_resolves_its_references() -> None:
assert "{{node:a}}" not in rendered
-
-
def test_no_system_prompt_returns_none_so_the_caller_keeps_its_own_default() -> None:
"""Not the empty string: what an unset System prompt becomes is
``_run_agent_node``'s decision, and answering it here too would give two
@@ -438,3 +436,128 @@ def test_an_empty_system_prompt_reference_is_reported_not_raised() -> None:
unresolved_out=unresolved,
)
assert unresolved == ["a"]
+
+
+# ----------------------------------------------------------------------
+# Edge handoff: what a main edge passes
+# ----------------------------------------------------------------------
+
+
+def _handoff_graph(handoff: dict[str, Any] | None, *, gate: bool = False) -> dict[str, Any]:
+ """Analyst (parser: notes, recipe[array]) -> [Gate ->] Builder."""
+ parser = _parser("p", "a", "notes")
+ parser["node"]["data"]["config"]["output_contract"]["fields"].append({"name": "recipe", "type": "array"})
+ nodes = [_agent("a", "Analyst"), parser["node"], _agent("b", "Builder")]
+ edges = [parser["edge"]]
+ if gate:
+ nodes.append({"id": "g", "type": "critic_gate", "data": {"label": "Gate", "config": {}}})
+ edges += [_edge("a", "g"), _edge("g", "b")]
+ else:
+ edges.append(_edge("a", "b"))
+ if handoff is not None:
+ edges[-1]["data"] = {"handoff": handoff}
+ return {"nodes": nodes, "edges": edges}
+
+
+_RUNS = {
+ "a": {
+ "output_text": "Long prose report.",
+ "payload": {"notes": "keep age", "recipe": [{"name": "bmi", "op": "ratio"}, {"name": "pp", "op": "diff"}]},
+ }
+}
+
+
+def test_an_edge_with_no_handoff_passes_the_full_output() -> None:
+ context = _upstream_context(_handoff_graph(None), "b", _RUNS)
+ assert "Long prose report." in context
+ assert 'notes="keep age"' in context
+
+
+def test_fields_only_drops_the_prose() -> None:
+ context = _upstream_context(_handoff_graph({"mode": "fields"}), "b", _RUNS)
+ assert "Long prose report." not in context
+ assert context.startswith("[Analyst]\nStructured fields: notes=")
+ assert '"op": "ratio"' in context
+
+
+def test_selected_fields_pass_only_those_and_item_keys_narrow_a_list() -> None:
+ handoff = {"mode": "selected", "fields": [{"name": "recipe", "item_keys": ["name"]}]}
+ context = _upstream_context(_handoff_graph(handoff), "b", _RUNS)
+ assert context == '[Analyst]\nStructured fields: recipe=["bmi", "pp"]'
+
+
+def test_several_item_keys_keep_smaller_objects() -> None:
+ handoff = {"mode": "selected", "fields": [{"name": "recipe", "item_keys": ["name", "op"]}]}
+ context = _upstream_context(_handoff_graph(handoff), "b", _RUNS)
+ assert '{"name": "bmi", "op": "ratio"}' in context
+
+
+def test_a_narrowed_edge_with_nothing_extracted_falls_back_to_the_full_output() -> None:
+ runs = {"a": {"output_text": "Long prose report.", "payload": {}}}
+ context = _upstream_context(_handoff_graph({"mode": "selected", "fields": [{"name": "notes"}]}), "b", runs)
+ assert "Long prose report." in context
+
+
+def test_previous_passes_what_the_edge_passes_but_a_node_reference_stays_whole() -> None:
+ graph = _handoff_graph({"mode": "selected", "fields": [{"name": "notes"}]})
+ previous, _ = _resolve_prompt_references("{{previous}}", graph, "b", _RUNS)
+ whole, _ = _resolve_prompt_references("{{node:a}}", graph, "b", _RUNS)
+ assert previous == 'Structured fields: notes="keep age"'
+ assert "Long prose report." in whole
+
+
+def test_a_gates_outgoing_edge_offers_its_workers_fields() -> None:
+ validate_prompt_references(graph=_handoff_graph({"mode": "selected", "fields": [{"name": "notes"}]}, gate=True))
+
+
+def test_an_edge_into_a_critic_gate_must_pass_the_full_output() -> None:
+ graph = _handoff_graph(None, gate=True)
+ graph["edges"][1]["data"] = {"handoff": {"mode": "fields"}}
+ with pytest.raises(ProtocolValidationError, match="only pass the full output"):
+ validate_prompt_references(graph=graph)
+
+
+@pytest.mark.parametrize(
+ ("handoff", "message"),
+ [
+ ({"mode": "nothing"}, "unknown handoff"),
+ ({"mode": "selected", "fields": []}, "none are selected"),
+ ({"mode": "selected", "fields": [{"name": "n_rows"}]}, "does not declare"),
+ ({"mode": "selected", "fields": [{"name": "notes", "item_keys": ["x"]}]}, "not a list field"),
+ ],
+)
+def test_an_impossible_handoff_is_refused_at_publish(handoff: dict[str, Any], message: str) -> None:
+ with pytest.raises(ProtocolValidationError, match=message):
+ validate_prompt_references(graph=_handoff_graph(handoff))
+
+
+def test_a_narrowed_handoff_from_a_sender_without_a_parser_is_refused() -> None:
+ graph = {
+ "nodes": [_agent("a"), _agent("b")],
+ "edges": [{**_edge("a", "b"), "data": {"handoff": {"mode": "fields"}}}],
+ }
+ with pytest.raises(ProtocolValidationError, match="no Output Parser"):
+ validate_prompt_references(graph=graph)
+
+
+def test_a_field_reference_can_narrow_a_list_to_item_keys() -> None:
+ graph = _handoff_graph(None)
+ one, _ = _resolve_prompt_references("Steps: {{node:a.recipe[name]}}", graph, "b", _RUNS)
+ two, _ = _resolve_prompt_references("{{ node:a.recipe[ name , op ] }}", graph, "b", _RUNS)
+ assert one == 'Steps: ["bmi", "pp"]'
+ assert two == '[{"name": "bmi", "op": "ratio"}, {"name": "pp", "op": "diff"}]'
+
+
+def test_item_keys_parse_and_serialize() -> None:
+ (ref,) = pr.iter_references("{{node:a.recipe[name, op]|raw}}")
+ assert (ref.node_id, ref.field, ref.item_keys, ref.raw) == ("a", "recipe", ("name", "op"), True)
+ assert pr.serialize_node_reference("a", field="recipe", item_keys=("name",)) == "{{node:a.recipe[name]}}"
+
+
+def test_item_keys_on_a_field_that_is_not_a_list_are_refused() -> None:
+ graph = _handoff_graph(None)
+ graph["nodes"][2]["data"]["config"]["prompt"] = "{{node:a.notes[x]}}"
+ with pytest.raises(ProtocolValidationError, match="not a list field"):
+ validate_prompt_references(graph=graph)
+ graph["nodes"][2]["data"]["config"]["prompt"] = "{{node:a.recipe[name]}}"
+ validate_prompt_references(graph=graph)
diff --git a/tests/test_reported_metrics.py b/tests/test_reported_metrics.py
index d8958dac..8c95ca56 100644
--- a/tests/test_reported_metrics.py
+++ b/tests/test_reported_metrics.py
@@ -14,10 +14,6 @@ def _plan(producer_id: str, config: dict) -> object:
{
"id": "quality",
"name": "Quality",
- "value_type": "opaque",
- "direction": "neutral",
- "aggregation": "none",
- "primary": False,
}
],
"producers": [
@@ -206,21 +202,21 @@ async def test_agent_output_projection_reads_the_structured_parser_payload() ->
{
"id": "feature-count",
"name": "n_engineered_features",
- "value_type": "number",
- "direction": "neutral",
- "aggregation": "mean",
- }
+ },
+ {"id": "summary", "name": "summary"},
+ {"id": "missing", "name": "missing"},
],
"producers": [
{
"id": "fte-output",
"producer_id": "asaree.agent_output",
"kind": "reported",
- "outputs": {"feature_count": "feature-count"},
+ "outputs": {"feature_count": "feature-count", "summary": "summary", "missing": "missing"},
"config": {
"agent_node_id": "agent",
"projections": {
- "feature_count": {"path": "engineering_recipe", "transform": "length"}
+ "feature_count": {"path": "engineering_recipe", "transform": "length"},
+ "missing": {"path": "absent"},
},
},
}
@@ -242,12 +238,42 @@ async def test_agent_output_projection_reads_the_structured_parser_payload() ->
result = await collect_reported_metrics(run, plan, {"nodes": [], "edges": []})
- assert result.observations[0].status == "measured"
- assert result.observations[0].value == 2
+ by_id = {observation.metric_id: observation for observation in result.observations}
+ assert by_id["feature-count"].status == "measured" and by_id["feature-count"].value == 2
+ assert by_id["summary"].value == "summary", "no projection records the whole final output"
+ assert by_id["missing"].status == "unavailable"
+
+
+@pytest.mark.asyncio
+async def test_agent_output_projection_falls_back_to_json_in_the_final_answer() -> None:
+ plan = parse_measurement_plan(
+ {
+ "metrics": [{"id": "threshold", "name": "threshold"}],
+ "producers": [
+ {
+ "id": "dc-output",
+ "producer_id": "asaree.agent_output",
+ "kind": "reported",
+ "outputs": {"threshold": "threshold"},
+ "config": {"agent_node_id": "agent", "projections": {"threshold": {"path": "a.b"}}},
+ }
+ ],
+ "inputs": [],
+ }
+ )
+ run = SimpleNamespace(
+ id=uuid4(),
+ replicate_result_id=None,
+ node_runs={"agent": {"status": "completed", "output_text": 'Done.\n```json\n{"a": {"b": 0.3}}\n```'}},
+ )
+
+ result = await collect_reported_metrics(run, plan, {"nodes": [], "edges": []})
+
+ assert result.observations[0].value == 0.3
@pytest.mark.asyncio
-async def test_mcp_projection_supports_json_pointer_keys_containing_dots(
+async def test_mcp_metric_captures_the_complete_tool_result_without_projection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
plan = parse_measurement_plan(
@@ -272,9 +298,7 @@ async def test_mcp_projection_supports_json_pointer_keys_containing_dots(
"mcp_node_id": "mcp",
"server_id": str(uuid4()),
"tool_name": "score",
- "projections": {
- "accuracy": {"path": "/test_metrics/metrics_at_0.5/accuracy"}
- },
+ "projections": {"accuracy": {"path": "/test_metrics/metrics_at_0.5/accuracy"}},
},
}
],
@@ -304,7 +328,8 @@ async def test_mcp_projection_supports_json_pointer_keys_containing_dots(
result = await collect_reported_metrics(run, plan, {"nodes": [], "edges": []})
assert result.observations[0].status == "measured"
- assert result.observations[0].value == 0.81
+ assert result.observations[0].value == '{"test_metrics":{"metrics_at_0.5":{"accuracy":0.81}}}'
+ assert result.observations[0].value_type is None
@pytest.mark.asyncio
@@ -374,7 +399,7 @@ async def test_agent_output_plan_requires_an_active_agent() -> None:
@pytest.mark.asyncio
-async def test_scalar_reported_metric_requires_a_projection() -> None:
+async def test_reported_metric_rejects_value_semantics() -> None:
plan = parse_measurement_plan(
{
"metrics": [
@@ -404,8 +429,33 @@ async def test_scalar_reported_metric_requires_a_projection() -> None:
{"nodes": [{"id": "agent", "type": "agent", "data": {}}]},
)
- assert [issue.code for issue in report.issues] == ["reported_projection_missing"]
+ assert [issue.code for issue in report.issues] == ["reported_metric_semantics_not_supported"]
async def _async_value(value):
return value
+
+
+@pytest.mark.asyncio
+async def test_malformed_projection_is_a_validation_issue() -> None:
+ plan = parse_measurement_plan(
+ {
+ "metrics": [{"id": "quality", "name": "Quality"}],
+ "producers": [
+ {
+ "id": "agent-output",
+ "producer_id": "asaree.agent_output",
+ "kind": "reported",
+ "outputs": {"value": "quality"},
+ "config": {"agent_node_id": "agent", "projections": {"value": {"path": "a", "transform": "sum"}}},
+ }
+ ],
+ "inputs": [],
+ }
+ )
+
+ report = await validate_reported_measurement_plan(
+ plan, {"nodes": [{"id": "agent", "type": "agent", "data": {}}], "edges": []}
+ )
+
+ assert [issue.code for issue in report.issues] == ["reported_projection_invalid"]
diff --git a/tests/test_run_model_script_sanitize.py b/tests/test_run_model_script_sanitize.py
new file mode 100644
index 00000000..83759391
--- /dev/null
+++ b/tests/test_run_model_script_sanitize.py
@@ -0,0 +1,113 @@
+import json
+
+import numpy as np
+import pandas as pd
+import pytest
+from asaree_sklearn_core.hyperparams import parse_param_spec, sanitize_hyperparameter_payload
+from asaree_sklearn_model import server
+
+SPEC = {
+ "max_depth": {"low": 3, "high": 10},
+ "learning_rate": {"low": 1e-3, "high": 0.3},
+ "gamma": {"low": 0, "high": 5},
+ "subsample": {"low": 0.5, "high": 1.0},
+}
+SCRIPT = """
+chosen_threshold = 0.5
+def predict_proba(X):
+ return np.full(len(X), 0.5)
+result = {"hp_seen": hp}
+"""
+
+
+def test_sanitizer_drops_what_the_script_cannot_run() -> None:
+ clean, notes = sanitize_hyperparameter_payload(
+ {
+ "search_space": [
+ {"name": "max_depth", "low": 3, "high": 6},
+ {"param": "learning_rate", "low": 0, "high": 0.1},
+ {"param": "subsample", "low": 0.6, "high": 0.9},
+ {"param": "subsample", "low": 0.7, "high": 0.8},
+ ],
+ "fixed_params": {"objective": "binary:logistic", "gamma": 1, "subsample": 0.7},
+ "n_trials": 500,
+ },
+ SPEC,
+ )
+ assert clean == {
+ "search_space": [{"param": "subsample", "low": 0.6, "high": 0.9}],
+ "fixed_params": {"gamma": 1},
+ "n_trials": 100,
+ }
+ assert len(notes) == 6
+
+
+def test_sanitizer_reduces_trials_when_nothing_is_tuned() -> None:
+ clean, notes = sanitize_hyperparameter_payload({"search_space": [], "n_trials": 20}, SPEC)
+ assert clean["n_trials"] == 1
+ assert notes
+
+
+def test_param_spec_must_have_numeric_bounds() -> None:
+ with pytest.raises(ValueError, match="numeric 'low' and 'high'"):
+ parse_param_spec(json.dumps({"max_depth": {"low": "3"}}))
+
+
+@pytest.fixture
+def workspace(tmp_path, monkeypatch: pytest.MonkeyPatch) -> str:
+ rng = np.random.default_rng(0)
+ frames = {}
+ for split, n in (("train", 40), ("test", 20)):
+ frame = pd.DataFrame({"a": rng.normal(size=n), "y": [0, 1] * (n // 2)})
+ frames[split] = tmp_path / f"{split}.parquet"
+ frame.to_parquet(frames[split])
+ (tmp_path / "ws").mkdir()
+ (tmp_path / "ws" / "state.json").write_text(
+ json.dumps(
+ {
+ "target_column": "y",
+ "head": "v0",
+ "versions": [{"id": "v0", "train": str(frames["train"]), "test": str(frames["test"])}],
+ }
+ )
+ )
+ monkeypatch.setenv("ASAREE_DATASET_WORKSPACE_DIR", str(tmp_path))
+ return "ws"
+
+
+def test_run_model_script_sanitizes_against_the_given_spec(workspace: str) -> None:
+ payload_json = json.dumps({"search_space": [{"name": "max_depth", "low": 3, "high": 6}], "n_trials": 5})
+ result = json.loads(
+ server.run_model_script(
+ code=SCRIPT,
+ payload_json=payload_json,
+ workspace_id=workspace,
+ param_spec_json=json.dumps(SPEC),
+ )
+ )
+ assert "error" not in result, result.get("error")
+ bound = {"search_space": [], "fixed_params": {}, "n_trials": 1}
+ assert result["sanitized_payload"] == bound
+ assert result["model_decisions"]["hp_seen"] == bound
+ assert result["n_sanitize_notes"] == 2
+ assert result["payload_sha256"] == server.hashlib.sha256(payload_json.encode()).hexdigest()
+
+
+def test_run_model_script_scores_defaults_for_a_null_payload_with_a_spec(workspace: str) -> None:
+ result = json.loads(
+ server.run_model_script(
+ code=SCRIPT, payload_json="null", workspace_id=workspace, param_spec_json=json.dumps(SPEC)
+ )
+ )
+ assert "error" not in result, result.get("error")
+ assert result["n_sanitize_notes"] == 1
+ assert result["sanitized_payload"]["n_trials"] == server.DEFAULT_N_TRIALS
+
+
+def test_run_model_script_without_a_spec_binds_the_payload_unchanged(workspace: str) -> None:
+ result = json.loads(
+ server.run_model_script(code=SCRIPT, payload_json='{"anything": 1}', workspace_id=workspace)
+ )
+ assert "error" not in result, result.get("error")
+ assert result["model_decisions"]["hp_seen"] == {"anything": 1}
+ assert "n_sanitize_notes" not in result
diff --git a/tests/test_runtime_metrics.py b/tests/test_runtime_metrics.py
index af3a4781..df7a8221 100644
--- a/tests/test_runtime_metrics.py
+++ b/tests/test_runtime_metrics.py
@@ -9,7 +9,9 @@
from asaree.services.measurement_engine import CompletedReplicate, MeasurementInput, ProducerBinding
from asaree.services.runtime_metrics import (
+ NodeRuntimeMetricProducer,
RuntimeMetricProducer,
+ authored_prompt_sha256,
collect_runtime_facts,
validate_runtime_measurement_plan,
)
@@ -300,6 +302,74 @@ async def test_runtime_producer_counts_rejections_and_only_actual_critic_approva
assert result.observations["critic_approvals"].value == 1
+@pytest.mark.asyncio
+async def test_runtime_producer_splits_rejections_by_scope_and_counts_null_verdicts_as_rejections() -> None:
+ result = await _evaluate(
+ _facts(
+ runs=[{"run_id": "worker-1", "steps": [], "max_iterations_hit": True}, {"run_id": "worker-2", "steps": []}],
+ critic_reviews=[
+ {"run_id": "critic-1", "approved": False, "rejection_scope": "partial"},
+ {"run_id": "critic-2", "approved": None, "rejection_scope": None},
+ {"run_id": "critic-3", "approved": False, "rejection_scope": "full"},
+ {"run_id": "critic-4", "approved": True, "rejection_scope": None},
+ ],
+ critic_gates=[
+ {"revisions_used": 2, "approved": True, "forced": False},
+ {"revisions_used": 1, "approved": None, "forced": True},
+ ],
+ )
+ )
+
+ values = {key: observation.value for key, observation in result.observations.items()}
+ assert values["critic_invocations"] == 4
+ assert values["critic_rejections"] == 3
+ assert values["critic_approvals"] == 1
+ assert values["critic_rejections_partial"] == 1
+ assert values["critic_rejections_full"] == 2
+ assert values["revision_rounds"] == 3
+ assert values["capped_agent_runs"] == 1
+
+
+@pytest.mark.asyncio
+async def test_runtime_producer_reports_agent_runs_and_reason_act_counters() -> None:
+ result = await _evaluate(
+ _facts(
+ runs=[
+ {"run_id": "dc", "steps": [], "reason_act": {"iterations": 3, "tool_calls": 5}},
+ {"run_id": "fte", "steps": [], "reason_act": {"iterations": 12, "tool_calls": 20}},
+ {"run_id": "critic", "steps": [], "reason_act": None},
+ ]
+ )
+ )
+
+ values = {key: observation.value for key, observation in result.observations.items()}
+ assert values["agent_runs"] == 3
+ assert values["react_runs"] == 2
+ assert values["react_turns"] == 15
+ assert values["react_tool_calls"] == 25
+
+
+@pytest.mark.asyncio
+async def test_reason_act_counters_are_unavailable_without_a_reason_act_run_or_its_record() -> None:
+ single_pass = await _evaluate(_facts(runs=[{"run_id": "mlm", "steps": [], "reason_act": None}]))
+ unrecorded = await _evaluate(_facts(runs=[{"run_id": "old", "steps": []}]))
+
+ assert single_pass.observations["react_runs"].value == 0
+ assert single_pass.observations["react_turns"].status == "unavailable"
+ assert single_pass.observations["react_tool_calls"].status == "unavailable"
+ assert unrecorded.observations["agent_runs"].value == 1
+ for key in ("react_runs", "react_turns", "react_tool_calls"):
+ assert unrecorded.observations[key].status == "unavailable"
+
+
+@pytest.mark.asyncio
+async def test_runtime_producer_reports_zero_critic_activity_when_no_critic_ran() -> None:
+ result = await _evaluate(_facts(critic_gates=[{"revisions_used": 0, "approved": None, "forced": False}]))
+
+ for key in ("critic_invocations", "critic_rejections_partial", "critic_rejections_full", "revision_rounds"):
+ assert result.observations[key].value == 0
+
+
@pytest.mark.asyncio
async def test_runtime_producer_marks_duration_unavailable_without_both_boundaries() -> None:
result = await _evaluate(_facts(completed_at=None))
@@ -333,7 +403,10 @@ async def test_runtime_fact_collection_uses_protocol_attribution_not_final_node_
run.run_metadata = {}
run.output = None
attributed[1].run_metadata = {"runtime_role": "critic"}
- attributed[1].output = OutputEnvelope(payload={"approved": False}).to_json()
+ attributed[1].output = OutputEnvelope(payload={"approved": False, "rejection_scope": "partial"}).to_json()
+ attributed[2].pattern_overrides = {
+ "reason_act_state": {"max_iterations_hit": True, "iterations": 4, "tool_calls": 6}
+ }
list_call: dict[str, object] = {}
async def fake_list_runs(**kwargs):
@@ -370,7 +443,9 @@ async def fake_get_run_steps(run_id):
assert list_call["metadata"] == {"protocol_run_id": str(protocol_run_id)}
assert {run["run_id"] for run in facts["runs"]} == {str(run_id) for run_id in run_ids}
assert facts["critic_gates"] == [{"approved": None, "forced": True, "revisions_used": 2}]
- assert facts["critic_reviews"] == [{"approved": False, "run_id": str(run_ids[1])}]
+ assert facts["critic_reviews"] == [{"approved": False, "rejection_scope": "partial", "run_id": str(run_ids[1])}]
+ assert [run["max_iterations_hit"] for run in facts["runs"]] == [False, False, True, False]
+ assert [run["reason_act"] for run in facts["runs"]] == [None, None, {"iterations": 4, "tool_calls": 6}, None]
assert facts["runs"][0]["usage"] == {
"input_tokens": 1,
"output_tokens": 2,
@@ -415,3 +490,160 @@ async def fake_get_run_steps(requested_run_id):
assert facts["runs"][0]["usage"] == {"input_tokens": 8, "cache_read_tokens": 3}
assert facts["runs"][1]["cost_usd"] is None
assert facts["runs"][1]["usage"] == {}
+
+
+def _prompt_graph(goal: str = "Clean the data.") -> dict[str, object]:
+ return {
+ "nodes": [
+ {
+ "id": "a",
+ "type": "agent",
+ "position": {"x": 0, "y": 0},
+ "data": {"config": {"goal": goal, "model": "x"}},
+ },
+ {"id": "g", "type": "critic_gate", "data": {"config": {"system_prompt": "Checklist: ..."}}},
+ {"id": "d", "type": "dataset", "data": {"config": {"goal": "not a prompt"}}},
+ ],
+ "edges": [],
+ }
+
+
+def test_prompt_hash_covers_prompt_text_only() -> None:
+ base = authored_prompt_sha256(_prompt_graph())
+ moved = _prompt_graph()
+ moved["nodes"] = list(reversed(moved["nodes"])) # type: ignore[call-overload]
+ moved["nodes"][-1]["position"] = {"x": 500, "y": 9} # type: ignore[index]
+ moved["nodes"][-1]["data"]["config"]["model"] = "y" # type: ignore[index]
+
+ assert base is not None and len(base) == 64
+ assert authored_prompt_sha256(moved) == base
+ assert authored_prompt_sha256(_prompt_graph("Clean the data!")) != base
+ assert authored_prompt_sha256({"nodes": [], "edges": []}) is None
+
+
+@pytest.mark.asyncio
+async def test_runtime_producer_reports_the_prompt_hash_from_facts() -> None:
+ measured = await _evaluate(_facts(prompt_sha256="ab" * 32))
+ missing = await _evaluate(_facts())
+
+ assert measured.observations["prompt_sha256"].value == "ab" * 32
+ assert missing.observations["prompt_sha256"].status == "unavailable"
+
+
+async def _evaluate_nodes(facts: dict[str, object], node_ids: list[str]):
+ adapter = NodeRuntimeMetricProducer()
+ return await adapter.evaluate(
+ ProducerBinding(
+ id="runtime-stage",
+ producer_id=adapter.producer_id,
+ kind="runtime",
+ outputs={key: key for key in adapter.output_keys},
+ config={"node_ids": node_ids},
+ ),
+ {"facts": MeasurementInput(value_type="runtime_facts", value=facts)},
+ CompletedReplicate(replicate_id="replicate-1", attempt_id="attempt-1"),
+ )
+
+
+def _stage_run(run_id: str, node_id: str | None, tokens: int, iterations: int) -> dict[str, object]:
+ return {
+ "run_id": run_id,
+ "node_id": node_id,
+ "usage": {"total_tokens": tokens},
+ "steps": [{"iteration": index, "tool_call": None, "llm_call": None} for index in range(iterations)],
+ "max_iterations_hit": False,
+ }
+
+
+@pytest.mark.asyncio
+async def test_node_runtime_producer_sums_only_the_chosen_nodes_runs() -> None:
+ facts = _facts(
+ runs=[
+ _stage_run("dc-1", "agent-dc", 100, 3),
+ _stage_run("dc-revision", "agent-dc", 50, 2),
+ _stage_run("fte-1", "agent-fte", 400, 5),
+ _stage_run("critic-dc", "gate-dc", 7, 1),
+ _stage_run("critic-fte", "gate-fte", 9, 1),
+ ]
+ )
+
+ dc = await _evaluate_nodes(facts, ["agent-dc"])
+ critics = await _evaluate_nodes(facts, ["gate-dc", "gate-fte"])
+ absent = await _evaluate_nodes(facts, ["agent-mlm"])
+
+ assert dc.observations["total_tokens"].value == 150
+ assert dc.observations["agent_loop_iterations"].value == 5
+ assert dc.observations["agent_runs"].value == 2
+ assert critics.observations["total_tokens"].value == 16
+ assert absent.observations["agent_runs"].value == 0
+ assert absent.observations["total_tokens"].status == "unavailable"
+
+
+@pytest.mark.asyncio
+async def test_node_runtime_producer_is_unavailable_for_facts_without_node_attribution() -> None:
+ legacy = _stage_run("dc-1", None, 100, 3)
+ del legacy["node_id"]
+
+ result = await _evaluate_nodes(_facts(runs=[legacy]), ["agent-dc"])
+
+ assert {observation.status for observation in result.observations.values()} == {"unavailable"}
+
+
+def test_node_runtime_validation_requires_nodes_on_the_canvas() -> None:
+ def plan(node_ids: list[str]) -> dict[str, object]:
+ return {
+ "metrics": [{"id": "tokens-dc", "name": "tokens_dc", "value_type": "number", "aggregation": "sum"}],
+ "producers": [
+ {
+ "id": "runtime-dc",
+ "producer_id": "asaree.node_runtime",
+ "kind": "runtime",
+ "outputs": {"total_tokens": "tokens-dc"},
+ "config": {"node_ids": node_ids},
+ }
+ ],
+ "inputs": [{"producer_binding_id": "runtime-dc", "input_key": "facts", "source_key": "attempt.runtime"}],
+ }
+
+ graph = {"nodes": [{"id": "agent-dc", "type": "agent"}], "edges": []}
+
+ assert not validate_runtime_measurement_plan(plan(["agent-dc"]), graph=graph).issues
+ assert [issue.code for issue in validate_runtime_measurement_plan(plan(["gone"]), graph=graph).issues] == [
+ "node_runtime_node_unavailable"
+ ]
+ assert [issue.code for issue in validate_runtime_measurement_plan(plan([]), graph=graph).issues] == [
+ "node_runtime_nodes_missing"
+ ]
+
+
+def test_node_runtime_metric_saved_as_id_and_name_still_validates() -> None:
+ report = validate_runtime_measurement_plan(
+ {
+ "metrics": [
+ {"id": "tokens-dc", "name": "tokens_dc"},
+ {"id": "errors-dc", "name": "tool_errors_dc"},
+ ],
+ "producers": [
+ {
+ "id": "node-runtime-tokens-dc",
+ "producer_id": "asaree.node_runtime",
+ "kind": "runtime",
+ "outputs": {"total_tokens": "tokens-dc"},
+ "config": {"node_ids": ["agent-dc"]},
+ },
+ {
+ "id": "node-runtime-errors-dc",
+ "producer_id": "asaree.node_runtime",
+ "kind": "runtime",
+ "outputs": {"tool_error_rate": "errors-dc"},
+ "config": {"node_ids": ["agent-dc"]},
+ },
+ ],
+ "inputs": [
+ {"producer_binding_id": binding, "input_key": "facts", "source_key": "attempt.runtime"}
+ for binding in ("node-runtime-tokens-dc", "node-runtime-errors-dc")
+ ],
+ }
+ )
+
+ assert not report.issues
diff --git a/tests/test_skills_api.py b/tests/test_skills_api.py
new file mode 100644
index 00000000..02239d7d
--- /dev/null
+++ b/tests/test_skills_api.py
@@ -0,0 +1,55 @@
+"""API error translation for registered Agent Skills."""
+
+from __future__ import annotations
+
+import uuid
+from io import BytesIO
+from types import SimpleNamespace
+
+import pytest
+from fastapi import HTTPException, UploadFile
+from motoro.schemas.skill import SkillCreate
+from sqlalchemy.exc import IntegrityError
+
+from asaree.api import skills
+
+
+async def test_registering_duplicate_skill_name_returns_conflict(monkeypatch: pytest.MonkeyPatch) -> None:
+ async def _duplicate(**_kwargs: object) -> None:
+ raise IntegrityError(
+ "INSERT INTO skills",
+ {},
+ Exception('duplicate key value violates unique constraint "uq_skills_owner_name_active"'),
+ )
+
+ monkeypatch.setattr(skills.skill_service, "create_skill", _duplicate)
+
+ with pytest.raises(HTTPException) as exc_info:
+ await skills.create_skill_endpoint(
+ SkillCreate(name="duplicate-skill", description="A duplicate skill.", body="Instructions."),
+ SimpleNamespace(id=uuid.uuid4()),
+ )
+
+ assert exc_info.value.status_code == 409
+ assert exc_info.value.detail == "A skill with this name is already registered to your account."
+
+
+async def test_uploading_duplicate_skill_folder_returns_conflict(monkeypatch: pytest.MonkeyPatch) -> None:
+ async def _duplicate(*_args: object, **_kwargs: object) -> None:
+ raise IntegrityError(
+ "INSERT INTO skills",
+ {},
+ Exception('duplicate key value violates unique constraint "uq_skills_owner_name_active"'),
+ )
+
+ monkeypatch.setattr(skills.skill_service, "create_skill_from_bundle", _duplicate)
+ upload = UploadFile(
+ BytesIO(b"---\nname: duplicate-skill\ndescription: A duplicate skill.\n---\n"),
+ filename="duplicate-skill/SKILL.md",
+ )
+
+ with pytest.raises(HTTPException) as exc_info:
+ await skills.upload_skill_folder_endpoint(SimpleNamespace(id=uuid.uuid4()), [upload])
+
+ assert exc_info.value.status_code == 409
+ assert exc_info.value.detail == "A skill with this name is already registered to your account."
diff --git a/tests/test_tool_steps.py b/tests/test_tool_steps.py
new file mode 100644
index 00000000..9cc123e4
--- /dev/null
+++ b/tests/test_tool_steps.py
@@ -0,0 +1,289 @@
+import json
+
+import pytest
+
+from asaree.services import tool_steps
+from asaree.services.derived_metrics import (
+ collect_derived_observations,
+ derived_binding_issue,
+ feature_pipeline_values,
+ tool_step_values,
+)
+from asaree.services.measurement_engine import parse_measurement_plan
+from asaree.services.tool_steps import (
+ canonical_payload_json,
+ execute_tool_step,
+ sha256_text,
+ validate_tool_step,
+)
+
+SERVER_ID = "b16f01eb-0000-4000-8000-000000000001"
+CODE = "print('model')\n"
+ARGUMENTS = {
+ "code": {"source": "script_code"},
+ "payload_json": {"source": "upstream_payload", "format": "json_string"},
+ "workspace_id": {"source": "workspace_id"},
+ "random_seed": {"source": "value", "value": 7},
+}
+HASH_CHECKS = {"code_sha256": "code", "payload_sha256": "payload_json"}
+
+
+def _graph(*, tool_names=("run_model_script",), with_script=True, arguments=None, hash_checks=None) -> dict:
+ nodes = [
+ {"id": "gate", "type": "critic_gate", "data": {"config": {}}},
+ {
+ "id": "mcp",
+ "type": "mcp_tool",
+ "data": {"config": {"server_id": SERVER_ID, "server_name": "models", "tool_names": list(tool_names)}},
+ },
+ {
+ "id": "step",
+ "type": "tool_step",
+ "data": {
+ "config": {
+ "tool_name": "run_model_script",
+ "arguments": ARGUMENTS if arguments is None else arguments,
+ "hash_checks": HASH_CHECKS if hash_checks is None else hash_checks,
+ }
+ },
+ },
+ ]
+ edges = [
+ {"source": "gate", "target": "step", "targetHandle": None},
+ {"source": "mcp", "target": "step", "targetHandle": "tool"},
+ ]
+ if with_script:
+ nodes.append({"id": "script", "type": "script", "data": {"config": {"code": CODE}}})
+ edges.append({"source": "script", "target": "step", "targetHandle": "tool"})
+ return {"nodes": nodes, "edges": edges}
+
+
+def _step(graph: dict) -> dict:
+ return next(node for node in graph["nodes"] if node["id"] == "step")
+
+
+def test_validation_requires_the_tool_to_be_enabled_on_the_mcp_node() -> None:
+ graph = _graph(tool_names=("other",))
+ assert "does not enable" in (validate_tool_step(graph, _step(graph)) or "")
+ graph = _graph()
+ assert validate_tool_step(graph, _step(graph)) is None
+
+
+def test_validation_checks_argument_sources_and_hash_checks() -> None:
+ graph = _graph(arguments={"x": {"source": "sanitized"}})
+ assert "no valid source" in (validate_tool_step(graph, _step(graph)) or "")
+ graph = _graph(with_script=False)
+ assert "no Script is wired" in (validate_tool_step(graph, _step(graph)) or "")
+ graph = _graph(hash_checks={"code_sha256": "missing"})
+ assert "not a sent argument" in (validate_tool_step(graph, _step(graph)) or "")
+ graph = _graph(arguments={}, hash_checks={}, with_script=False)
+ assert validate_tool_step(graph, _step(graph)) is None
+
+
+def test_validation_names_the_old_config_format() -> None:
+ graph = _graph()
+ _step(graph)["data"]["config"].update(sanitizer="xgboost_hyperparameters", verify_hashes=True)
+ assert "old Tool Step format" in (validate_tool_step(graph, _step(graph)) or "")
+
+
+def _patch_call(monkeypatch: pytest.MonkeyPatch, respond) -> list[dict]:
+ calls: list[dict] = []
+
+ async def call_server_tool(server_id, tool_name, arguments, **_kwargs):
+ calls.append({"server_id": str(server_id), "tool_name": tool_name, "arguments": arguments})
+ return respond(arguments)
+
+ monkeypatch.setattr(tool_steps.mcp_service, "call_server_tool", call_server_tool)
+ return calls
+
+
+@pytest.mark.asyncio
+async def test_tool_step_sends_mapped_arguments_and_records_them_first(monkeypatch: pytest.MonkeyPatch) -> None:
+ def respond(arguments):
+ return False, json.dumps(
+ {
+ "code_sha256": sha256_text(arguments["code"]),
+ "payload_sha256": sha256_text(arguments["payload_json"]),
+ "n_sanitize_notes": 3,
+ "test_metrics": {"average_precision": 0.41, "metrics_at_0.5": {"f1": 0.3}},
+ }
+ )
+
+ calls = _patch_call(monkeypatch, respond)
+ recorded: list[dict] = []
+
+ async def record(step):
+ assert not calls, "provenance must be recorded before the call"
+ recorded.append(step)
+
+ graph = _graph()
+ payload = {"search_space": [{"name": "max_depth", "low": 3, "high": 6}]}
+ node_run = await execute_tool_step(
+ _step(graph),
+ graph=graph,
+ upstream_runs=[{"status": "completed", "payload": payload}],
+ workspace_id="ws-1",
+ record_provenance=record,
+ )
+
+ assert node_run["status"] == "completed", node_run["error"]
+ sent = calls[0]["arguments"]
+ assert sent == {
+ "code": CODE.strip(),
+ "payload_json": canonical_payload_json(payload),
+ "workspace_id": "ws-1",
+ "random_seed": 7,
+ }
+ assert recorded[0]["payload"] == payload
+ assert recorded[0]["arguments"] == {"workspace_id": "ws-1", "random_seed": 7}
+ assert recorded[0]["argument_sha256"]["payload_json"] == sha256_text(sent["payload_json"])
+ values = tool_step_values(node_run)
+ assert values is not None
+ assert values["test_metrics.metrics_at_0.5.f1"] == 0.3
+ assert values["n_sanitize_notes"] == 3
+
+
+@pytest.mark.asyncio
+async def test_tool_step_sends_null_when_upstream_has_no_payload(monkeypatch: pytest.MonkeyPatch) -> None:
+ calls = _patch_call(monkeypatch, lambda arguments: (False, json.dumps({"ok": True})))
+ graph = _graph(hash_checks={})
+ node_run = await execute_tool_step(
+ _step(graph), graph=graph, upstream_runs=[{"output_text": "no json here"}], workspace_id="ws-1"
+ )
+ assert node_run["status"] == "completed"
+ assert calls[0]["arguments"]["payload_json"] == "null"
+
+
+@pytest.mark.asyncio
+async def test_tool_step_fails_when_the_scored_payload_differs(monkeypatch: pytest.MonkeyPatch) -> None:
+ _patch_call(
+ monkeypatch,
+ lambda arguments: (
+ False,
+ json.dumps({"code_sha256": sha256_text(CODE.strip()), "payload_sha256": "tampered"}),
+ ),
+ )
+ graph = _graph()
+ node_run = await execute_tool_step(
+ _step(graph),
+ graph=graph,
+ upstream_runs=[{"payload": {"search_space": [], "n_trials": 1}}],
+ workspace_id="ws-1",
+ )
+ assert node_run["status"] == "failed"
+ assert "'payload_sha256'" in node_run["error"] and node_run["error"].startswith("Hash check failed")
+ assert node_run["tool_step"]["payload"] == {"search_space": [], "n_trials": 1}
+
+
+def test_feature_pipeline_counts_created_features_against_raw_columns() -> None:
+ node_runs = {
+ "dc": {"status": "completed", "payload": {"n_cols_out": 10}},
+ "fte": {"status": "completed", "payload": {"n_features_out": 14}},
+ "fs": {"status": "completed", "payload": {"selected_features": ["num__age", "num__age_x_bmi", "bmi"]}},
+ }
+ values = feature_pipeline_values(
+ node_runs,
+ {"dc_node_id": "dc", "fte_node_id": "fte", "fs_node_id": "fs"},
+ frozenset({"age", "bmi"}),
+ )
+ assert values == {
+ "n_features_after_dc": 10,
+ "n_features_after_fte": 14,
+ "n_features_after_fs": 3,
+ "n_features_created": 4,
+ "n_created_selected": 1,
+ "frac_created_selected": pytest.approx(1 / 3),
+ }
+
+
+def test_feature_pipeline_measures_the_fte_recipe_like_the_notebook() -> None:
+ recipe = [
+ {"name": "ratio", "op": "ratio", "inputs": ["a", "b"], "params": {}, "rationale": "x"},
+ {"name": "ratio_sq", "op": "power", "inputs": ["ratio"], "params": {"p": 2}, "rationale": "y"},
+ ]
+ config = {"dc_node_id": "dc", "fte_node_id": "fte", "fs_node_id": "fs"}
+
+ def values_for(entries):
+ return feature_pipeline_values(
+ {"fte": {"status": "completed", "payload": {"engineering_recipe": entries}}}, config, None
+ )
+
+ values = values_for(recipe)
+ assert values["n_recipe_ops"] == 2
+ assert values["recipe_depth"] == 2
+ # Order and rationale don't change what was engineered.
+ reordered = [{**entry, "rationale": "changed"} for entry in reversed(recipe)]
+ assert values_for(reordered)["recipe_hash"] == values["recipe_hash"]
+ assert values_for(recipe[:1])["recipe_hash"] != values["recipe_hash"]
+ assert values_for([])["recipe_depth"] == 0
+
+
+def _derived_plan(producer_id: str, outputs: dict, config: dict):
+ return parse_measurement_plan(
+ {
+ "metrics": [{"id": metric_id, "name": metric_id} for metric_id in outputs.values()],
+ "producers": [
+ {
+ "id": "source",
+ "producer_id": producer_id,
+ "kind": "reported",
+ "outputs": outputs,
+ "artifacts": [],
+ "config": config,
+ }
+ ],
+ "inputs": [],
+ }
+ )
+
+
+@pytest.mark.asyncio
+async def test_tool_step_producer_writes_numbers_and_reports_missing_paths() -> None:
+ plan = _derived_plan(
+ "asaree.tool_step",
+ {"pr_auc": "pr_auc", "nan": "nan", "absent": "absent", "n_metrics": "n_metrics", "whole": "whole"},
+ {
+ "node_id": "step",
+ "projections": {
+ "pr_auc": {"path": "test_metrics.average_precision"},
+ "nan": {"path": "test_metrics.nan"},
+ "absent": {"path": "absent"},
+ "n_metrics": {"path": "test_metrics", "transform": "length"},
+ },
+ },
+ )
+ node_runs = {
+ "step": {
+ "status": "completed",
+ "payload": {"test_metrics": {"average_precision": 0.41, "nan": float("nan")}},
+ "tool_step": {},
+ }
+ }
+ observations = await collect_derived_observations(
+ plan.producers[0],
+ {metric.id: metric for metric in plan.metrics},
+ node_runs=node_runs,
+ graph=_graph(),
+ attempt_id="a1",
+ )
+ by_id = {observation.metric_id: observation for observation in observations}
+ assert by_id["pr_auc"].value == 0.41 and by_id["pr_auc"].status == "measured"
+ assert by_id["nan"].status == "unavailable"
+ assert by_id["absent"].status == "unavailable"
+ assert by_id["n_metrics"].value == 2
+ assert by_id["whole"].value == node_runs["step"]["payload"], "no projection records the whole result"
+ assert derived_binding_issue(plan.producers[0], _graph()) is None
+ assert derived_binding_issue(plan.producers[0], {"nodes": [], "edges": []})[0] == "tool_step_missing"
+
+
+def test_graph_validation_rejects_a_misconfigured_tool_step() -> None:
+ from asaree.services.protocol_execution import ProtocolValidationError, topological_order
+
+ def with_input(graph: dict) -> dict:
+ graph["nodes"][0] = {"id": "gate", "type": "input", "data": {"config": {}}}
+ return graph
+
+ ordered = [node["id"] for node in topological_order(with_input(_graph()))]
+ assert ordered.index("gate") < ordered.index("step")
+ with pytest.raises(ProtocolValidationError, match="does not enable"):
+ topological_order(with_input(_graph(tool_names=("other",))))