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
+14
View File
@@ -0,0 +1,14 @@
FROM node:20-alpine AS api
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY src ./src
COPY db ./db
COPY tsconfig.json ./
EXPOSE 4000
CMD ["npm", "run", "server"]
+11 -2
View File
@@ -30,9 +30,15 @@ View your app in AI Studio: https://ai.studio/apps/b6ca5081-8525-4b56-8fbc-92c24
`docker compose up --build -d` `docker compose up --build -d`
4. Open `http://localhost:8080`. 4. Open `http://localhost:8080`.
The production image copies the local `dist` directory into The production compose stack runs two services:
- `app`: nginx serves the host-built `dist` folder on `http://localhost:8080`
- `api`: the VISA API runs on `http://localhost:4000`
nginx also proxies API calls under `http://localhost:8080/api/...` to the API
container. The frontend image copies the local `dist` directory into
`nginx:stable-alpine3.23-slim`; it does not download Node or reinstall npm `nginx:stable-alpine3.23-slim`; it does not download Node or reinstall npm
packages inside Docker. packages inside the frontend Docker image.
**Development server (optional, downloads a Node image and dependencies):** **Development server (optional, downloads a Node image and dependencies):**
@@ -40,3 +46,6 @@ packages inside Docker.
2. Start the development container: 2. Start the development container:
`docker compose -f docker-compose.dev.yml up --build` `docker compose -f docker-compose.dev.yml up --build`
3. Open `http://localhost:3000`. 3. Open `http://localhost:3000`.
The development compose stack runs Vite on `http://localhost:3000` and the VISA
API on `http://localhost:4000`.
@@ -0,0 +1,207 @@
create table if not exists models (
id text primary key,
name text not null,
description text not null default '',
domain text not null default '',
author text not null default '',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists model_versions (
id text primary key,
model_id text not null references models (id) on delete cascade,
version_name text not null,
status text not null default 'draft' check (status in ('draft', 'checked', 'released', 'archived')),
source_protocol text not null default 'VISA',
created_by text not null default '',
created_at timestamptz not null default now(),
notes text not null default ''
);
create table if not exists visa_agents (
id text primary key,
model_version_id text not null references model_versions (id) on delete cascade,
name text not null,
symbol_set text not null,
instance_symbol text not null,
category text not null check (category in ('Environment', 'Space', 'Decision-maker', 'Passive')),
description text not null default '',
quantity_symbol text not null,
quantity_type text not null check (quantity_type in ('fixed', 'variable')),
quantity_value text not null,
sort_order integer not null default 0,
unique (model_version_id, name),
unique (model_version_id, symbol_set)
);
create table if not exists visa_variables (
id text primary key,
model_version_id text not null references model_versions (id) on delete cascade,
agent_id text not null references visa_agents (id) on delete cascade,
name text not null,
symbol text not null,
variable_type text not null check (variable_type in ('exog_homo', 'exog_hetero', 'endog_decision', 'endog_non_decision')),
data_type text not null,
value_source text not null,
unit text not null default '',
description text not null default '',
is_time_indexed boolean not null default false,
sort_order integer not null default 0,
unique (model_version_id, agent_id, name),
unique (model_version_id, symbol)
);
create table if not exists visa_sensing_relations (
id text primary key,
model_version_id text not null references model_versions (id) on delete cascade,
observer_agent_id text not null references visa_agents (id) on delete cascade,
observed_agent_id text not null references visa_agents (id) on delete cascade,
access_type text not null check (access_type in ('none', 'partial', 'all')),
scope text not null check (scope in ('self', 'peer', 'other', 'all', 'singleton')),
condition text not null default '',
notes text not null default ''
);
create table if not exists visa_sensing_variables (
sensing_relation_id text not null references visa_sensing_relations (id) on delete cascade,
variable_id text not null references visa_variables (id) on delete cascade,
primary key (sensing_relation_id, variable_id)
);
create table if not exists visa_internal_functions (
id text primary key,
model_version_id text not null references model_versions (id) on delete cascade,
agent_id text not null references visa_agents (id) on delete cascade,
function_code text not null,
name text not null,
method text not null,
reference text not null default '',
description text not null default '',
sort_order integer not null default 0,
unique (model_version_id, function_code)
);
create table if not exists visa_function_inputs (
function_id text not null references visa_internal_functions (id) on delete cascade,
variable_id text not null references visa_variables (id) on delete cascade,
source_type text not null check (source_type in ('self', 'sensed', 'input', 'output')),
expression text not null default '',
primary key (function_id, variable_id, source_type)
);
create table if not exists visa_function_updates (
id text primary key,
function_id text not null references visa_internal_functions (id) on delete cascade,
target_agent_id text references visa_agents (id) on delete set null,
variable_id text references visa_variables (id) on delete set null,
update_type text not null check (update_type in ('self_state', 'external_effect', 'create_agent', 'remove_agent')),
expression text not null default ''
);
create table if not exists visa_associated_data (
id text primary key,
model_version_id text not null references model_versions (id) on delete cascade,
data_code text not null,
title text not null,
data_type text not null check (data_type in ('Empirical', 'Literature', 'Generated')),
temporal_type text not null check (temporal_type in ('Static', 'Dynamic')),
source text not null,
collection_method text not null check (collection_method in ('Survey', 'Administrative', 'Sensor', 'Experimental', 'Computational')),
preprocessing_method text not null check (preprocessing_method in ('None', 'Selected', 'Aggregated', 'Transformed')),
record_count text not null default '',
availability text not null check (availability in ('Open', 'Restricted', 'Private')),
license text not null default '',
url_or_reference text not null default '',
description text not null default '',
unique (model_version_id, data_code)
);
create table if not exists visa_inputs (
id text primary key,
model_version_id text not null references model_versions (id) on delete cascade,
agent_id text not null references visa_agents (id) on delete cascade,
variable_id text not null references visa_variables (id) on delete cascade,
symbol text not null,
value_or_distribution text not null,
data_source_id text references visa_associated_data (id) on delete set null,
derivation text not null check (derivation in ('Direct', 'Estimated', 'Computed', 'Assumed')),
algorithm text not null default '',
reference text not null default '',
notes text not null default '',
unique (model_version_id, variable_id)
);
create table if not exists visa_outputs (
id text primary key,
model_version_id text not null references model_versions (id) on delete cascade,
symbol text not null,
indicator_name text not null,
formula text not null,
data_type text not null,
unit text not null default '',
frequency text not null,
description text not null default '',
unique (model_version_id, symbol)
);
create table if not exists visa_schedule_steps (
id text primary key,
model_version_id text not null references model_versions (id) on delete cascade,
step_number integer not null,
agent_id text not null references visa_agents (id) on delete cascade,
function_id text not null references visa_internal_functions (id) on delete cascade,
execution_mode text not null check (execution_mode in ('Synchronous', 'Sequential', 'Random-order', 'Asynchronous')),
execution_parameters text not null default '',
condition text not null default '',
sort_order integer not null default 0,
unique (model_version_id, step_number, function_id)
);
create table if not exists visa_termination_conditions (
id text primary key,
model_version_id text not null references model_versions (id) on delete cascade,
condition_code text not null,
indicator_symbol text not null,
condition_expression text not null,
description text not null default '',
value_source text not null default '',
termination_logic text not null,
unique (model_version_id, condition_code)
);
create table if not exists visa_validations (
id text primary key,
model_version_id text not null references model_versions (id) on delete cascade,
validation_code text not null,
level text not null check (level in ('Agent', 'Model', 'Output')),
validation_object text not null,
benchmark_data_id text references visa_associated_data (id) on delete set null,
method text not null,
indicator text not null,
passing_condition text not null,
reference text not null default '',
status text not null default 'pending' check (status in ('pending', 'passed', 'failed', 'blocked')),
notes text not null default '',
unique (model_version_id, validation_code)
);
create table if not exists visa_consistency_check_results (
id text primary key,
model_version_id text not null references model_versions (id) on delete cascade,
rule_code text not null,
rule_type text not null check (rule_type in ('within_table', 'cross_table')),
status text not null check (status in ('passed', 'failed', 'warning')),
message text not null,
related_table text not null default '',
related_record_id text not null default '',
checked_at timestamptz not null default now()
);
create index if not exists idx_model_versions_model on model_versions (model_id);
create index if not exists idx_visa_agents_model_version on visa_agents (model_version_id);
create index if not exists idx_visa_variables_agent on visa_variables (agent_id);
create index if not exists idx_visa_sensing_observer on visa_sensing_relations (observer_agent_id);
create index if not exists idx_visa_functions_agent on visa_internal_functions (agent_id);
create index if not exists idx_visa_schedule_model_version on visa_schedule_steps (model_version_id, step_number);
create index if not exists idx_visa_checks_model_version on visa_consistency_check_results (model_version_id, checked_at);
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const currentDir = dirname(fileURLToPath(import.meta.url));
const migration = readFileSync(join(currentDir, '202606230001_create_visa_schema.sql'), 'utf8');
const requiredTables = [
'models',
'model_versions',
'visa_agents',
'visa_variables',
'visa_sensing_relations',
'visa_sensing_variables',
'visa_internal_functions',
'visa_function_inputs',
'visa_function_updates',
'visa_associated_data',
'visa_inputs',
'visa_outputs',
'visa_schedule_steps',
'visa_termination_conditions',
'visa_validations',
'visa_consistency_check_results',
];
for (const table of requiredTables) {
assert.match(migration, new RegExp(`create table if not exists ${table}`, 'i'), `${table} table missing`);
}
const requiredForeignKeys = [
'references model_versions',
'references visa_agents',
'references visa_variables',
'references visa_sensing_relations',
'references visa_internal_functions',
'references visa_associated_data',
];
for (const foreignKey of requiredForeignKeys) {
assert.match(migration, new RegExp(foreignKey, 'i'), `${foreignKey} foreign key missing`);
}
const requiredIndexes = [
'idx_visa_agents_model_version',
'idx_visa_variables_agent',
'idx_visa_sensing_observer',
'idx_visa_functions_agent',
'idx_visa_schedule_model_version',
'idx_visa_checks_model_version',
];
for (const indexName of requiredIndexes) {
assert.match(migration, new RegExp(`create index if not exists ${indexName}`, 'i'), `${indexName} index missing`);
}
assert.match(migration, /unique \(model_version_id, function_code\)/i);
assert.match(migration, /unique \(model_version_id, validation_code\)/i);
assert.match(migration, /check \(status in \('draft', 'checked', 'released', 'archived'\)\)/i);
console.log('visa schema migration tests passed');
+14
View File
@@ -11,3 +11,17 @@ services:
volumes: volumes:
- .:/app - .:/app
- /app/node_modules - /app/node_modules
api:
container_name: agentblock-api-dev
build:
context: .
dockerfile: Dockerfile.dev
command: npm run server
environment:
PORT: "4000"
ports:
- "4000:4000"
volumes:
- .:/app
- /app/node_modules
+15
View File
@@ -4,6 +4,21 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
depends_on:
- api
ports: ports:
- "8080:80" - "8080:80"
restart: unless-stopped restart: unless-stopped
api:
container_name: agentblock-api
build:
context: .
dockerfile: Dockerfile.api
environment:
PORT: "4000"
expose:
- "4000"
ports:
- "4000:4000"
restart: unless-stopped
@@ -0,0 +1,95 @@
# VISA API And Migration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a real backend API surface and a database schema migration for the VISA backend foundation.
**Architecture:** Add an Express app factory with seeded in-memory VISA data so the API can run without introducing a database driver yet. Add a PostgreSQL migration file that creates the normalized VISA schema described in `docs/visa-backend-database-design.md`.
**Tech Stack:** TypeScript, Express, Node assert tests, PostgreSQL SQL migration.
---
### Task 1: API Tests
**Files:**
- Create: `src/server/visaApi.test.ts`
- [ ] **Step 1: Write failing API tests**
Test `GET /api/model-versions/:id/visa`, `GET /api/model-versions/:id/view-model`, `POST /api/model-versions/:id/actions/run-check`, and a missing model version response.
- [ ] **Step 2: Run the test to verify red**
Run: `npx.cmd tsx src/server/visaApi.test.ts`.
Expected: FAIL because the server app module does not exist.
### Task 2: API Implementation
**Files:**
- Create: `src/server/visaRepository.ts`
- Create: `src/server/visaApi.ts`
- Create: `src/server/index.ts`
- Modify: `package.json`
- [ ] **Step 1: Add in-memory repository**
Seed the repository with `createDefaultVisaSpec()` and expose `getVisaSpec`, `getWorkflowViewModel`, and `runConsistencyCheck`.
- [ ] **Step 2: Add Express app factory**
Expose the VISA JSON endpoint, view-model endpoint, and run-check action.
- [ ] **Step 3: Add server entry and npm script**
Add `src/server/index.ts` and a `server` script using `tsx`.
- [ ] **Step 4: Run API tests**
Run: `npx.cmd tsx src/server/visaApi.test.ts`.
Expected: PASS.
### Task 3: Migration Tests
**Files:**
- Create: `db/migrations/202606230001_create_visa_schema.test.ts`
- [ ] **Step 1: Write failing migration test**
Assert the migration includes the system tables, VISA semantic tables, consistency check table, foreign keys, and indexes.
- [ ] **Step 2: Run the test to verify red**
Run: `npx.cmd tsx db/migrations/202606230001_create_visa_schema.test.ts`.
Expected: FAIL because the SQL migration does not exist.
### Task 4: Migration SQL
**Files:**
- Create: `db/migrations/202606230001_create_visa_schema.sql`
- [ ] **Step 1: Add PostgreSQL schema migration**
Create model/version tables, VISA T1-T8 normalized tables, consistency check table, foreign keys, uniqueness constraints, and basic indexes.
- [ ] **Step 2: Run migration test**
Run: `npx.cmd tsx db/migrations/202606230001_create_visa_schema.test.ts`.
Expected: PASS.
### Task 5: Verification
**Files:**
- Modify as needed based on test results.
- [ ] **Step 1: Run model and API tests**
Run:
- `npx.cmd tsx src/workflowModel.test.ts`
- `npx.cmd tsx src/visaModel.test.ts`
- `npx.cmd tsx src/server/visaApi.test.ts`
- `npx.cmd tsx db/migrations/202606230001_create_visa_schema.test.ts`
- [ ] **Step 2: Run TypeScript check**
Run: `npm.cmd run lint`.
@@ -0,0 +1,82 @@
# VISA Backend Foundation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a VISA-native backend/database foundation while keeping the current frontend workflow model compatible through an adapter.
**Architecture:** Define VISA T1-T8 as backend-oriented domain structures, add a canonical sample specification, and convert that specification into the existing `WorkflowModel` view model. Keep the database design in documentation for the next backend phase.
**Tech Stack:** TypeScript, Node assert tests, Vite React, Markdown database design documentation.
---
### Task 1: Adapter Tests
**Files:**
- Create: `src/visaModel.test.ts`
- [ ] **Step 1: Write tests for VISA-to-workflow conversion**
Create tests that assert the adapter exposes VISA agents as frontend workflow nodes, internal functions as behavior sections, sensing records as observable rows, and model-level tables as schedule/data/I-O/validation rows.
- [ ] **Step 2: Run test to verify it fails**
Run: `npx tsx src/visaModel.test.ts`
Expected: FAIL because `visaModel` does not exist yet.
### Task 2: VISA Domain Model
**Files:**
- Create: `src/visaModel.ts`
- [ ] **Step 1: Define VISA T1-T8 TypeScript interfaces**
Define backend-facing types for agents, variables, sensing relations, internal functions, associated data, inputs, outputs, schedule steps, termination conditions, validations, and consistency check results.
- [ ] **Step 2: Add a canonical default VISA specification**
Create `createDefaultVisaSpec()` using the existing segregation-style example but expressed as VISA T1-T8 rather than frontend tables.
### Task 3: Workflow Adapter
**Files:**
- Modify: `src/visaModel.ts`
- [ ] **Step 1: Implement `buildWorkflowFromVisaSpec`**
Convert T1 agents into workflow nodes, selected T3/T4 relations into view edges and behavior sections, and T5-T8 records into the existing table rows.
- [ ] **Step 2: Run adapter tests**
Run: `npx tsx src/visaModel.test.ts`
Expected: PASS.
### Task 4: Database Design Documentation
**Files:**
- Create: `docs/visa-backend-database-design.md`
- [ ] **Step 1: Document the database schema**
Write the scheme as eight VISA semantic modules plus normalized helper tables for sensing/function references, model versions, and consistency check results.
- [ ] **Step 2: Document frontend boundary**
State that the frontend must not show the eight tables as raw database tables; it consumes view models derived from VISA data.
### Task 5: Verification
**Files:**
- Modify as needed based on test results.
- [ ] **Step 1: Run all model tests**
Run: `npx tsx src/workflowModel.test.ts` and `npx tsx src/visaModel.test.ts`.
- [ ] **Step 2: Run TypeScript lint**
Run: `npm run lint`.
- [ ] **Step 3: Report status**
Summarize changed files, tests, and remaining backend work.
+396
View File
@@ -0,0 +1,396 @@
# VISA Backend Database Design
## Purpose
The project should treat VISA as a backend model specification protocol, not as a set of frontend tables. The database is the source of truth for reproducible ABM descriptions. The frontend consumes derived view models for graph editing, summaries, validation reports, and export actions.
This design follows the selected approach C:
```text
Database and backend: VISA-native T1-T8 specification
Adapter API: VISA data -> frontend workflow view model
Frontend: modeling interface, not raw database table display
```
## High-Level Architecture
```text
Frontend
Canvas, forms, summaries, validation report, export actions
|
v
Adapter API
Builds frontend view models from VISA records
Converts semantic edit actions into VISA updates
|
v
Backend Services
Model versioning
VISA T1-T8 CRUD
19-rule consistency checking
VISA JSON export
Future code generation
|
v
Database
Model/version metadata
VISA semantic modules
Normalized helper tables
Consistency check results
```
## System Tables
### `models`
Stores the model project.
```text
id
name
description
domain
author
created_at
updated_at
```
### `model_versions`
Stores versioned VISA specifications. Every VISA record belongs to one `model_version_id`.
```text
id
model_id
version_name
status: draft | checked | released | archived
source_protocol: VISA
created_by
created_at
notes
```
## VISA Semantic Modules
The database may use more than eight physical tables, but the backend must expose eight VISA semantic modules.
## T1 Agent
Physical table: `visa_agents`
```text
id
model_version_id
name
symbol_set
instance_symbol
category: Environment | Space | Decision-maker | Passive
description
quantity_symbol
quantity_type: fixed | variable
quantity_value
sort_order
```
Design rule: database records should represent model-semantic agents, not implementation classes. Datasets, output records, and reports belong to T5 or T6, not T1.
## T2 Variable
Physical table: `visa_variables`
```text
id
model_version_id
agent_id
name
symbol
variable_type: exog_homo | exog_hetero | endog_decision | endog_non_decision
data_type
value_source
unit
description
is_time_indexed
sort_order
```
Design rule: endogenous variables should be traceable to T4 function IDs. Exogenous variables marked as `Input` must be covered by T6a.
## T3 Sensing
Physical tables:
```text
visa_sensing_relations
visa_sensing_variables
```
`visa_sensing_relations`
```text
id
model_version_id
observer_agent_id
observed_agent_id
access_type: none | partial | all
scope: self | peer | other | all | singleton
condition
notes
```
`visa_sensing_variables`
```text
sensing_relation_id
variable_id
```
Design rule: store sensing as normalized relations rather than a wide matrix. The backend can reconstruct the VISA matrix for export and can check whether T4 functions use only authorized information.
## T4 Internal Function
Physical tables:
```text
visa_internal_functions
visa_function_inputs
visa_function_updates
```
`visa_internal_functions`
```text
id
model_version_id
agent_id
function_code
name
method
reference
description
sort_order
```
`visa_function_inputs`
```text
function_id
variable_id
source_type: self | sensed | input | output
expression
```
`visa_function_updates`
```text
function_id
target_agent_id
variable_id
update_type: self_state | external_effect | create_agent | remove_agent
expression
```
Design rule: every internal function must update at least one endogenous variable or create/remove an instance of a variable-quantity agent type.
## T5 Associated Data
Physical table: `visa_associated_data`
```text
id
model_version_id
data_code
title
data_type: Empirical | Literature | Generated
temporal_type: Static | Dynamic
source
collection_method: Survey | Administrative | Sensor | Experimental | Computational
preprocessing_method: None | Selected | Aggregated | Transformed
record_count
availability: Open | Restricted | Private
license
url_or_reference
description
```
Design rule: this table records data provenance. It is not merely a source-column lookup table.
## T6 Input and Output
Physical tables:
```text
visa_inputs
visa_outputs
```
`visa_inputs`
```text
id
model_version_id
agent_id
variable_id
symbol
value_or_distribution
data_source_id
derivation: Direct | Estimated | Computed | Assumed
algorithm
reference
notes
```
`visa_outputs`
```text
id
model_version_id
symbol
indicator_name
formula
data_type
unit
frequency
description
```
Design rule: T6a covers exogenous model inputs. T6b covers simulation output indicators. Component-to-component frontend wiring is a derived view, not the canonical T6 design.
## T7 Schedule
Physical tables:
```text
visa_schedule_steps
visa_termination_conditions
```
`visa_schedule_steps`
```text
id
model_version_id
step_number
agent_id
function_id
execution_mode: Synchronous | Sequential | Random-order | Asynchronous
execution_parameters
condition
sort_order
```
`visa_termination_conditions`
```text
id
model_version_id
condition_code
indicator_symbol
condition_expression
description
value_source
termination_logic
```
Design rule: execution mode must be structured because synchronization choices can change ABM behavior.
## T8 Validation
Physical table: `visa_validations`
```text
id
model_version_id
validation_code
level: Agent | Model | Output
validation_object
benchmark_data_id
method
indicator
passing_condition
reference
status: pending | passed | failed | blocked
notes
```
Design rule: T8 is scientific model validation. It should not be mixed with the 19-rule consistency checker.
## Consistency Check Results
Physical table: `visa_consistency_check_results`
```text
id
model_version_id
rule_code
rule_type: within_table | cross_table
status: passed | failed | warning
message
related_table
related_record_id
checked_at
```
The backend should run the VISA 19-rule checker against a complete model version and persist results here. These records drive frontend check reports.
## Frontend Boundary
The frontend must not present the database as eight raw table pages. It should provide modeling workflows:
```text
Model overview
Agent and space designer
Variable editor
Behavior/function designer
Data source manager
Schedule builder
Validation and check report
Export/generate actions
```
These screens are allowed to show summaries, forms, cards, graphs, and reports. They should not make the user edit `visa_agents` or `visa_variables` as database rows.
## Adapter Contract
The adapter layer should expose two families of API responses.
Canonical VISA:
```text
GET /api/model-versions/:id/visa
```
Returns a complete T1-T8 VISA JSON object for checking, export, and code generation.
Frontend view model:
```text
GET /api/model-versions/:id/view-model
```
Returns graph nodes, graph edges, grouped variables, behavior cards, schedule timeline items, and validation summaries derived from VISA records.
Semantic edit actions:
```text
POST /api/model-versions/:id/actions/add-agent
POST /api/model-versions/:id/actions/add-variable
POST /api/model-versions/:id/actions/add-function
POST /api/model-versions/:id/actions/run-check
```
The backend translates these actions into normalized VISA writes and refreshes the derived view model.
## First Implementation Step
The current frontend can remain on `WorkflowModel` while a VISA adapter is introduced. The adapter converts:
```text
T1 Agent -> workflow nodes
T3 Sensing + T4 Internal Function -> view edges and behavior sections
T5 Associated Data -> associated data rows
T6 Input/Output -> I/O rows
T7 Schedule -> schedule rows
T8 Validation -> validation rows
```
This keeps the existing UI functional while moving the source of truth toward the backend VISA model.
+9
View File
@@ -8,6 +8,15 @@ server {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
location /api/ {
proxy_pass http://api:4000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~* \.(?:css|js|mjs|json|svg|png|jpg|jpeg|gif|ico|webp|woff2?)$ { location ~* \.(?:css|js|mjs|json|svg|png|jpg|jpeg|gif|ico|webp|woff2?)$ {
try_files $uri =404; try_files $uri =404;
expires 1y; expires 1y;
+2 -1
View File
@@ -8,7 +8,8 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"clean": "rm -rf dist", "clean": "rm -rf dist",
"lint": "tsc --noEmit" "lint": "tsc --noEmit",
"server": "tsx src/server/index.ts"
}, },
"dependencies": { "dependencies": {
"@google/genai": "^1.29.0", "@google/genai": "^1.29.0",
+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),
};
}