-
- {node.type === 'environment' ? : }
-
- {node.id === 'env' ? 'Environment_Space' : (node.id === 'agent' ? 'Core_Agent' : node.id.split('-')[0].toUpperCase())}
-
-
-
-
diff --git a/src/workflowModel.test.ts b/src/workflowModel.test.ts
new file mode 100644
index 0000000..26ce39a
--- /dev/null
+++ b/src/workflowModel.test.ts
@@ -0,0 +1,41 @@
+import assert from 'node:assert/strict';
+import { createDefaultWorkflow, getBehaviorSectionsForNode, getNodeConnectionAnchor, getParametersForNode } from './workflowModel';
+
+const baseWorkflow = createDefaultWorkflow();
+
+assert.deepEqual(
+ baseWorkflow.nodes.map((node) => node.kind),
+ ['environment', 'agent'],
+);
+
+assert.equal(baseWorkflow.edges.length, 1);
+
+const gridWorkflow = createDefaultWorkflow('grid');
+
+assert.deepEqual(
+ gridWorkflow.nodes.map((node) => node.kind),
+ ['environment', 'space', 'agent'],
+);
+
+assert.equal(gridWorkflow.edges.length, 2);
+
+const parameterRows = getParametersForNode(gridWorkflow, 'example-agent');
+
+assert.equal(parameterRows.length, 3);
+assert.equal(parameterRows[0].name, 'race');
+assert.equal(parameterRows[0].scope, 'endogenous');
+
+const behaviorSections = getBehaviorSectionsForNode(gridWorkflow, 'example-agent');
+
+assert.deepEqual(
+ behaviorSections.map((section) => section.kind),
+ ['observable_pool', 'input', 'calculation', 'output'],
+);
+
+const sourceAnchor = getNodeConnectionAnchor({ x: 10, y: 20, side: 'right' });
+const targetAnchor = getNodeConnectionAnchor({ x: 220, y: 20, side: 'left' });
+
+assert.deepEqual(sourceAnchor, { x: 186, y: 84 });
+assert.deepEqual(targetAnchor, { x: 220, y: 84 });
+
+console.log('workflowModel tests passed');
diff --git a/src/workflowModel.ts b/src/workflowModel.ts
new file mode 100644
index 0000000..7236b20
--- /dev/null
+++ b/src/workflowModel.ts
@@ -0,0 +1,340 @@
+export type WorkflowNodeKind =
+ | 'environment'
+ | 'space'
+ | 'agent'
+ | 'behavior'
+ | 'output';
+
+export type WorkflowPortType =
+ | 'ENV_CONTEXT'
+ | 'SPACE_CONTEXT'
+ | 'AGENT_CONTEXT'
+ | 'OBSERVABLE_INFO'
+ | 'BEHAVIOR_OUTPUT'
+ | 'REPORT';
+
+export type WorkflowNodeStatus = 'ready' | 'warning' | 'error' | 'success';
+
+export interface WorkflowPort {
+ id: string;
+ label: string;
+ type: WorkflowPortType;
+}
+
+export interface WorkflowNode {
+ id: string;
+ title: string;
+ subtitle: string;
+ kind: WorkflowNodeKind;
+ x: number;
+ y: number;
+ status: WorkflowNodeStatus;
+ inputs: WorkflowPort[];
+ outputs: WorkflowPort[];
+ config: Array<{ label: string; value: string }>;
+}
+
+export interface WorkflowEdge {
+ id: string;
+ source: string;
+ target: string;
+ type: WorkflowPortType;
+ label: string;
+}
+
+export type AttributeScope = 'endogenous' | 'exogenous' | 'derived';
+
+export interface WorkflowParameter {
+ id: string;
+ nodeId: string;
+ name: string;
+ type: 'Float' | 'Integer' | 'Boolean' | 'String' | 'Coordinate';
+ scope: AttributeScope;
+ source: string;
+ value: string;
+ status: 'ok' | 'warning' | 'error';
+ description: string;
+}
+
+export type BehaviorSectionKind = 'observable_pool' | 'input' | 'calculation' | 'output';
+
+export interface BehaviorSection {
+ id: string;
+ nodeId: string;
+ kind: BehaviorSectionKind;
+ title: string;
+ description: string;
+ items: Array<{ label: string; source: string; value: string }>;
+}
+
+export interface WorkflowModel {
+ nodes: WorkflowNode[];
+ edges: WorkflowEdge[];
+ parameters: WorkflowParameter[];
+ behaviorSections: BehaviorSection[];
+}
+
+export type InitialSpaceType = 'grid' | 'network' | 'continuous' | 'gis';
+
+function createSpaceNode(spaceType: InitialSpaceType): WorkflowNode {
+ const spaceConfig: Record
}> = {
+ grid: {
+ title: 'Grid Space',
+ subtitle: '40 x 40 grid for spatial interaction',
+ config: [
+ { label: 'width', value: '40' },
+ { label: 'height', value: '40' },
+ { label: 'horizontal wrap', value: 'true' },
+ { label: 'vertical wrap', value: 'true' },
+ { label: 'agent capacity/cell', value: '1' },
+ ],
+ },
+ network: {
+ title: 'Network Space',
+ subtitle: 'Nodes and edges for relationship-based interaction',
+ config: [
+ { label: 'network type', value: 'undirected' },
+ { label: 'default degree', value: '4' },
+ { label: 'rewiring', value: 'false' },
+ ],
+ },
+ continuous: {
+ title: 'Continuous Space',
+ subtitle: 'Coordinate plane for movement and distance-based sensing',
+ config: [
+ { label: 'width', value: '100' },
+ { label: 'height', value: '100' },
+ { label: 'boundary', value: 'bounded' },
+ ],
+ },
+ gis: {
+ title: 'GIS Space',
+ subtitle: 'Map-coordinate space for real geography',
+ config: [
+ { label: 'projection', value: 'WGS84' },
+ { label: 'basemap', value: 'not selected' },
+ { label: 'boundary layer', value: 'required' },
+ ],
+ },
+ };
+
+ const selected = spaceConfig[spaceType];
+
+ return {
+ id: `${spaceType}-space`,
+ title: selected.title,
+ subtitle: selected.subtitle,
+ kind: 'space',
+ x: 100,
+ y: 80,
+ status: spaceType === 'grid' ? 'warning' : 'ready',
+ inputs: [{ id: 'env', label: 'environment', type: 'ENV_CONTEXT' }],
+ outputs: [{ id: 'space', label: 'space state', type: 'SPACE_CONTEXT' }],
+ config: selected.config,
+ };
+}
+
+export function createDefaultWorkflow(spaceType?: InitialSpaceType): WorkflowModel {
+ const nodes: WorkflowNode[] = [
+ {
+ id: 'environment',
+ title: 'Environment',
+ subtitle: 'Global model container and shared context',
+ kind: 'environment',
+ x: -220,
+ y: -110,
+ status: 'ready',
+ inputs: [],
+ outputs: [{ id: 'env', label: 'environment', type: 'ENV_CONTEXT' }],
+ config: [
+ { label: 'model type', value: 'ABM + Game Model' },
+ { label: 'default agent', value: 'Example Agent' },
+ ],
+ },
+ {
+ id: 'example-agent',
+ title: 'Example Agent',
+ subtitle: 'Default agent with attributes and behaviors',
+ kind: 'agent',
+ x: 120,
+ y: -95,
+ status: 'ready',
+ inputs: [
+ { id: 'space', label: 'located in grid', type: 'SPACE_CONTEXT' },
+ { id: 'env', label: 'environment context', type: 'ENV_CONTEXT' },
+ ],
+ outputs: [{ id: 'agent', label: 'agent state', type: 'AGENT_CONTEXT' }],
+ config: [
+ { label: 'attributes', value: 'endogenous + exogenous' },
+ { label: 'behavior', value: 'on_sensing' },
+ ],
+ },
+ ];
+
+ if (spaceType) {
+ nodes.splice(1, 0, createSpaceNode(spaceType));
+ }
+
+ const edges: WorkflowEdge[] = [
+ { id: 'e1', source: 'environment', target: 'example-agent', type: 'ENV_CONTEXT', label: 'default context' },
+ ];
+
+ if (spaceType) {
+ const spaceId = `${spaceType}-space`;
+ edges.splice(
+ 0,
+ 1,
+ { id: 'e1', source: 'environment', target: spaceId, type: 'ENV_CONTEXT', label: 'contains space' },
+ { id: 'e2', source: spaceId, target: 'example-agent', type: 'SPACE_CONTEXT', label: 'locates agent' },
+ );
+ }
+
+ return {
+ nodes,
+ edges,
+ parameters: [
+ {
+ id: 'a1',
+ nodeId: 'example-agent',
+ name: 'race',
+ type: 'String',
+ scope: 'endogenous',
+ source: 'initialization',
+ value: 'A | B',
+ status: 'ok',
+ description: 'Agent group identity used by segregation behavior',
+ },
+ {
+ id: 'a2',
+ nodeId: 'example-agent',
+ name: 'position',
+ type: 'Coordinate',
+ scope: 'derived',
+ source: 'Grid Space connection',
+ value: '(x, y)',
+ status: 'ok',
+ description: 'Automatically added when agent is connected to grid space',
+ },
+ {
+ id: 'a3',
+ nodeId: 'example-agent',
+ name: 'happiness',
+ type: 'Boolean',
+ scope: 'endogenous',
+ source: 'on_sensing output',
+ value: '--',
+ status: 'warning',
+ description: 'Assigned by behavior after observing neighbors',
+ },
+ {
+ id: 's1',
+ nodeId: 'grid-space',
+ name: 'cell_capacity',
+ type: 'Integer',
+ scope: 'exogenous',
+ source: 'space config',
+ value: '1',
+ status: 'ok',
+ description: 'How many agents can occupy one grid cell',
+ },
+ {
+ id: 's2',
+ nodeId: 'grid-space',
+ name: 'neighbor_radius',
+ type: 'Integer',
+ scope: 'exogenous',
+ source: 'space config',
+ value: '--',
+ status: 'error',
+ description: 'Required for on_sensing neighborhood lookup',
+ },
+ {
+ id: 'n1',
+ nodeId: 'neighbor-agent',
+ name: 'race',
+ type: 'String',
+ scope: 'endogenous',
+ source: 'peer attribute',
+ value: 'visible',
+ status: 'ok',
+ description: 'Selectable observable attribute for Example Agent',
+ },
+ ],
+ behaviorSections: [
+ {
+ id: 'b1',
+ nodeId: 'example-agent',
+ kind: 'observable_pool',
+ title: 'Observable Information Pool',
+ description: 'All information the behavior may use, grouped by self and observed subjects.',
+ items: [
+ { label: 'self.race', source: 'Example Agent', value: 'String' },
+ { label: 'self.position', source: 'Grid Space', value: 'Coordinate' },
+ { label: 'neighbor.race', source: 'Neighbor Agent', value: 'String' },
+ { label: 'space.neighbor_count', source: 'Grid Space', value: 'Integer' },
+ ],
+ },
+ {
+ id: 'b2',
+ nodeId: 'example-agent',
+ kind: 'input',
+ title: 'Behavior Inputs',
+ description: 'Selected inputs passed into this behavior.',
+ items: [
+ { label: 'self.position', source: 'Observable pool', value: 'selected' },
+ { label: 'neighbor.race', source: 'Observable pool', value: 'selected' },
+ ],
+ },
+ {
+ id: 'b3',
+ nodeId: 'example-agent',
+ kind: 'calculation',
+ title: 'Calculation or LLM Call',
+ description: 'How inputs are transformed into a decision.',
+ items: [
+ { label: 'mode', source: 'user choice', value: 'formula' },
+ { label: 'rule', source: 'behavior editor', value: 'same_race_neighbors / total_neighbors >= threshold' },
+ ],
+ },
+ {
+ id: 'b4',
+ nodeId: 'example-agent',
+ kind: 'output',
+ title: 'Output Assignment',
+ description: 'Where the return value is saved.',
+ items: [
+ { label: 'return', source: 'calculation', value: 'Boolean' },
+ { label: 'assign to', source: 'Example Agent', value: 'self.happiness' },
+ ],
+ },
+ ],
+ };
+}
+
+export function getParametersForNode(workflow: WorkflowModel, nodeId: string) {
+ return workflow.parameters.filter((parameter) => parameter.nodeId === nodeId);
+}
+
+export function getBehaviorSectionsForNode(workflow: WorkflowModel, nodeId: string) {
+ return workflow.behaviorSections.filter((section) => section.nodeId === nodeId);
+}
+
+export const NODE_CARD_SIZE = {
+ width: 176,
+ height: 128,
+};
+
+export function getNodeConnectionAnchor({
+ x,
+ y,
+ side,
+}: {
+ x: number;
+ y: number;
+ side: 'left' | 'right';
+}) {
+ return {
+ x: side === 'left' ? x : x + NODE_CARD_SIZE.width,
+ y: y + NODE_CARD_SIZE.height / 2,
+ };
+}