Add VISA backend foundation
This commit is contained in:
@@ -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}`);
|
||||
});
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user