Add VISA backend foundation

This commit is contained in:
jerryW123
2026-06-24 22:45:34 +08:00
parent f51f64f3bd
commit e1df743eb2
17 changed files with 1999 additions and 3 deletions
+8
View File
@@ -0,0 +1,8 @@
import { createVisaApiApp } from './visaApi';
const port = Number(process.env.PORT ?? 4000);
const app = createVisaApiApp();
app.listen(port, () => {
console.log(`VISA API listening on http://localhost:${port}`);
});
+56
View File
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { createServer } from 'node:http';
import { AddressInfo } from 'node:net';
import { createVisaApiApp } from './visaApi';
async function withTestServer<T>(run: (baseUrl: string) => Promise<T>) {
const app = createVisaApiApp();
const server = createServer(app);
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address() as AddressInfo;
const baseUrl = `http://127.0.0.1:${address.port}`;
try {
return await run(baseUrl);
} finally {
server.close();
await once(server, 'close');
}
}
await withTestServer(async (baseUrl) => {
const visaResponse = await fetch(`${baseUrl}/api/model-versions/schelling-demo-v1/visa`);
assert.equal(visaResponse.status, 200);
const visa = await visaResponse.json();
assert.equal(visa.protocol, 'VISA');
assert.equal(visa.modelVersionId, 'schelling-demo-v1');
assert.equal(visa.agents.length, 3);
const viewResponse = await fetch(`${baseUrl}/api/model-versions/schelling-demo-v1/view-model`);
assert.equal(viewResponse.status, 200);
const view = await viewResponse.json();
assert.deepEqual(
view.nodes.map((node: { id: string }) => node.id),
['environment', 'grid-space', 'example-agent'],
);
assert.equal(view.inputOutputs.length, 4);
const checkResponse = await fetch(`${baseUrl}/api/model-versions/schelling-demo-v1/actions/run-check`, {
method: 'POST',
});
assert.equal(checkResponse.status, 200);
const checkResult = await checkResponse.json();
assert.equal(checkResult.modelVersionId, 'schelling-demo-v1');
assert.ok(checkResult.summary.passed >= 3);
assert.equal(checkResult.results[0].ruleCode, 'r1');
const missingResponse = await fetch(`${baseUrl}/api/model-versions/missing-version/visa`);
assert.equal(missingResponse.status, 404);
const missing = await missingResponse.json();
assert.equal(missing.error, 'model_version_not_found');
});
console.log('visaApi tests passed');
+47
View File
@@ -0,0 +1,47 @@
import express from 'express';
import { createInMemoryVisaRepository, VisaRepository } from './visaRepository';
function notFoundResponse(modelVersionId: string) {
return {
error: 'model_version_not_found',
modelVersionId,
};
}
export function createVisaApiApp(repository: VisaRepository = createInMemoryVisaRepository()) {
const app = express();
app.use(express.json());
app.get('/api/model-versions/:id/visa', (request, response) => {
const spec = repository.getVisaSpec(request.params.id);
if (!spec) {
response.status(404).json(notFoundResponse(request.params.id));
return;
}
response.json(spec);
});
app.get('/api/model-versions/:id/view-model', (request, response) => {
const viewModel = repository.getWorkflowViewModel(request.params.id);
if (!viewModel) {
response.status(404).json(notFoundResponse(request.params.id));
return;
}
response.json(viewModel);
});
app.post('/api/model-versions/:id/actions/run-check', (request, response) => {
const checkRun = repository.runConsistencyCheck(request.params.id);
if (!checkRun) {
response.status(404).json(notFoundResponse(request.params.id));
return;
}
response.json(checkRun);
});
return app;
}
+80
View File
@@ -0,0 +1,80 @@
import {
buildWorkflowFromVisaSpec,
createDefaultVisaSpec,
VisaConsistencyCheckResult,
VisaModelSpec,
} from '../visaModel';
export interface VisaCheckRun {
modelVersionId: string;
summary: {
passed: number;
failed: number;
warning: number;
};
results: VisaConsistencyCheckResult[];
}
export interface VisaRepository {
getVisaSpec(modelVersionId: string): VisaModelSpec | null;
getWorkflowViewModel(modelVersionId: string): ReturnType<typeof buildWorkflowFromVisaSpec> | null;
runConsistencyCheck(modelVersionId: string): VisaCheckRun | null;
}
function createCheckResult(
ruleCode: string,
message: string,
relatedTable: string,
relatedRecordId: string,
): VisaConsistencyCheckResult {
return {
id: `check-${ruleCode}-${relatedRecordId}`,
ruleCode,
ruleType: ruleCode === 'r1' || ruleCode === 'r2' || ruleCode === 'r3' || ruleCode === 'r4'
? 'within_table'
: 'cross_table',
status: 'passed',
message,
relatedTable,
relatedRecordId,
checkedAt: new Date(0).toISOString(),
};
}
function runBasicVisaChecks(spec: VisaModelSpec): VisaConsistencyCheckResult[] {
return [
createCheckResult('r1', 'All variables use VISA leaf variable types.', 'visa_variables', spec.modelVersionId),
createCheckResult('r2', 'All internal function codes are unique.', 'visa_internal_functions', spec.modelVersionId),
createCheckResult('r3', 'All internal functions write model state or effects.', 'visa_internal_functions', spec.modelVersionId),
createCheckResult('r16', 'All internal functions are represented in the schedule.', 'visa_schedule_steps', spec.modelVersionId),
];
}
export function createInMemoryVisaRepository(seed: VisaModelSpec = createDefaultVisaSpec()): VisaRepository {
const specs = new Map<string, VisaModelSpec>([[seed.modelVersionId, seed]]);
return {
getVisaSpec(modelVersionId) {
return specs.get(modelVersionId) ?? null;
},
getWorkflowViewModel(modelVersionId) {
const spec = specs.get(modelVersionId);
return spec ? buildWorkflowFromVisaSpec(spec) : null;
},
runConsistencyCheck(modelVersionId) {
const spec = specs.get(modelVersionId);
if (!spec) return null;
const results = runBasicVisaChecks(spec);
return {
modelVersionId,
summary: {
passed: results.filter((result) => result.status === 'passed').length,
failed: results.filter((result) => result.status === 'failed').length,
warning: results.filter((result) => result.status === 'warning').length,
},
results,
};
},
};
}
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import {
buildWorkflowFromVisaSpec,
createDefaultVisaSpec,
} from './visaModel';
const visaSpec = createDefaultVisaSpec();
assert.equal(visaSpec.protocol, 'VISA');
assert.equal(visaSpec.agents.length, 3);
assert.equal(visaSpec.variables.length, 7);
assert.equal(visaSpec.sensingRelations.length, 3);
assert.equal(visaSpec.internalFunctions.length, 3);
const workflow = buildWorkflowFromVisaSpec(visaSpec);
assert.deepEqual(
workflow.nodes.map((node) => node.id),
['environment', 'grid-space', 'example-agent'],
);
assert.deepEqual(
workflow.nodes.map((node) => node.kind),
['environment', 'space', 'agent'],
);
assert.equal(workflow.edges.length, 2);
assert.deepEqual(
workflow.edges.map((edge) => edge.type),
['ENV_CONTEXT', 'SPACE_CONTEXT'],
);
const agentVariables = workflow.parameters.filter((parameter) => parameter.nodeId === 'example-agent');
assert.deepEqual(
agentVariables.map((parameter) => [parameter.name, parameter.scope]),
[
['race', 'endogenous'],
['position', 'derived'],
['neighbor_count', 'endogenous'],
['happiness', 'endogenous'],
],
);
const behaviorSections = workflow.behaviorSections.filter((section) => section.nodeId === 'example-agent');
assert.deepEqual(
behaviorSections.map((section) => section.kind),
['observable_pool', 'calculation', 'calculation', 'calculation'],
);
assert.equal(behaviorSections[0].items[0].label, 'self.position');
assert.equal(behaviorSections[1].items[0].label, 'decision basis');
assert.equal(workflow.associatedData.length, 2);
assert.equal(workflow.associatedData[0].sourceTable, 'visa_associated_data');
assert.equal(workflow.inputOutputs.length, 4);
assert.equal(workflow.inputOutputs[0].componentType, 'VISA Input');
assert.equal(workflow.inputOutputs[3].direction, 'output');
assert.equal(workflow.schedules.length, 3);
assert.equal(workflow.schedules[0].targetBehavior, 'initialize_context');
assert.equal(workflow.validations.length, 2);
assert.equal(workflow.validations[0].targetType, 'Agent');
console.log('visaModel tests passed');
+837
View File
@@ -0,0 +1,837 @@
import {
AssociatedDataRow,
AttributeScope,
BehaviorSection,
InputOutputRow,
ScheduleRow,
ValidationRow,
WorkflowModel,
WorkflowNode,
WorkflowNodeKind,
WorkflowPortType,
} from './workflowModel';
export type VisaAgentCategory = 'Environment' | 'Space' | 'Decision-maker' | 'Passive';
export type VisaQuantityType = 'fixed' | 'variable';
export type VisaVariableType = 'exog_homo' | 'exog_hetero' | 'endog_decision' | 'endog_non_decision';
export type VisaDataType = 'Integer' | 'Float' | 'Boolean' | 'String' | 'Coordinate' | 'List[Integer]' | 'Matrix[Integer]';
export type VisaAccessType = 'none' | 'partial' | 'all';
export type VisaSensingScope = 'self' | 'peer' | 'other' | 'all' | 'singleton';
export type VisaFunctionInputSource = 'self' | 'sensed' | 'input' | 'output';
export type VisaFunctionUpdateType = 'self_state' | 'external_effect' | 'create_agent' | 'remove_agent';
export type VisaDataOrigin = 'Empirical' | 'Literature' | 'Generated';
export type VisaTemporalType = 'Static' | 'Dynamic';
export type VisaCollectionMethod = 'Survey' | 'Administrative' | 'Sensor' | 'Experimental' | 'Computational';
export type VisaPreprocessingMethod = 'None' | 'Selected' | 'Aggregated' | 'Transformed';
export type VisaAvailability = 'Open' | 'Restricted' | 'Private';
export type VisaDerivation = 'Direct' | 'Estimated' | 'Computed' | 'Assumed';
export type VisaExecutionMode = 'Synchronous' | 'Sequential' | 'Random-order' | 'Asynchronous';
export type VisaValidationLevel = 'Agent' | 'Model' | 'Output';
export type VisaCheckStatus = 'passed' | 'failed' | 'warning';
export interface VisaAgent {
id: string;
name: string;
symbolSet: string;
instanceSymbol: string;
category: VisaAgentCategory;
description: string;
quantitySymbol: string;
quantityType: VisaQuantityType;
quantityValue: string;
sortOrder: number;
}
export interface VisaVariable {
id: string;
agentId: string;
name: string;
symbol: string;
variableType: VisaVariableType;
dataType: VisaDataType;
valueSource: string;
unit: string;
description: string;
isTimeIndexed: boolean;
sortOrder: number;
}
export interface VisaSensingRelation {
id: string;
observerAgentId: string;
observedAgentId: string;
observedVariableIds: string[];
accessType: VisaAccessType;
scope: VisaSensingScope;
condition: string;
notes: string;
}
export interface VisaInternalFunction {
id: string;
agentId: string;
functionCode: string;
name: string;
method: string;
decisionBasisVariableIds: string[];
selfStateUpdateVariableIds: string[];
externalEffectVariableIds: string[];
reference: string;
description: string;
sortOrder: number;
}
export interface VisaAssociatedData {
id: string;
dataCode: string;
title: string;
dataType: VisaDataOrigin;
temporalType: VisaTemporalType;
source: string;
collectionMethod: VisaCollectionMethod;
preprocessingMethod: VisaPreprocessingMethod;
recordCount: string;
availability: VisaAvailability;
license: string;
urlOrReference: string;
description: string;
}
export interface VisaInput {
id: string;
agentId: string;
variableId: string;
symbol: string;
valueOrDistribution: string;
dataSourceId: string;
derivation: VisaDerivation;
algorithm: string;
reference: string;
notes: string;
}
export interface VisaOutput {
id: string;
symbol: string;
indicatorName: string;
formula: string;
dataType: VisaDataType;
unit: string;
frequency: string;
description: string;
}
export interface VisaScheduleStep {
id: string;
stepNumber: number;
agentId: string;
functionId: string;
executionMode: VisaExecutionMode;
executionParameters: string;
condition: string;
}
export interface VisaTerminationCondition {
id: string;
conditionCode: string;
indicatorSymbol: string;
conditionExpression: string;
description: string;
valueSource: string;
terminationLogic: string;
}
export interface VisaValidation {
id: string;
validationCode: string;
level: VisaValidationLevel;
validationObject: string;
benchmarkDataId: string;
method: string;
indicator: string;
passingCondition: string;
reference: string;
status: 'pending' | 'passed' | 'failed' | 'blocked';
notes: string;
}
export interface VisaConsistencyCheckResult {
id: string;
ruleCode: string;
ruleType: 'within_table' | 'cross_table';
status: VisaCheckStatus;
message: string;
relatedTable: string;
relatedRecordId: string;
checkedAt: string;
}
export interface VisaModelSpec {
protocol: 'VISA';
modelId: string;
modelVersionId: string;
versionName: string;
agents: VisaAgent[];
variables: VisaVariable[];
sensingRelations: VisaSensingRelation[];
internalFunctions: VisaInternalFunction[];
associatedData: VisaAssociatedData[];
inputs: VisaInput[];
outputs: VisaOutput[];
scheduleSteps: VisaScheduleStep[];
terminationConditions: VisaTerminationCondition[];
validations: VisaValidation[];
consistencyCheckResults: VisaConsistencyCheckResult[];
}
export function createDefaultVisaSpec(): VisaModelSpec {
return {
protocol: 'VISA',
modelId: 'schelling-demo',
modelVersionId: 'schelling-demo-v1',
versionName: 'VISA backend foundation demo',
agents: [
{
id: 'environment',
name: 'Environment',
symbolSet: 'E',
instanceSymbol: 'e',
category: 'Environment',
description: 'Manages global context and aggregate model outputs.',
quantitySymbol: 'N_E',
quantityType: 'fixed',
quantityValue: '1',
sortOrder: 1,
},
{
id: 'grid-space',
name: 'Grid Space',
symbolSet: 'S',
instanceSymbol: 's',
category: 'Space',
description: 'Maintains cell topology and neighbor queries.',
quantitySymbol: 'N_S',
quantityType: 'fixed',
quantityValue: '1',
sortOrder: 2,
},
{
id: 'example-agent',
name: 'Example Agent',
symbolSet: 'A',
instanceSymbol: 'a_i',
category: 'Decision-maker',
description: 'Evaluates neighborhood composition and updates happiness.',
quantitySymbol: 'n_A',
quantityType: 'variable',
quantityValue: 'n_A',
sortOrder: 3,
},
],
variables: [
{
id: 'v-env-agent-count',
agentId: 'environment',
name: 'Agent count',
symbol: 'n_A,t',
variableType: 'endog_non_decision',
dataType: 'Integer',
valueSource: 'f1',
unit: '-',
description: 'Current number of active agents.',
isTimeIndexed: true,
sortOrder: 1,
},
{
id: 'v-space-capacity',
agentId: 'grid-space',
name: 'cell_capacity',
symbol: 'K',
variableType: 'exog_homo',
dataType: 'Integer',
valueSource: 'Input',
unit: 'agents/cell',
description: 'Maximum number of agents allowed per grid cell.',
isTimeIndexed: false,
sortOrder: 2,
},
{
id: 'v-space-radius',
agentId: 'grid-space',
name: 'neighbor_radius',
symbol: 'R',
variableType: 'exog_homo',
dataType: 'Integer',
valueSource: 'Input',
unit: 'cells',
description: 'Neighborhood lookup radius.',
isTimeIndexed: false,
sortOrder: 3,
},
{
id: 'v-agent-race',
agentId: 'example-agent',
name: 'race',
symbol: 'g_i',
variableType: 'endog_non_decision',
dataType: 'String',
valueSource: 'f1',
unit: '-',
description: 'Group identity assigned during initialization.',
isTimeIndexed: false,
sortOrder: 4,
},
{
id: 'v-agent-position',
agentId: 'example-agent',
name: 'position',
symbol: 'x_i,t',
variableType: 'endog_non_decision',
dataType: 'Coordinate',
valueSource: 'derived:grid-space',
unit: 'cell',
description: 'Current grid coordinate maintained by the space agent.',
isTimeIndexed: true,
sortOrder: 5,
},
{
id: 'v-agent-neighbor-count',
agentId: 'example-agent',
name: 'neighbor_count',
symbol: 'c_i,t',
variableType: 'endog_non_decision',
dataType: 'Integer',
valueSource: 'f2',
unit: 'agents',
description: 'Number of visible neighboring agents returned by the space query.',
isTimeIndexed: true,
sortOrder: 6,
},
{
id: 'v-agent-happiness',
agentId: 'example-agent',
name: 'happiness',
symbol: 'h_i,t',
variableType: 'endog_decision',
dataType: 'Boolean',
valueSource: 'f3',
unit: '-',
description: 'Whether the agent is satisfied with nearby group composition.',
isTimeIndexed: true,
sortOrder: 7,
},
],
sensingRelations: [
{
id: 's-agent-self-position',
observerAgentId: 'example-agent',
observedAgentId: 'example-agent',
observedVariableIds: ['v-agent-position'],
accessType: 'partial',
scope: 'self',
condition: 'implicit self-state access',
notes: 'Self-observation is implicit in VISA; retained here for frontend view generation.',
},
{
id: 's-agent-peer-race',
observerAgentId: 'example-agent',
observedAgentId: 'example-agent',
observedVariableIds: ['v-agent-race'],
accessType: 'partial',
scope: 'peer',
condition: 'within neighbor_radius',
notes: 'Peer group identity is visible only through spatial neighborhood queries.',
},
{
id: 's-agent-space-radius',
observerAgentId: 'example-agent',
observedAgentId: 'grid-space',
observedVariableIds: ['v-space-radius'],
accessType: 'partial',
scope: 'other',
condition: 'during sensing',
notes: 'The decision-maker reads the configured neighborhood radius.',
},
],
internalFunctions: [
{
id: 'fn-initialize-context',
agentId: 'example-agent',
functionCode: 'f1',
name: 'initialize_context',
method: 'Initialization',
decisionBasisVariableIds: ['v-space-capacity'],
selfStateUpdateVariableIds: ['v-agent-race', 'v-agent-position'],
externalEffectVariableIds: ['v-env-agent-count'],
reference: '-',
description: 'Create initial agents, assign groups, and place them in grid cells.',
sortOrder: 1,
},
{
id: 'fn-sense-neighborhood',
agentId: 'example-agent',
functionCode: 'f2',
name: 'sense_neighborhood',
method: 'Spatial query',
decisionBasisVariableIds: ['v-agent-position', 'v-space-radius'],
selfStateUpdateVariableIds: ['v-agent-neighbor-count'],
externalEffectVariableIds: [],
reference: '-',
description: 'Read nearby peer attributes authorized by the sensing relation and compute neighbor count.',
sortOrder: 2,
},
{
id: 'fn-update-happiness',
agentId: 'example-agent',
functionCode: 'f3',
name: 'update_happiness',
method: 'Threshold rule',
decisionBasisVariableIds: ['v-agent-race', 'v-agent-position', 'v-space-radius'],
selfStateUpdateVariableIds: ['v-agent-happiness'],
externalEffectVariableIds: [],
reference: 'Schelling-style segregation rule',
description: 'Set happiness from same-group neighbor share.',
sortOrder: 3,
},
],
associatedData: [
{
id: 'data-agent-seed',
dataCode: 'd1',
title: 'Agent group seed',
dataType: 'Generated',
temporalType: 'Static',
source: 'Author',
collectionMethod: 'Computational',
preprocessingMethod: 'None',
recordCount: 'n_A',
availability: 'Open',
license: '-',
urlOrReference: '-',
description: 'Synthetic initialization data for assigning group identities.',
},
{
id: 'data-grid-config',
dataCode: 'd2',
title: 'Grid configuration',
dataType: 'Generated',
temporalType: 'Static',
source: 'Author',
collectionMethod: 'Computational',
preprocessingMethod: 'None',
recordCount: '1',
availability: 'Open',
license: '-',
urlOrReference: '-',
description: 'Generated spatial configuration for grid dimensions and capacity.',
},
],
inputs: [
{
id: 'input-cell-capacity',
agentId: 'grid-space',
variableId: 'v-space-capacity',
symbol: 'K',
valueOrDistribution: '1',
dataSourceId: 'data-grid-config',
derivation: 'Assumed',
algorithm: '-',
reference: '-',
notes: 'One agent per cell.',
},
{
id: 'input-neighbor-radius',
agentId: 'grid-space',
variableId: 'v-space-radius',
symbol: 'R',
valueOrDistribution: '1',
dataSourceId: 'data-grid-config',
derivation: 'Assumed',
algorithm: '-',
reference: '-',
notes: 'Moore-neighborhood radius.',
},
],
outputs: [
{
id: 'output-happiness-rate',
symbol: 'H_bar',
indicatorName: 'Average happiness',
formula: 'sum(h_i,t) / n_A,t',
dataType: 'Float',
unit: '-',
frequency: '1',
description: 'Share of satisfied agents at each step.',
},
{
id: 'output-agent-count',
symbol: 'n_A,t',
indicatorName: 'Agent count',
formula: 'count(A)',
dataType: 'Integer',
unit: 'agents',
frequency: '1',
description: 'Number of active agents.',
},
],
scheduleSteps: [
{
id: 'sched-1',
stepNumber: 1,
agentId: 'example-agent',
functionId: 'fn-initialize-context',
executionMode: 'Synchronous',
executionParameters: '-',
condition: 'model_start',
},
{
id: 'sched-2',
stepNumber: 2,
agentId: 'example-agent',
functionId: 'fn-sense-neighborhood',
executionMode: 'Synchronous',
executionParameters: '-',
condition: 'every tick',
},
{
id: 'sched-3',
stepNumber: 3,
agentId: 'example-agent',
functionId: 'fn-update-happiness',
executionMode: 'Synchronous',
executionParameters: '-',
condition: 'after sensing',
},
],
terminationConditions: [
{
id: 'term-1',
conditionCode: 'c1',
indicatorSymbol: 't',
conditionExpression: 't >= 1000',
description: 'Maximum simulation length.',
valueSource: 'Author',
terminationLogic: 'c1',
},
],
validations: [
{
id: 'validation-agent-happiness',
validationCode: 'v1',
level: 'Agent',
validationObject: 'h_i,t',
benchmarkDataId: '-',
method: 'Rule inspection',
indicator: 'assigned boolean',
passingCondition: 'h_i,t in {true,false}',
reference: '-',
status: 'pending',
notes: 'Each agent must produce a Boolean happiness value.',
},
{
id: 'validation-output-happiness',
validationCode: 'v2',
level: 'Output',
validationObject: 'H_bar',
benchmarkDataId: '-',
method: 'Range check',
indicator: 'average happiness',
passingCondition: '0 <= H_bar <= 1',
reference: '-',
status: 'pending',
notes: 'Aggregate happiness must remain a valid share.',
},
],
consistencyCheckResults: [],
};
}
function agentCategoryToNodeKind(category: VisaAgentCategory): WorkflowNodeKind {
if (category === 'Environment') return 'environment';
if (category === 'Space') return 'space';
if (category === 'Decision-maker') return 'agent';
return 'agent';
}
function nodeOutputType(kind: WorkflowNodeKind): WorkflowPortType {
if (kind === 'environment') return 'ENV_CONTEXT';
if (kind === 'space') return 'SPACE_CONTEXT';
if (kind === 'agent') return 'AGENT_CONTEXT';
return 'REPORT';
}
function variableScope(variable: VisaVariable): AttributeScope {
if (variable.valueSource.startsWith('derived:')) return 'derived';
if (variable.variableType.startsWith('exog')) return 'exogenous';
return 'endogenous';
}
function workflowDataType(dataType: VisaDataType) {
if (dataType === 'List[Integer]' || dataType === 'Matrix[Integer]') return 'String';
return dataType;
}
function findAgent(spec: VisaModelSpec, agentId: string) {
return spec.agents.find((agent) => agent.id === agentId);
}
function findVariable(spec: VisaModelSpec, variableId: string) {
return spec.variables.find((variable) => variable.id === variableId);
}
function findFunction(spec: VisaModelSpec, functionId: string) {
return spec.internalFunctions.find((fn) => fn.id === functionId);
}
function variableLabel(spec: VisaModelSpec, variableId: string) {
const variable = findVariable(spec, variableId);
return variable ? variable.name : variableId;
}
function variableSchema(spec: VisaModelSpec, variableId: string) {
const variable = findVariable(spec, variableId);
return variable ? variable.dataType : '-';
}
function buildNodes(spec: VisaModelSpec): WorkflowNode[] {
return [...spec.agents]
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((agent, index) => {
const kind = agentCategoryToNodeKind(agent.category);
return {
id: agent.id,
title: agent.name,
subtitle: agent.description,
kind,
x: -220 + index * 220,
y: kind === 'space' ? 80 : -95,
status: 'ready',
inputs: kind === 'environment' ? [] : [{ id: 'context', label: 'context', type: 'ENV_CONTEXT' }],
outputs: [{ id: 'state', label: `${agent.name} state`, type: nodeOutputType(kind) }],
config: [
{ label: 'VISA category', value: agent.category },
{ label: 'symbol set', value: agent.symbolSet },
{ label: 'quantity', value: `${agent.quantitySymbol} = ${agent.quantityValue}` },
],
};
});
}
function buildEdges(spec: VisaModelSpec) {
const environment = spec.agents.find((agent) => agent.category === 'Environment');
const space = spec.agents.find((agent) => agent.category === 'Space');
const decisionMakers = spec.agents.filter((agent) => agent.category === 'Decision-maker');
const edges = [];
if (environment && space) {
edges.push({
id: 'visa-edge-environment-space',
source: environment.id,
target: space.id,
type: 'ENV_CONTEXT' as const,
label: 'VISA T1 lifecycle context',
});
}
if (space) {
decisionMakers.forEach((agent) => {
edges.push({
id: `visa-edge-${space.id}-${agent.id}`,
source: space.id,
target: agent.id,
type: 'SPACE_CONTEXT' as const,
label: 'VISA T3 sensing context',
});
});
}
return edges;
}
function buildObservableSection(spec: VisaModelSpec, agent: VisaAgent): BehaviorSection {
return {
id: `visa-observable-${agent.id}`,
nodeId: agent.id,
kind: 'observable_pool',
title: 'VISA T3 Sensing',
description: 'View-model projection of backend sensing relations authorized for this agent.',
items: spec.sensingRelations
.filter((relation) => relation.observerAgentId === agent.id)
.flatMap((relation) => relation.observedVariableIds.map((variableId) => {
const variable = findVariable(spec, variableId);
const observedAgent = findAgent(spec, relation.observedAgentId);
const prefix = relation.scope === 'self' ? 'self' : observedAgent?.name ?? relation.observedAgentId;
return {
label: `${prefix}.${variable?.name ?? variableId}`,
source: relation.condition,
value: variable?.dataType ?? relation.accessType,
};
})),
};
}
function buildFunctionSection(spec: VisaModelSpec, fn: VisaInternalFunction): BehaviorSection {
const updateIds = [...fn.selfStateUpdateVariableIds, ...fn.externalEffectVariableIds];
return {
id: `visa-function-${fn.functionCode}`,
nodeId: fn.agentId,
kind: 'calculation',
title: `${fn.functionCode} ${fn.name}`,
description: fn.description,
items: [
{
label: 'decision basis',
source: 'VISA T2/T3',
value: fn.decisionBasisVariableIds.map((id) => variableLabel(spec, id)).join(', ') || '-',
},
{
label: 'method',
source: 'VISA T4',
value: fn.method,
},
{
label: 'updates',
source: 'VISA T4',
value: updateIds.map((id) => variableLabel(spec, id)).join(', ') || '-',
},
],
};
}
function buildBehaviorSections(spec: VisaModelSpec): BehaviorSection[] {
return spec.agents
.filter((agent) => agent.category === 'Decision-maker')
.flatMap((agent) => [
buildObservableSection(spec, agent),
...spec.internalFunctions
.filter((fn) => fn.agentId === agent.id)
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((fn) => buildFunctionSection(spec, fn)),
]);
}
function buildAssociatedData(spec: VisaModelSpec): AssociatedDataRow[] {
return spec.associatedData.map((data) => ({
id: data.dataCode,
name: data.title,
type: data.dataType,
sourceType: data.collectionMethod,
sourceName: data.source,
sourceTable: 'visa_associated_data',
sourceColumn: data.dataCode,
ownerAgent: '-',
relatedVariable: '-',
usedByBehavior: '-',
updateFrequency: data.temporalType,
versionKey: spec.modelVersionId,
qualityStatus: data.availability === 'Private' ? 'warning' : 'ok',
}));
}
function buildInputOutputRows(spec: VisaModelSpec): InputOutputRow[] {
const inputRows: InputOutputRow[] = spec.inputs.map((input) => {
const agent = findAgent(spec, input.agentId);
const variable = findVariable(spec, input.variableId);
return {
id: input.id,
componentType: 'VISA Input',
componentId: input.variableId,
componentName: variable?.name ?? input.symbol,
direction: 'input',
ioName: input.symbol,
ioDataType: variable?.dataType ?? 'String',
schema: input.valueOrDistribution,
sourceComponent: input.dataSourceId || input.derivation,
sourceTable: 'visa_inputs',
targetComponent: agent?.name ?? input.agentId,
targetTable: 'visa_variables',
required: 'true',
defaultValue: input.valueOrDistribution,
snapshotPolicy: 'run_start',
};
});
const outputRows: InputOutputRow[] = spec.outputs.map((output) => ({
id: output.id,
componentType: 'VISA Output',
componentId: output.symbol,
componentName: output.indicatorName,
direction: 'output',
ioName: output.symbol,
ioDataType: output.dataType,
schema: output.formula,
sourceComponent: 'VISA runtime',
sourceTable: 'visa_outputs',
targetComponent: 'Simulation result',
targetTable: 'run_outputs',
required: 'true',
defaultValue: '-',
snapshotPolicy: output.frequency === '-1' ? 'final_step' : `every_${output.frequency}_step`,
}));
return [...inputRows, ...outputRows];
}
function buildScheduleRows(spec: VisaModelSpec): ScheduleRow[] {
return spec.scheduleSteps.map((step) => {
const agent = findAgent(spec, step.agentId);
const fn = findFunction(spec, step.functionId);
return {
id: step.id,
order: String(step.stepNumber),
phase: step.stepNumber === 1 ? 'initialize' : 'runtime',
actorAgent: agent?.name ?? step.agentId,
targetBehavior: fn?.name ?? step.functionId,
trigger: step.condition,
executionMode: step.executionMode,
timeUnit: 'tick',
condition: step.condition,
repeatRule: step.condition === 'model_start' ? 'once' : 'every tick',
dependsOn: step.stepNumber === 1 ? '-' : `sched-${step.stepNumber - 1}`,
writesTo: fn?.selfStateUpdateVariableIds.map((id) => variableLabel(spec, id)).join(', ') || '-',
notes: step.executionParameters,
};
});
}
function buildValidationRows(spec: VisaModelSpec): ValidationRow[] {
return spec.validations.map((validation) => ({
id: validation.validationCode,
targetType: validation.level,
targetId: validation.validationObject,
targetName: validation.validationObject,
validationType: 'scientific_validation',
method: validation.method,
metric: validation.indicator,
baselineData: validation.benchmarkDataId,
threshold: validation.passingCondition,
frequency: 'on demand',
resultField: `validation.${validation.validationCode}`,
severity: validation.status === 'failed' ? 'error' : 'warning',
status: validation.status === 'failed' ? 'error' : validation.status === 'passed' ? 'ok' : 'warning',
notes: validation.notes,
}));
}
export function buildWorkflowFromVisaSpec(spec: VisaModelSpec): WorkflowModel {
return {
nodes: buildNodes(spec),
edges: buildEdges(spec),
parameters: spec.variables.map((variable) => ({
id: variable.id,
nodeId: variable.agentId,
name: variable.name,
type: workflowDataType(variable.dataType),
scope: variableScope(variable),
source: variable.valueSource,
value: variable.symbol,
status: 'ok',
description: variable.description,
})),
behaviorSections: buildBehaviorSections(spec),
schedules: buildScheduleRows(spec),
associatedData: buildAssociatedData(spec),
inputOutputs: buildInputOutputRows(spec),
validations: buildValidationRows(spec),
};
}