diff --git a/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx b/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx index 7370e852..d60fa1c0 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx @@ -273,6 +273,15 @@ function SpecRendererInner({ const [showResults, setShowResults] = useState(false); const [validationStatus, setValidationStatus] = useState<'valid' | 'invalid' | 'unchecked'>('unchecked'); + // When connecting Extractor → Objective/Constraint and the extractor has multiple outputs, + // we prompt the user to choose which output_name to use. + const [pendingOutputSelect, setPendingOutputSelect] = useState(null); + // Build trial history for sparklines (extract objective values from recent trials) const trialHistory = useMemo(() => { const history: Record = {}; @@ -589,39 +598,43 @@ function SpecRendererInner({ const targetType = classify(targetId); try { - // Always persist the visual edge (for now) - await addEdge(sourceId, targetId); - // Option A truth model: objective/constraint source is the real linkage. - // When user connects Extractor -> Objective/Constraint, update *.source accordingly. + // When user connects Extractor -> Objective/Constraint, we must choose an output_name. if (spec && sourceType === 'extractor' && (targetType === 'objective' || targetType === 'constraint')) { const ext = spec.extractors.find((e) => e.id === sourceId); + const outputNames = (ext?.outputs || []).map((o) => o.name).filter(Boolean); - // Choose a sensible default output: - // - prefer 'value' if present - // - else if only one output, use it - // - else use first output - const outputs = ext?.outputs || []; - const preferred = outputs.find((o) => o.name === 'value')?.name; - const outputName = - preferred || (outputs.length === 1 ? outputs[0].name : outputs.length > 0 ? outputs[0].name : 'value'); - - if (targetType === 'objective') { - await updateNode(targetId, { - source: { extractor_id: sourceId, output_name: outputName }, - }); - } else { - await updateNode(targetId, { - source: { extractor_id: sourceId, output_name: outputName }, + // If extractor has multiple outputs, prompt the user. + if (outputNames.length > 1) { + const preferred = outputNames.includes('value') ? 'value' : outputNames[0]; + setPendingOutputSelect({ + sourceId, + targetId, + outputNames, + selected: preferred, }); + return; } + + // Single (or zero) output: choose deterministically. + const outputName = outputNames[0] || 'value'; + + // Persist edge + runnable source. + await addEdge(sourceId, targetId); + await updateNode(targetId, { + source: { extractor_id: sourceId, output_name: outputName }, + }); + return; } + + // Default: just persist the visual edge. + await addEdge(sourceId, targetId); } catch (err) { console.error('Failed to add connection:', err); setError(err instanceof Error ? err.message : 'Failed to add connection'); } }, - [editable, addEdge, setError, spec, updateNode] + [editable, addEdge, setError, spec, updateNode, setPendingOutputSelect] ); // Handle node clicks for selection @@ -760,6 +773,34 @@ function SpecRendererInner({ [editable, addNode, selectNode, setError, localNodes] ); + // ------------------------------------------------------------------------- + // Output selection modal handlers (Extractor → Objective/Constraint) + // ------------------------------------------------------------------------- + + const confirmOutputSelection = useCallback(async () => { + if (!pendingOutputSelect) return; + + const { sourceId, targetId, selected } = pendingOutputSelect; + + try { + // Persist edge + runnable source wiring + await addEdge(sourceId, targetId); + await updateNode(targetId, { + source: { extractor_id: sourceId, output_name: selected }, + }); + } catch (err) { + console.error('Failed to apply output selection:', err); + setError(err instanceof Error ? err.message : 'Failed to apply output selection'); + } finally { + setPendingOutputSelect(null); + } + }, [pendingOutputSelect, addEdge, updateNode, setError]); + + const cancelOutputSelection = useCallback(() => { + // User canceled: do not create the edge, do not update source + setPendingOutputSelect(null); + }, []); + // Loading state if (showLoadingOverlay && isLoading && !spec) { return ( @@ -842,6 +883,55 @@ function SpecRendererInner({ )} + {/* Output selection modal (Extractor → Objective/Constraint) */} + {pendingOutputSelect && ( +
+
+

Select extractor output

+

+ This extractor provides multiple outputs. Choose which output the target should use. +

+ +
+ + +

+ Tip: we default to value when available. +

+
+ +
+ + +
+
+
+ )} +