feat: add ABM pipeline contracts and validation

This commit is contained in:
jerryW123
2026-05-11 19:55:38 +08:00
parent 1a0fa80d01
commit 0bb7b46360
4 changed files with 1595 additions and 145 deletions
+12 -4
View File
@@ -3,7 +3,11 @@ import { Link, useLocation } from 'react-router-dom';
import { LayoutGrid, Home, Edit3, Activity, ChevronDown, Globe } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
const ModuleSwitcher: React.FC = () => {
interface ModuleSwitcherProps {
variant?: 'light' | 'dark';
}
const ModuleSwitcher: React.FC<ModuleSwitcherProps> = ({ variant = 'light' }) => {
const [isOpen, setIsOpen] = useState(false);
const location = useLocation();
@@ -20,11 +24,15 @@ const ModuleSwitcher: React.FC = () => {
<div className="relative z-[100]">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-1.5 bg-white hover:bg-slate-50 rounded-xl border border-slate-200 shadow-sm transition-all text-slate-800 active:scale-95"
className={`flex items-center gap-2 px-3 py-1.5 rounded-xl border shadow-sm transition-all active:scale-95 ${
variant === 'dark'
? 'bg-blue-600 hover:bg-blue-500 border-blue-500 text-white shadow-blue-950/30'
: 'bg-white hover:bg-slate-50 border-slate-200 text-slate-800'
}`}
>
<LayoutGrid className="w-4 h-4 text-blue-600" />
<LayoutGrid className={`w-4 h-4 ${variant === 'dark' ? 'text-white' : 'text-blue-600'}`} />
<span className="text-sm font-black tracking-tight">{currentModule.name}</span>
<ChevronDown className={`w-3 h-3 text-slate-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
<ChevronDown className={`w-3 h-3 transition-transform ${variant === 'dark' ? 'text-blue-100' : 'text-slate-400'} ${isOpen ? 'rotate-180' : ''}`} />
</button>
<AnimatePresence>
+1049 -140
View File
File diff suppressed because it is too large Load Diff
+101 -1
View File
@@ -1,5 +1,16 @@
import assert from 'node:assert/strict';
import { createDefaultWorkflow, getBehaviorSectionsForNode, getNodeConnectionAnchor, getParametersForNode } from './workflowModel';
import {
buildRuntimeConfig,
createDefaultWorkflow,
ensureValidationChecklist,
getBehaviorSectionsForNode,
getInputOutputsForEdge,
getInputOutputsForNode,
getNodeConnectionAnchor,
getNodeStatusFromValidation,
getParametersForNode,
getValidationsForNode,
} from './workflowModel';
const baseWorkflow = createDefaultWorkflow();
@@ -10,6 +21,44 @@ assert.deepEqual(
assert.equal(baseWorkflow.edges.length, 1);
assert.equal(baseWorkflow.schedules.length, 3);
assert.equal(baseWorkflow.schedules[0].id, 'S001');
assert.equal(baseWorkflow.schedules[0].actorAgent, 'Environment');
assert.equal(baseWorkflow.associatedData.length, 3);
assert.equal(baseWorkflow.associatedData[0].sourceTable, 'model_initial_state');
assert.equal(baseWorkflow.inputOutputs.length, 3);
assert.equal(baseWorkflow.inputOutputs[0].snapshotPolicy, 'run_start');
assert.equal(baseWorkflow.validations.length, 3);
assert.equal(baseWorkflow.validations[0].severity, 'error');
const runtimeConfig = buildRuntimeConfig(baseWorkflow);
assert.equal(runtimeConfig.runtime.version, 'workflow-runtime-v1');
assert.equal(runtimeConfig.runtime.ioContracts.length, 3);
assert.deepEqual(runtimeConfig.runtime.ioContracts[0], {
id: 'IO001',
direction: 'input',
name: 'self.position',
dataType: 'Coordinate',
schema: '{ x: number, y: number }',
source: {
component: 'Grid Space',
table: 'space_cell_map',
},
target: {
component: 'Example Agent',
table: '-',
},
required: true,
defaultValue: null,
snapshotPolicy: 'run_start',
});
assert.equal(runtimeConfig.runtime.schedule[0].id, 'S001');
assert.equal(runtimeConfig.runtime.validation[0].severity, 'error');
const gridWorkflow = createDefaultWorkflow('grid');
assert.deepEqual(
@@ -19,6 +68,57 @@ assert.deepEqual(
assert.equal(gridWorkflow.edges.length, 2);
const spaceToAgentContracts = getInputOutputsForEdge(gridWorkflow, 'e2');
assert.deepEqual(
spaceToAgentContracts.map((row) => row.ioName),
['self.position'],
);
const agentContracts = getInputOutputsForNode(gridWorkflow, 'example-agent');
assert.deepEqual(
agentContracts.map((row) => row.ioName),
['self.position', 'neighbor.race', 'self.happiness'],
);
assert.equal(getNodeStatusFromValidation(gridWorkflow, 'grid-space'), 'error');
assert.equal(getNodeStatusFromValidation(gridWorkflow, 'example-agent'), 'ready');
const gridValidations = getValidationsForNode(gridWorkflow, 'grid-space');
assert.deepEqual(
gridValidations.map((row) => row.id),
['V001'],
);
const generatedWorkflow = ensureValidationChecklist({
...gridWorkflow,
nodes: [
...gridWorkflow.nodes,
{
id: 'ai-output',
title: 'AI Output',
subtitle: 'Generated report output',
kind: 'output',
x: 360,
y: 120,
status: 'ready',
inputs: [{ id: 'report', label: 'report input', type: 'REPORT' }],
outputs: [],
config: [],
},
],
validations: gridWorkflow.validations,
});
const generatedNodeValidations = getValidationsForNode(generatedWorkflow, 'ai-output');
assert.deepEqual(
generatedNodeValidations.map((row) => row.method),
['node_config_present', 'io_contract_coverage'],
);
const parameterRows = getParametersForNode(gridWorkflow, 'example-agent');
assert.equal(parameterRows.length, 3);
+433
View File
@@ -67,11 +67,82 @@ export interface BehaviorSection {
items: Array<{ label: string; source: string; value: string }>;
}
export interface ScheduleRow {
id: string;
order: string;
phase: string;
actorAgent: string;
targetBehavior: string;
trigger: string;
executionMode: string;
timeUnit: string;
condition: string;
repeatRule: string;
dependsOn: string;
writesTo: string;
notes: string;
}
export interface AssociatedDataRow {
id: string;
name: string;
type: string;
sourceType: string;
sourceName: string;
sourceTable: string;
sourceColumn: string;
ownerAgent: string;
relatedVariable: string;
usedByBehavior: string;
updateFrequency: string;
versionKey: string;
qualityStatus: 'ok' | 'warning' | 'error';
}
export interface InputOutputRow {
id: string;
componentType: string;
componentId: string;
componentName: string;
direction: 'input' | 'output';
ioName: string;
ioDataType: string;
schema: string;
sourceComponent: string;
sourceTable: string;
targetComponent: string;
targetTable: string;
required: string;
defaultValue: string;
snapshotPolicy: string;
}
export interface ValidationRow {
id: string;
targetType: string;
targetId: string;
targetName: string;
validationType: string;
method: string;
metric: string;
baselineData: string;
threshold: string;
frequency: string;
resultField: string;
severity: 'error' | 'warning' | 'info';
status: 'ok' | 'warning' | 'error';
notes: string;
}
export interface WorkflowModel {
nodes: WorkflowNode[];
edges: WorkflowEdge[];
parameters: WorkflowParameter[];
behaviorSections: BehaviorSection[];
schedules: ScheduleRow[];
associatedData: AssociatedDataRow[];
inputOutputs: InputOutputRow[];
validations: ValidationRow[];
}
export type InitialSpaceType = 'grid' | 'network' | 'continuous' | 'gis';
@@ -308,6 +379,203 @@ export function createDefaultWorkflow(spaceType?: InitialSpaceType): WorkflowMod
],
},
],
schedules: [
{
id: 'S001',
order: '1',
phase: 'initialize',
actorAgent: 'Environment',
targetBehavior: 'create_context',
trigger: 'model_start',
executionMode: 'sync',
timeUnit: 'tick',
condition: 'always',
repeatRule: 'once',
dependsOn: '-',
writesTo: 'ENV_CONTEXT',
notes: 'Create shared model context',
},
{
id: 'S002',
order: '2',
phase: 'initialize',
actorAgent: 'Grid Space',
targetBehavior: 'allocate_cells',
trigger: 'space_ready',
executionMode: 'sync',
timeUnit: 'tick',
condition: 'environment_ready',
repeatRule: 'once',
dependsOn: 'S001',
writesTo: 'SPACE_CONTEXT',
notes: 'Initialize spatial container',
},
{
id: 'S003',
order: '3',
phase: 'daily',
actorAgent: 'Example Agent',
targetBehavior: 'on_sensing',
trigger: 'every_tick',
executionMode: 'sync',
timeUnit: 'day',
condition: 'neighbor_radius valid',
repeatRule: 'every tick',
dependsOn: 'S002',
writesTo: 'happiness',
notes: 'Observe neighbors and update agent state',
},
],
associatedData: [
{
id: 'D001',
name: 'agent_group_seed',
type: 'parameter',
sourceType: 'initialization',
sourceName: 'default workflow',
sourceTable: 'model_initial_state',
sourceColumn: 'race',
ownerAgent: 'Example Agent',
relatedVariable: 'race',
usedByBehavior: 'on_sensing',
updateFrequency: 'model start',
versionKey: 'workflow_v3',
qualityStatus: 'ok',
},
{
id: 'D002',
name: 'grid_position_map',
type: 'derived_data',
sourceType: 'space connection',
sourceName: 'Grid Space',
sourceTable: 'space_cell_map',
sourceColumn: 'x,y',
ownerAgent: 'Example Agent',
relatedVariable: 'position',
usedByBehavior: 'on_sensing',
updateFrequency: 'every tick',
versionKey: 'space_config_v1',
qualityStatus: 'ok',
},
{
id: 'D003',
name: 'neighbor_lookup_radius',
type: 'space_config',
sourceType: 'manual config',
sourceName: 'Grid Space',
sourceTable: 'space_parameters',
sourceColumn: 'neighbor_radius',
ownerAgent: 'Grid Space',
relatedVariable: 'neighbor_radius',
usedByBehavior: 'on_sensing',
updateFrequency: 'model start',
versionKey: 'missing',
qualityStatus: 'error',
},
],
inputOutputs: [
{
id: 'IO001',
componentType: 'Behavior',
componentId: 'on_sensing',
componentName: 'on_sensing',
direction: 'input',
ioName: 'self.position',
ioDataType: 'Coordinate',
schema: '{ x: number, y: number }',
sourceComponent: 'Grid Space',
sourceTable: 'space_cell_map',
targetComponent: 'Example Agent',
targetTable: '-',
required: 'true',
defaultValue: '-',
snapshotPolicy: 'run_start',
},
{
id: 'IO002',
componentType: 'Behavior',
componentId: 'on_sensing',
componentName: 'on_sensing',
direction: 'input',
ioName: 'neighbor.race',
ioDataType: 'String',
schema: 'A | B',
sourceComponent: 'Observable pool',
sourceTable: 'agent_state',
targetComponent: 'Example Agent',
targetTable: '-',
required: 'true',
defaultValue: '-',
snapshotPolicy: 'each_tick',
},
{
id: 'IO003',
componentType: 'Behavior',
componentId: 'on_sensing',
componentName: 'on_sensing',
direction: 'output',
ioName: 'self.happiness',
ioDataType: 'Boolean',
schema: 'true | false',
sourceComponent: 'on_sensing',
sourceTable: '-',
targetComponent: 'Example Agent',
targetTable: 'agent_state',
required: 'true',
defaultValue: 'false',
snapshotPolicy: 'each_tick',
},
],
validations: [
{
id: 'V001',
targetType: 'Variable',
targetId: 's2',
targetName: 'neighbor_radius',
validationType: 'schema_check',
method: 'required_value',
metric: 'missing_count',
baselineData: 'space_parameters.neighbor_radius',
threshold: '= 0',
frequency: 'model start',
resultField: 'validation.required_missing',
severity: 'error',
status: 'error',
notes: 'Required for on_sensing neighborhood lookup',
},
{
id: 'V002',
targetType: 'Behavior',
targetId: 'on_sensing',
targetName: 'on_sensing',
validationType: 'rule_check',
method: 'output_assignment',
metric: 'assigned',
baselineData: 'self.happiness',
threshold: 'true',
frequency: 'every tick',
resultField: 'behavior.output_valid',
severity: 'error',
status: 'ok',
notes: 'Behavior must write a Boolean result to the agent state',
},
{
id: 'V003',
targetType: 'Workflow',
targetId: 'workflow_v3',
targetName: 'workflow',
validationType: 'reproducibility',
method: 'input_snapshot',
metric: 'coverage',
baselineData: 'all required I/O rows',
threshold: '100%',
frequency: 'before run',
resultField: 'run.snapshot_coverage',
severity: 'warning',
status: 'warning',
notes: 'All required inputs should be captured for reproduction',
},
],
};
}
@@ -319,6 +587,171 @@ export function getBehaviorSectionsForNode(workflow: WorkflowModel, nodeId: stri
return workflow.behaviorSections.filter((section) => section.nodeId === nodeId);
}
export function getInputOutputsForEdge(workflow: WorkflowModel, edgeId: string) {
const edge = workflow.edges.find((workflowEdge) => workflowEdge.id === edgeId);
if (!edge) return [];
const sourceNode = workflow.nodes.find((node) => node.id === edge.source);
const targetNode = workflow.nodes.find((node) => node.id === edge.target);
if (!sourceNode || !targetNode) return [];
return workflow.inputOutputs.filter((row) => (
row.sourceComponent === sourceNode.title
&& row.targetComponent === targetNode.title
));
}
export function getInputOutputsForNode(workflow: WorkflowModel, nodeId: string) {
const node = workflow.nodes.find((workflowNode) => workflowNode.id === nodeId);
if (!node) return [];
return workflow.inputOutputs.filter((row) => (
row.sourceComponent === node.title
|| row.targetComponent === node.title
));
}
function validationBelongsToNode(workflow: WorkflowModel, validation: ValidationRow, node: WorkflowNode) {
if (validation.targetId === node.id || validation.targetName === node.title) return true;
if (validation.targetType === 'Variable') {
return workflow.parameters.some((parameter) => (
parameter.nodeId === node.id
&& (parameter.id === validation.targetId || parameter.name === validation.targetName)
));
}
if (validation.targetType === 'Behavior') {
return workflow.behaviorSections.some((section) => (
section.nodeId === node.id
&& (section.id === validation.targetId || section.title === validation.targetName || validation.targetId === 'on_sensing')
));
}
return false;
}
export function getValidationsForNode(workflow: WorkflowModel, nodeId: string) {
const node = workflow.nodes.find((workflowNode) => workflowNode.id === nodeId);
if (!node) return [];
return workflow.validations.filter((validation) => validationBelongsToNode(workflow, validation, node));
}
function createNodeValidationChecklist(workflow: WorkflowModel, node: WorkflowNode): ValidationRow[] {
const hasIoContract = workflow.inputOutputs.some((row) => (
row.sourceComponent === node.title
|| row.targetComponent === node.title
));
return [
{
id: `AUTO-${node.id}-CONFIG`,
targetType: 'Node',
targetId: node.id,
targetName: node.title,
validationType: 'schema_check',
method: 'node_config_present',
metric: 'config_items',
baselineData: `${node.id}.config`,
threshold: '> 0',
frequency: 'before run',
resultField: `validation.${node.id}.config_present`,
severity: 'warning',
status: node.config.length > 0 ? 'ok' : 'warning',
notes: 'Generated checklist item: node should have explicit configuration before running.',
},
{
id: `AUTO-${node.id}-IO`,
targetType: 'Node',
targetId: node.id,
targetName: node.title,
validationType: 'contract_check',
method: 'io_contract_coverage',
metric: 'io_contract_count',
baselineData: 'inputOutputs',
threshold: node.inputs.length || node.outputs.length ? '> 0' : '>= 0',
frequency: 'before run',
resultField: `validation.${node.id}.io_contract_coverage`,
severity: 'warning',
status: hasIoContract || (!node.inputs.length && !node.outputs.length) ? 'ok' : 'warning',
notes: 'Generated checklist item: node ports should be represented by I/O contract rows.',
},
];
}
export function ensureValidationChecklist(workflow: WorkflowModel): WorkflowModel {
const existingIds = new Set(workflow.validations.map((validation) => validation.id));
const generatedValidations = workflow.nodes.flatMap((node) => (
createNodeValidationChecklist(workflow, node).filter((validation) => !existingIds.has(validation.id))
));
if (!generatedValidations.length) return workflow;
return {
...workflow,
validations: [...workflow.validations, ...generatedValidations],
};
}
export function getNodeStatusFromValidation(workflow: WorkflowModel, nodeId: string): WorkflowNodeStatus {
const node = workflow.nodes.find((workflowNode) => workflowNode.id === nodeId);
if (!node) return 'ready';
const relatedValidations = getValidationsForNode(workflow, node.id);
if (relatedValidations.some((validation) => validation.status === 'error')) return 'error';
if (relatedValidations.some((validation) => validation.status === 'warning')) return 'warning';
return node.status === 'success' ? 'success' : 'ready';
}
export function buildRuntimeConfig(workflow: WorkflowModel) {
const workflowWithChecklist = ensureValidationChecklist(workflow);
return {
runtime: {
version: 'workflow-runtime-v1',
model: {
nodes: workflowWithChecklist.nodes.map((node) => ({
id: node.id,
kind: node.kind,
title: node.title,
status: node.status,
})),
edges: workflowWithChecklist.edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
type: edge.type,
label: edge.label,
})),
},
ioContracts: workflowWithChecklist.inputOutputs.map((row) => ({
id: row.id,
direction: row.direction,
name: row.ioName,
dataType: row.ioDataType,
schema: row.schema,
source: {
component: row.sourceComponent,
table: row.sourceTable,
},
target: {
component: row.targetComponent,
table: row.targetTable,
},
required: row.required === 'true',
defaultValue: row.defaultValue === '-' ? null : row.defaultValue,
snapshotPolicy: row.snapshotPolicy,
})),
schedule: workflowWithChecklist.schedules,
associatedData: workflowWithChecklist.associatedData,
validation: workflowWithChecklist.validations,
},
};
}
export const NODE_CARD_SIZE = {
width: 176,
height: 128,