+
{inputPorts.length} in
•
@@ -261,8 +264,10 @@ const GroupBlockNode = memo(({ data, selected, id }: GroupBlockNodeProps) => {
{/* Render output handles */}
{outputPorts.map((port, index) => {
- const spacing = 100 / (outputPorts.length + 1)
- const topPercent = spacing * (index + 1)
+ const rangeStart = 70
+ const rangeEnd = 90
+ const spacing = (rangeEnd - rangeStart) / (outputPorts.length + 1)
+ const topPercent = rangeStart + spacing * (index + 1)
const color = getPortColor(port.semantic)
const isConnected = isHandleConnected(port.externalPortId, false)
diff --git a/project/frontend/src/components/InternalNodeConfigPanel.tsx b/project/frontend/src/components/InternalNodeConfigPanel.tsx
index 291a800..4957d6b 100644
--- a/project/frontend/src/components/InternalNodeConfigPanel.tsx
+++ b/project/frontend/src/components/InternalNodeConfigPanel.tsx
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@@ -280,6 +281,41 @@ export default function InternalNodeConfigPanel({
)}
+
+ {field.type === 'multiselect' && field.options && (
+
+ {field.options.map((opt) => {
+ const currentValues = currentValue ?? field.default ?? []
+ const isChecked = Array.isArray(currentValues) && currentValues.includes(opt.value)
+
+ return (
+
+ {
+ const newValues = Array.isArray(currentValues) ? [...currentValues] : []
+ if (checked) {
+ if (!newValues.includes(opt.value)) {
+ newValues.push(opt.value)
+ }
+ } else {
+ const index = newValues.indexOf(opt.value)
+ if (index > -1) {
+ newValues.splice(index, 1)
+ }
+ }
+ handleConfigChange(field.name, newValues)
+ }}
+ />
+
+
+ )
+ })}
+
+ )}
)
})
diff --git a/project/frontend/src/index.css b/project/frontend/src/index.css
index 80f6504..883eb83 100644
--- a/project/frontend/src/index.css
+++ b/project/frontend/src/index.css
@@ -120,6 +120,13 @@ body {
font-family: var(--font-sans);
}
+.react-flow__node-group {
+ background: transparent !important;
+ border: none !important;
+ padding: 0 !important;
+ box-shadow: none !important;
+}
+
code, pre {
font-family: var(--font-mono);
}
\ No newline at end of file
diff --git a/project/frontend/src/lib/nodes/definitions/pytorch/dataloader.ts b/project/frontend/src/lib/nodes/definitions/pytorch/dataloader.ts
index 8c082ea..3a8148a 100644
--- a/project/frontend/src/lib/nodes/definitions/pytorch/dataloader.ts
+++ b/project/frontend/src/lib/nodes/definitions/pytorch/dataloader.ts
@@ -66,20 +66,6 @@ export class DataLoaderNode extends SourceNodeDefinition {
type: 'boolean',
default: false,
description: 'Use random synthetic data for testing'
- },
- {
- name: 'csv_file',
- label: 'CSV File',
- type: 'file',
- accept: '.csv',
- description: 'Upload a CSV file for data loading (optional)'
- },
- {
- name: 'csv_filename',
- label: 'Uploaded File Name',
- type: 'text',
- placeholder: 'No file uploaded',
- description: 'Name of the uploaded CSV file (read-only)'
}
]
diff --git a/project/frontend/src/lib/nodes/definitions/pytorch/groundtruth.ts b/project/frontend/src/lib/nodes/definitions/pytorch/groundtruth.ts
new file mode 100644
index 0000000..43216bf
--- /dev/null
+++ b/project/frontend/src/lib/nodes/definitions/pytorch/groundtruth.ts
@@ -0,0 +1,88 @@
+/**
+ * PyTorch Ground Truth Node Definition
+ */
+
+import { SourceNodeDefinition } from '../../base'
+import { NodeMetadata, BackendFramework } from '../../contracts'
+import { TensorShape, BlockConfig, ConfigField } from '../../../types'
+import { PortDefinition } from '../../ports'
+
+export class GroundTruthNode extends SourceNodeDefinition {
+ readonly metadata: NodeMetadata = {
+ type: 'groundtruth',
+ label: 'Ground Truth',
+ category: 'input',
+ color: 'var(--color-orange)',
+ icon: 'Target',
+ description: 'Ground truth labels for training',
+ framework: BackendFramework.PyTorch
+ }
+
+ readonly configSchema: ConfigField[] = [
+ {
+ name: 'shape',
+ label: 'Label Shape',
+ type: 'text',
+ default: '[1, 10]',
+ required: true,
+ placeholder: '[batch, num_classes]',
+ description: 'Ground truth tensor dimensions as JSON array'
+ },
+ {
+ name: 'label',
+ label: 'Custom Label',
+ type: 'text',
+ default: 'Ground Truth',
+ placeholder: 'Enter custom label...',
+ description: 'Custom label for this ground truth node'
+ },
+ {
+ name: 'note',
+ label: 'Note',
+ type: 'text',
+ placeholder: 'Add notes here...',
+ description: 'Notes or comments about this ground truth data'
+ }
+ ]
+
+ /**
+ * Ground truth outputs labels, not data
+ */
+ getOutputPorts(config: BlockConfig): PortDefinition[] {
+ return [{
+ id: 'default',
+ label: 'Labels',
+ type: 'output',
+ semantic: 'labels',
+ required: false,
+ description: 'Ground truth labels for training'
+ }]
+ }
+
+ computeOutputShape(inputShape: TensorShape | undefined, config: BlockConfig): TensorShape | undefined {
+ const shapeStr = String(config.shape || '[1, 10]')
+ const dims = this.parseShapeString(shapeStr)
+
+ if (dims) {
+ return {
+ dims,
+ description: 'Ground truth labels'
+ }
+ }
+
+ return undefined
+ }
+
+ validateConfig(config: BlockConfig): string[] {
+ const errors = super.validateConfig(config)
+
+ // Validate shape format
+ const shapeStr = String(config.shape || '')
+ const dims = this.parseShapeString(shapeStr)
+ if (!dims) {
+ errors.push('Label Shape must be a valid JSON array of positive numbers')
+ }
+
+ return errors
+ }
+}
diff --git a/project/frontend/src/lib/nodes/definitions/pytorch/index.ts b/project/frontend/src/lib/nodes/definitions/pytorch/index.ts
index c6f70da..818e6c4 100644
--- a/project/frontend/src/lib/nodes/definitions/pytorch/index.ts
+++ b/project/frontend/src/lib/nodes/definitions/pytorch/index.ts
@@ -5,8 +5,10 @@
export { InputNode } from './input'
export { DataLoaderNode } from './dataloader'
+export { GroundTruthNode } from './groundtruth'
export { OutputNode } from './output'
export { LossNode } from './loss'
+export { MetricsNode } from './metrics'
export { EmptyNode } from './empty'
export { LinearNode } from './linear'
export { Conv2DNode } from './conv2d'
diff --git a/project/frontend/src/lib/nodes/definitions/pytorch/loss.ts b/project/frontend/src/lib/nodes/definitions/pytorch/loss.ts
index 2b25610..473f108 100644
--- a/project/frontend/src/lib/nodes/definitions/pytorch/loss.ts
+++ b/project/frontend/src/lib/nodes/definitions/pytorch/loss.ts
@@ -121,17 +121,10 @@ export class LossNode extends NodeDefinition {
}
/**
- * Get output ports - loss always outputs a single scalar loss value
+ * Loss nodes are terminal nodes - they don't have output ports
*/
getOutputPorts(config: BlockConfig): PortDefinition[] {
- return [{
- id: 'loss-output',
- label: 'Loss',
- type: 'output',
- semantic: 'loss',
- required: false,
- description: 'Scalar loss value'
- }]
+ return []
}
/**
diff --git a/project/frontend/src/lib/nodes/definitions/pytorch/metrics.ts b/project/frontend/src/lib/nodes/definitions/pytorch/metrics.ts
new file mode 100644
index 0000000..c84ab15
--- /dev/null
+++ b/project/frontend/src/lib/nodes/definitions/pytorch/metrics.ts
@@ -0,0 +1,155 @@
+/**
+ * PyTorch Metrics Node Definition
+ */
+
+import { NodeDefinition } from '../../base'
+import { NodeMetadata, BackendFramework } from '../../contracts'
+import { TensorShape, BlockConfig, ConfigField, BlockType } from '../../../types'
+import { PortDefinition } from '../../ports'
+
+export class MetricsNode extends NodeDefinition {
+ readonly metadata: NodeMetadata = {
+ type: 'metrics',
+ label: 'Metrics',
+ category: 'output',
+ color: 'var(--color-success)',
+ icon: 'BarChart3',
+ description: 'Track multiple evaluation metrics during training',
+ framework: BackendFramework.PyTorch
+ }
+
+ readonly configSchema: ConfigField[] = [
+ {
+ name: 'task_type',
+ label: 'Task Type',
+ type: 'select',
+ default: 'binary_classification',
+ required: true,
+ options: [
+ { value: 'binary_classification', label: 'Binary Classification' },
+ { value: 'multiclass_classification', label: 'Multiclass Classification' },
+ { value: 'multilabel_classification', label: 'Multilabel Classification' },
+ { value: 'regression', label: 'Regression' }
+ ],
+ description: 'Type of task for metric selection'
+ },
+ {
+ name: 'metrics',
+ label: 'Metrics',
+ type: 'multiselect',
+ default: ['accuracy'],
+ required: true,
+ options: [
+ // Classification metrics
+ { value: 'accuracy', label: 'Accuracy' },
+ { value: 'precision', label: 'Precision' },
+ { value: 'recall', label: 'Recall' },
+ { value: 'f1', label: 'F1 Score' },
+ { value: 'specificity', label: 'Specificity' },
+ { value: 'auroc', label: 'AUROC' },
+ { value: 'auprc', label: 'AUPRC' },
+ // Regression metrics
+ { value: 'mse', label: 'Mean Squared Error' },
+ { value: 'mae', label: 'Mean Absolute Error' },
+ { value: 'rmse', label: 'Root Mean Squared Error' },
+ { value: 'r2', label: 'R² Score' }
+ ],
+ description: 'Select one or more metrics to track during training'
+ },
+ {
+ name: 'num_classes',
+ label: 'Number of Classes',
+ type: 'number',
+ default: 2,
+ min: 2,
+ description: 'Required for multiclass classification'
+ },
+ {
+ name: 'average',
+ label: 'Averaging Method',
+ type: 'select',
+ default: 'macro',
+ options: [
+ { value: 'macro', label: 'Macro' },
+ { value: 'micro', label: 'Micro' },
+ { value: 'weighted', label: 'Weighted' },
+ { value: 'none', label: 'None' }
+ ],
+ description: 'Averaging method for multi-class metrics'
+ }
+ ]
+
+ /**
+ * Get input ports based on task type
+ */
+ getInputPorts(config: BlockConfig): PortDefinition[] {
+ return [
+ {
+ id: 'metrics-input-predictions',
+ label: 'Predictions',
+ type: 'input',
+ semantic: 'predictions',
+ required: true,
+ description: 'Model predictions'
+ },
+ {
+ id: 'metrics-input-targets',
+ label: 'Targets',
+ type: 'input',
+ semantic: 'labels',
+ required: true,
+ description: 'Ground truth targets'
+ }
+ ]
+ }
+
+ /**
+ * Metrics nodes are terminal nodes - they don't have output ports
+ */
+ getOutputPorts(config: BlockConfig): PortDefinition[] {
+ return []
+ }
+
+ /**
+ * Metrics node accepts multiple inputs
+ */
+ allowsMultipleInputs(): boolean {
+ return true
+ }
+
+ computeOutputShape(inputShape: TensorShape | undefined, config: BlockConfig): TensorShape | undefined {
+ return { dims: [1], description: 'Metric value' }
+ }
+
+ validateIncomingConnection(
+ sourceNodeType: BlockType,
+ sourceOutputShape: TensorShape | undefined,
+ targetConfig: BlockConfig
+ ): string | undefined {
+ // Metrics node accepts any input shape
+ return undefined
+ }
+
+ validateConfig(config: BlockConfig): string[] {
+ const errors = super.validateConfig(config)
+
+ // Validate metrics array
+ const metrics = config.metrics
+ if (!Array.isArray(metrics)) {
+ errors.push('At least one metric is required')
+ } else if (metrics.length === 0) {
+ errors.push('At least one metric is required')
+ }
+
+ // Validate num_classes for multiclass tasks
+ const taskType = config.task_type || 'binary_classification'
+ if (taskType === 'multiclass_classification') {
+ const numClasses = config.num_classes
+ if (numClasses === undefined || numClasses < 2) {
+ errors.push('Number of classes must be >= 2 for multiclass classification')
+ }
+ }
+
+ return errors
+ }
+}
diff --git a/project/frontend/src/lib/nodes/definitions/tensorflow/index.ts b/project/frontend/src/lib/nodes/definitions/tensorflow/index.ts
index b31559c..3496b53 100644
--- a/project/frontend/src/lib/nodes/definitions/tensorflow/index.ts
+++ b/project/frontend/src/lib/nodes/definitions/tensorflow/index.ts
@@ -8,8 +8,10 @@
export { InputNode } from '../pytorch/input'
export { DataLoaderNode } from '../pytorch/dataloader'
+export { GroundTruthNode } from '../pytorch/groundtruth'
export { OutputNode } from '../pytorch/output'
export { LossNode } from '../pytorch/loss'
+export { MetricsNode } from './metrics'
export { EmptyNode } from '../pytorch/empty'
export { LinearNode } from '../pytorch/linear'
export { Conv2DNode } from '../pytorch/conv2d'
diff --git a/project/frontend/src/lib/nodes/definitions/tensorflow/metrics.ts b/project/frontend/src/lib/nodes/definitions/tensorflow/metrics.ts
new file mode 100644
index 0000000..71fa0e6
--- /dev/null
+++ b/project/frontend/src/lib/nodes/definitions/tensorflow/metrics.ts
@@ -0,0 +1,151 @@
+/**
+ * TensorFlow Metrics Node Definition
+ */
+
+import { NodeDefinition } from '../../base'
+import { NodeMetadata, BackendFramework } from '../../contracts'
+import { TensorShape, BlockConfig, ConfigField, BlockType } from '../../../types'
+import { PortDefinition } from '../../ports'
+
+export class MetricsNode extends NodeDefinition {
+ readonly metadata: NodeMetadata = {
+ type: 'metrics',
+ label: 'Metrics',
+ category: 'output',
+ color: 'var(--color-success)',
+ icon: 'BarChart3',
+ description: 'Track multiple evaluation metrics during training',
+ framework: BackendFramework.TensorFlow
+ }
+
+ readonly configSchema: ConfigField[] = [
+ {
+ name: 'task_type',
+ label: 'Task Type',
+ type: 'select',
+ default: 'binary_classification',
+ required: true,
+ options: [
+ { value: 'binary_classification', label: 'Binary Classification' },
+ { value: 'multiclass_classification', label: 'Multiclass Classification' },
+ { value: 'multilabel_classification', label: 'Multilabel Classification' },
+ { value: 'regression', label: 'Regression' }
+ ],
+ description: 'Type of task for metric selection'
+ },
+ {
+ name: 'metrics',
+ label: 'Metrics',
+ type: 'multiselect',
+ default: ['accuracy'],
+ required: true,
+ options: [
+ // Classification metrics
+ { value: 'accuracy', label: 'Accuracy' },
+ { value: 'precision', label: 'Precision' },
+ { value: 'recall', label: 'Recall' },
+ { value: 'f1', label: 'F1 Score' },
+ // Regression metrics
+ { value: 'mse', label: 'Mean Squared Error' },
+ { value: 'mae', label: 'Mean Absolute Error' },
+ { value: 'rmse', label: 'Root Mean Squared Error' }
+ ],
+ description: 'Select one or more metrics to track during training'
+ },
+ {
+ name: 'num_classes',
+ label: 'Number of Classes',
+ type: 'number',
+ default: 2,
+ min: 2,
+ description: 'Required for multiclass classification'
+ },
+ {
+ name: 'average',
+ label: 'Averaging Method',
+ type: 'select',
+ default: 'macro',
+ options: [
+ { value: 'macro', label: 'Macro' },
+ { value: 'micro', label: 'Micro' },
+ { value: 'weighted', label: 'Weighted' },
+ { value: 'none', label: 'None' }
+ ],
+ description: 'Averaging method for multi-class metrics'
+ }
+ ]
+
+ /**
+ * Get input ports based on task type
+ */
+ getInputPorts(config: BlockConfig): PortDefinition[] {
+ return [
+ {
+ id: 'metrics-input-predictions',
+ label: 'Predictions',
+ type: 'input',
+ semantic: 'predictions',
+ required: true,
+ description: 'Model predictions'
+ },
+ {
+ id: 'metrics-input-targets',
+ label: 'Targets',
+ type: 'input',
+ semantic: 'labels',
+ required: true,
+ description: 'Ground truth targets'
+ }
+ ]
+ }
+
+ /**
+ * Metrics nodes are terminal nodes - they don't have output ports
+ */
+ getOutputPorts(config: BlockConfig): PortDefinition[] {
+ return []
+ }
+
+ /**
+ * Metrics node accepts multiple inputs
+ */
+ allowsMultipleInputs(): boolean {
+ return true
+ }
+
+ computeOutputShape(inputShape: TensorShape | undefined, config: BlockConfig): TensorShape | undefined {
+ return { dims: [1], description: 'Metric value' }
+ }
+
+ validateIncomingConnection(
+ sourceNodeType: BlockType,
+ sourceOutputShape: TensorShape | undefined,
+ targetConfig: BlockConfig
+ ): string | undefined {
+ // Metrics node accepts any input shape
+ return undefined
+ }
+
+ validateConfig(config: BlockConfig): string[] {
+ const errors = super.validateConfig(config)
+
+ // Validate metrics array
+ const metrics = config.metrics
+ if (!Array.isArray(metrics)) {
+ errors.push('At least one metric is required')
+ } else if (metrics.length === 0) {
+ errors.push('At least one metric is required')
+ }
+
+ // Validate num_classes for multiclass tasks
+ const taskType = config.task_type || 'binary_classification'
+ if (taskType === 'multiclass_classification') {
+ const numClasses = config.num_classes
+ if (numClasses === undefined || numClasses < 2) {
+ errors.push('Number of classes must be >= 2 for multiclass classification')
+ }
+ }
+
+ return errors
+ }
+}
diff --git a/project/frontend/src/lib/store.ts b/project/frontend/src/lib/store.ts
index 1e364e9..6e71236 100644
--- a/project/frontend/src/lib/store.ts
+++ b/project/frontend/src/lib/store.ts
@@ -142,14 +142,51 @@ export const useModelBuilderStore = create
((set, get) => ({
updateNode: (id, data) => {
const state = get()
const historyUpdate = saveHistory(state)
-
+
+ // Update node and immediately recompute output shape if config changed
set((state) => ({
- nodes: state.nodes.map((node) =>
- node.id === id ? { ...node, data: { ...node.data, ...data } } : node
- ),
+ nodes: state.nodes.map((node) => {
+ if (node.id === id) {
+ const updatedData = { ...node.data, ...data }
+
+ // If config changed, recompute output shape
+ if (data.config) {
+ const nodeDef = getNodeDefinition(
+ node.data.blockType as BlockType,
+ BackendFramework.PyTorch
+ )
+
+ if (nodeDef) {
+ // Pure source nodes (dataloader, groundtruth) compute from config alone
+ if (node.data.blockType === 'dataloader' ||
+ node.data.blockType === 'groundtruth') {
+ updatedData.outputShape = nodeDef.computeOutputShape(undefined, updatedData.config)
+ }
+ // Input nodes: passthrough if has input, otherwise from config
+ else if (node.data.blockType === 'input') {
+ if (updatedData.inputShape) {
+ // Connected to DataLoader: passthrough (output = input)
+ updatedData.outputShape = nodeDef.computeOutputShape(updatedData.inputShape, updatedData.config)
+ } else {
+ // Not connected: act as source
+ updatedData.outputShape = nodeDef.computeOutputShape(undefined, updatedData.config)
+ }
+ }
+ // Transform nodes: use current input shape
+ else if (updatedData.inputShape) {
+ updatedData.outputShape = nodeDef.computeOutputShape(updatedData.inputShape, updatedData.config)
+ }
+ }
+ }
+
+ return { ...node, data: updatedData }
+ }
+ return node
+ }),
...historyUpdate
}))
-
+
+ // Propagate changes downstream
get().inferDimensions()
},
@@ -223,21 +260,29 @@ export const useModelBuilderStore = create((set, get) => ({
set({ nodes: updatedNodes })
}
- if (!targetNode.data.inputShape) {
- const updatedNodes = nodes.map((node) => {
- if (node.id === targetNode.id) {
- return {
- ...node,
- data: {
- ...node.data,
- inputShape: sourceShape
- }
+ // Update input shape and immediately recompute output shape
+ const updatedNodes = nodes.map((node) => {
+ if (node.id === targetNode.id) {
+ const newInputShape = sourceShape
+ let newOutputShape = node.data.outputShape
+
+ // Recompute output shape based on new input and current config
+ if (targetNodeDef) {
+ newOutputShape = targetNodeDef.computeOutputShape(newInputShape, node.data.config)
+ }
+
+ return {
+ ...node,
+ data: {
+ ...node.data,
+ inputShape: newInputShape,
+ outputShape: newOutputShape
}
}
- return node
- })
- set({ nodes: updatedNodes })
- }
+ }
+ return node
+ })
+ set({ nodes: updatedNodes })
}
setTimeout(() => get().inferDimensions(), 0)
@@ -430,7 +475,7 @@ export const useModelBuilderStore = create((set, get) => ({
}
// Check if target allows multiple inputs (for backwards compatibility)
- const allowsMultiple = targetNode.data.blockType === 'concat' || targetNode.data.blockType === 'add' || targetNode.data.blockType === 'loss'
+ const allowsMultiple = targetNode.data.blockType === 'concat' || targetNode.data.blockType === 'add' || targetNode.data.blockType === 'loss' || targetNode.data.blockType === 'metrics'
if (!allowsMultiple) {
const hasExistingInput = edges.some((e) => e.target === connection.target)
if (hasExistingInput) return false
@@ -483,16 +528,25 @@ export const useModelBuilderStore = create((set, get) => ({
nodes.forEach((node) => {
const hasInput = edges.some((e) => e.target === node.id)
const hasOutput = edges.some((e) => e.source === node.id)
-
- if (!hasInput && node.data.blockType !== 'input') {
+
+ // Source nodes (input, dataloader, groundtruth) are SUPPOSED to have no input connections
+ const isSourceNode = node.data.blockType === 'input' ||
+ node.data.blockType === 'dataloader' ||
+ node.data.blockType === 'groundtruth'
+
+ if (!hasInput && !isSourceNode) {
errors.push({
nodeId: node.id,
message: `Block "${node.data.label}" has no input connection`,
type: 'warning'
})
}
-
- if (!hasOutput && node.data.blockType !== 'output' && node.data.blockType !== 'loss') {
+
+ // Terminal nodes (output, loss) are SUPPOSED to have no output connections
+ const isTerminalNode = node.data.blockType === 'output' ||
+ node.data.blockType === 'loss'
+
+ if (!hasOutput && !isTerminalNode) {
errors.push({
nodeId: node.id,
message: `Block "${node.data.label}" has no output connection`,
@@ -652,13 +706,31 @@ export const useModelBuilderStore = create((set, get) => ({
} else {
// Regular node processing
let nodeDef = getNodeDefinition(node.data.blockType, BackendFramework.PyTorch)
-
- if (node.data.blockType === 'input') {
+
+ // Pure source nodes (dataloader, groundtruth) compute shape from config
+ if (node.data.blockType === 'dataloader' || node.data.blockType === 'groundtruth') {
if (nodeDef) {
- // Use new registry method
+ // Source nodes don't need inputShape - compute from config alone
const outputShape = nodeDef.computeOutputShape(undefined, node.data.config)
node.data.outputShape = outputShape
}
+ }
+ // Input nodes: passthrough if connected, otherwise use config
+ else if (node.data.blockType === 'input') {
+ if (nodeDef) {
+ if (incomingEdges.length > 0) {
+ // Passthrough: output = input from connected DataLoader
+ const sourceNode = nodeMap.get(incomingEdges[0].source)
+ if (sourceNode?.data.outputShape) {
+ node.data.inputShape = sourceNode.data.outputShape
+ node.data.outputShape = nodeDef.computeOutputShape(node.data.inputShape, node.data.config)
+ }
+ } else {
+ // No incoming edges: compute from config (acts as source)
+ const outputShape = nodeDef.computeOutputShape(undefined, node.data.config)
+ node.data.outputShape = outputShape
+ }
+ }
} else {
if (incomingEdges.length > 0) {
// Special handling for merge nodes (concat, add) with multiple inputs
@@ -710,8 +782,20 @@ export const useModelBuilderStore = create((set, get) => ({
outgoingEdges.forEach((e) => processNode(e.target))
}
- const inputNodes = updatedNodes.filter((n) => n.data.blockType === 'input')
- inputNodes.forEach((node) => processNode(node.id))
+ // Start from all source nodes
+ // - DataLoader and GroundTruth are always sources
+ // - Input nodes are only sources if they have no incoming edges (not connected to DataLoader)
+ const sourceNodes = updatedNodes.filter((n) => {
+ if (n.data.blockType === 'dataloader' || n.data.blockType === 'groundtruth') {
+ return true
+ }
+ if (n.data.blockType === 'input') {
+ // Input is a source only if it has no incoming edges
+ return getIncomingEdges(n.id).length === 0
+ }
+ return false
+ })
+ sourceNodes.forEach((node) => processNode(node.id))
set({ nodes: updatedNodes })
},
diff --git a/project/frontend/src/lib/types.ts b/project/frontend/src/lib/types.ts
index ddf6736..42a30d8 100644
--- a/project/frontend/src/lib/types.ts
+++ b/project/frontend/src/lib/types.ts
@@ -4,6 +4,7 @@ import type { PortSemantic } from './nodes/ports'
export type BlockType =
| 'input'
| 'dataloader'
+ | 'groundtruth'
| 'output'
| 'loss'
| 'empty'