diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..6c17f60 --- /dev/null +++ b/Dockerfile.api @@ -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"] diff --git a/README.md b/README.md index 3df556b..06a277a 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,15 @@ View your app in AI Studio: https://ai.studio/apps/b6ca5081-8525-4b56-8fbc-92c24 `docker compose up --build -d` 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 -packages inside Docker. +packages inside the frontend Docker image. **Development server (optional, downloads a Node image and dependencies):** @@ -40,3 +46,6 @@ packages inside Docker. 2. Start the development container: `docker compose -f docker-compose.dev.yml up --build` 3. Open `http://localhost:3000`. + +The development compose stack runs Vite on `http://localhost:3000` and the VISA +API on `http://localhost:4000`. diff --git a/db/migrations/202606230001_create_visa_schema.sql b/db/migrations/202606230001_create_visa_schema.sql new file mode 100644 index 0000000..cc59c27 --- /dev/null +++ b/db/migrations/202606230001_create_visa_schema.sql @@ -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); diff --git a/db/migrations/202606230001_create_visa_schema.test.ts b/db/migrations/202606230001_create_visa_schema.test.ts new file mode 100644 index 0000000..90e7753 --- /dev/null +++ b/db/migrations/202606230001_create_visa_schema.test.ts @@ -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'); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 747cca3..2df72b8 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -11,3 +11,17 @@ services: volumes: - .:/app - /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 diff --git a/docker-compose.yml b/docker-compose.yml index 0db603b..0ebed59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,21 @@ services: build: context: . dockerfile: Dockerfile + depends_on: + - api ports: - "8080:80" restart: unless-stopped + + api: + container_name: agentblock-api + build: + context: . + dockerfile: Dockerfile.api + environment: + PORT: "4000" + expose: + - "4000" + ports: + - "4000:4000" + restart: unless-stopped diff --git a/docs/superpowers/plans/2026-06-23-visa-api-and-migration.md b/docs/superpowers/plans/2026-06-23-visa-api-and-migration.md new file mode 100644 index 0000000..d62ec94 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-visa-api-and-migration.md @@ -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`. diff --git a/docs/superpowers/plans/2026-06-23-visa-backend-foundation.md b/docs/superpowers/plans/2026-06-23-visa-backend-foundation.md new file mode 100644 index 0000000..a61b393 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-visa-backend-foundation.md @@ -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. diff --git a/docs/visa-backend-database-design.md b/docs/visa-backend-database-design.md new file mode 100644 index 0000000..f0d4611 --- /dev/null +++ b/docs/visa-backend-database-design.md @@ -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. diff --git a/nginx.conf b/nginx.conf index 9f0696d..bb9f881 100644 --- a/nginx.conf +++ b/nginx.conf @@ -8,6 +8,15 @@ server { 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?)$ { try_files $uri =404; expires 1y; diff --git a/package.json b/package.json index 4acb7dc..5c5b55f 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "vite build", "preview": "vite preview", "clean": "rm -rf dist", - "lint": "tsc --noEmit" + "lint": "tsc --noEmit", + "server": "tsx src/server/index.ts" }, "dependencies": { "@google/genai": "^1.29.0", diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..ee16d82 --- /dev/null +++ b/src/server/index.ts @@ -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}`); +}); diff --git a/src/server/visaApi.test.ts b/src/server/visaApi.test.ts new file mode 100644 index 0000000..909b70a --- /dev/null +++ b/src/server/visaApi.test.ts @@ -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(run: (baseUrl: string) => Promise) { + 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'); diff --git a/src/server/visaApi.ts b/src/server/visaApi.ts new file mode 100644 index 0000000..b1d359d --- /dev/null +++ b/src/server/visaApi.ts @@ -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; +} diff --git a/src/server/visaRepository.ts b/src/server/visaRepository.ts new file mode 100644 index 0000000..0b45cff --- /dev/null +++ b/src/server/visaRepository.ts @@ -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 | 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([[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, + }; + }, + }; +} diff --git a/src/visaModel.test.ts b/src/visaModel.test.ts new file mode 100644 index 0000000..ce8cf7b --- /dev/null +++ b/src/visaModel.test.ts @@ -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'); diff --git a/src/visaModel.ts b/src/visaModel.ts new file mode 100644 index 0000000..0473d67 --- /dev/null +++ b/src/visaModel.ts @@ -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), + }; +}