A deterministic, event-driven simulation framework for modeling complex financial workflows over time. It is purpose-built around the International Retirement scenario (US + AU cross-border retirement) and exposes those mechanics through a pluggable workbench UI, declarative scenario composition (toolsets), Monte Carlo, and parameter optimization.
Audience: this README is the orientation guide for anyone (human or AI) joining the codebase. It documents the current architecture and how to extend it. Deeper, per-feature designs live in
design/*.md. Known inconsistencies and rework candidates are tracked indesign/inconsistencies.md.
npm install
npm run dev # vite dev server on http://localhost:5173
npm run build # production app build → dist/
npm run build:lib # library build → dist-lib/ (ESM)
npm run build:all # both
npm run build:index # regenerate src/index.js (auto-generated)
npm run test:unit # node --test on tests/unit/**/*.test.mjs
npm run test:viz # jest + jsdom on tests/viz/
npm test # both
npm run requirements # check coverage against JP Spec requirementsThe dev server entry point is index.html → src/main.js → SimulationWorkbench.
┌─────────────────────────────────────────────────────────────────────────────┐
│ Browser App │
│ index.html → main.js → SimulationWorkbench → WorkbenchApp → BaseApp │
│ │
│ ┌──────────────────────── WorkbenchShell ─────────────────────────────┐ │
│ │ Dockable plugins: │ │
│ │ scenario · mc-config · opt-config · config-list · inspector │ │
│ │ config-graph · timeline · chart · mc-results · opt-results │ │
│ │ state-panel · action-detail · mc-runs · opt-runs · exec-history │ │
│ │ lineage · dashboard · perf │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ▲ reads/edits │
│ │ │
│ ┌─────────────────────── ServiceRegistry ─────────────────────────────┐ │
│ │ bus (EventBus) ─ SERVICE_ACTION ─► SimulationSync (re-wires sim) │ │
│ │ graph (Graph, single) │ │
│ │ config layer → events, handlers, actions, reducers, │ │
│ │ persons, accounts, real-properties, … │ │
│ │ execution layer → runtime instances added during stepTo() │ │
│ │ graphQueryApi · stateRegistry · schemaRegistry │ │
│ │ personService · accountService · realPropertyService · … │ │
│ │ eventService · handlerService · actionService · reducerService │ │
│ │ scenarioRegistry · scenarioService · simulationRegistry │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ▲ │
│ ┌────────────────────────── Simulation ───────────────────────────────┐ │
│ │ IndexedMinHeap (event queue) · state · journal │ │
│ │ HandlerRegistry · ReducerPipeline │ │
│ │ ExecutionGraph (records runtime nodes/edges into the same Graph) │ │
│ │ SimulationHistory (snapshots, rewind, branch) │ │
│ │ control { paused, breakpointNodeIds, pendingExecution, … } │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ▲ │
│ ┌──────────────────────── ScenarioLoader ─────────────────────────────┐ │
│ │ if cfg.toolsets: ScenarioCompiler.compile(cfg, services) │ │
│ │ else if cfg has graph: ScenarioSerializer.deserializeGraph(…) │ │
│ │ persons/accounts: ScenarioSerializer.deserializePersonsAccounts │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ▲ │
│ ┌─────────────────────── BaseScenario ────────────────────────────────┐ │
│ │ static getParamSchema() · static getToolsets() │ │
│ │ static buildDefaultConfig(params, simStart, simEnd) │ │
│ │ IntlRetirementScenario — the production scenario subclass │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
Three layers, top to bottom:
- Simulation engine (
src/simulation-framework/) — pure, deterministic. Owns the event queue, action/reducer pipeline, journal, history, and execution-graph recorder. No DOM, no domain knowledge. - Finance + service domain (
src/finance/,src/services/,src/scenarios/) — domain types (persons, accounts, real property, collectibles), per-year tax and account-rules modules, declarative toolsets, and the services that own them. Domain logic plugs into the engine via handlers and reducers. - Visualization + apps (
src/visualization/,src/apps/) — the workbench shell, plugins, editors, charts, timelines, and Monte Carlo / optimization UIs. Reads everything from the services; mutations always flow back through them.
- Vite is the build tool. Two configs:
vite.config.js— production app build (entry:index.html→src/main.js).vite.lib.config.js— library build for consumers (@terrypacker/financial-sim).
- Output dirs are
dist/(app) anddist-lib/(library, ESM-only). src/index.jsis auto-generated byscripts/build-index.jsfrom the directory tree. Regenerate after adding/removing exported modules:npm run build:index. Do not editsrc/index.jsmanually.- No runtime dependencies. Dev-only deps:
vite,jest,jest-environment-jsdom,@babel/parser,echarts(used by the graph renderer in the workbench UI).
History note: the project previously used Rollup; see design/14-vite-migration.md.
| Module | File | Responsibility |
|---|---|---|
Simulation |
simulation.js |
Orchestrator. Owns the event queue, state, journal, handler registry, reducer pipeline, breakpoint control, and ExecutionGraph. stepTo(date) dequeues events in date order and runs them through the pipeline. |
SimulationHistory |
simulation-history.js |
Snapshot array, rewind, replay, branching. snapshotCursor, eventCounter. |
SimulationState |
simulation-state.js |
Helpers for assigning stable stateKey slots to accounts/people inside sim.state. |
ExecutionGraph |
execution-graph.js |
Records runtime execution into the same singleton Graph as the config layer. Edge types: INSTANCE_OF, EXECUTES, EMITS, SCHEDULES. |
GraphRecorder |
graph-recorder.js |
Subscribes to EXECUTION_BEGIN/EXECUTION_END and writes nodes/edges via ExecutionGraph. |
EventBus |
event-bus.js |
Pub/sub with wildcard support. Keeps a full message history for replay/debug. Used both for SERVICE_ACTION and for execution telemetry. |
| Bus messages | bus-messages.js |
BusMessage, SimulationBusMessage, ExecutionBusMessage, BreakpointHitMessage. Discriminators: type / subtype / kind (EVENT/HANDLER/ACTION/REDUCER). Hierarchical executionId (e.g. e1.1:h1.1:a1.1). |
Action and subclasses |
actions.js |
Action, FieldAction, FieldValueAction, AmountAction, RecordBalanceAction, ScriptedAction, RecordMetricAction, RecordArrayMetricAction, RecordNumericSumMetricAction, RecordMultiplicativeMetricAction. All extend SimGraphNode (kind: 'action', layer: 'config'). actionClass getter returns constructor.name — used by the serializer; do not let minifiers strip these names. |
ActionDefinition |
actions.js |
Template that handlers carry in generatedActionDefinitions. instantiate(ctx) builds a fresh runtime Action with a UUID instanceId. |
HandlerEntry / HandlerRegistry |
handlers.js |
A HandlerEntry wraps a function with handledEvents, generatedActionTypes (for graph edges), and generatedActionDefinitions (for runtime instantiation). |
Reducer + subclasses + ReducerPipeline |
reducers.js |
Priority-ordered reducer chain keyed by action type. Subclasses: Reducer, AccountTransactionReducer, ArrayReducer, NumericSumReducer, MultiplicativeReducer, FieldReducer, FieldValueReducer, NoOpReducer, RepeatingReducer, ScriptedReducer. |
Journal / JournalEntry |
journal.js |
Append-only (action, prevState, nextState) log; supports filtering, timelines, and traces. |
BaseEvent / EventSeries / OneOffEvent |
events/ |
Recurring vs one-off scheduled events. Series intervals: monthly, quarterly, annually, month-end, year-end, plus month/day-anchored data.periods events (see design/0-period-engine.md). |
MinHeap / IndexedMinHeap |
min-heap.js / indexed-min-heap.js |
Priority queues keyed on event date. Indexed variant supports removal by instanceId + type. |
DateUtils |
date-utils.js |
Stateless date arithmetic. |
Distributions |
distributions.js |
Seeded RNG distributions (uniform, normal, lognormal, beta) for Monte Carlo. |
ScenarioRunner |
scenario.js |
Batch and Monte Carlo runner; summarize() returns mean / p10 / p50 / p90 / success rate. |
| Constant | Value | Purpose |
|---|---|---|
PRE_PROCESS |
10 | Validation, normalization |
CASH_FLOW |
20 | Cash credits / debits |
POSITION_UPDATE |
30 | Portfolio position changes |
COST_BASIS |
40 | Cost-basis math |
TAX_CALC |
60 | Tax computation |
TAX_APPLY |
70 | Tax payment / withholding |
METRICS |
90 | Derived metrics / KPIs |
LOGGING |
100 | Audit logging |
Every execution unit (event / handler / action / reducer) publishes paired EXECUTION_BEGIN + EXECUTION_END messages with a hierarchical executionId. GraphRecorder consumes them to build the runtime execution graph in the same singleton Graph as the config layer. UI plugins (exec-history, lineage, perf) read from graphQueryApi.
sim.control.breakpointNodeIds is a Set<string> of config-graph node IDs. Before each handler / action / reducer call, the simulation checks the set, snapshots a pendingExecution resume context, and throws an internal BreakpointSignal caught inside stepTo(). The paused node has not executed yet — sim.state reflects the state immediately before it.
Resume paths inside _resumeFromPendingExecution():
pendingExecution.type |
Resume entry point |
|---|---|
handler |
execute(event, { startHandlerIdx: i }) |
action |
_processActionQueue([pausedAction, ...rest]) then remaining handlers |
reducer |
_processReducers(action, j, …) then remaining actions, then remaining handlers |
control.breakpointsEnabled is forced off during rewind/replay so snapshot restoration cannot trip a breakpoint.
When you do build wiring by hand (most scenarios should use toolsets — see below), prefer the fluent builders over raw constructors:
ActionBuilder.amount().type('SALARY').name('Monthly Salary').value(8000).build();
ReducerBuilder.metric('salary').name('Record Salary').build();
HandlerBuilder.fn(ctx => [...this.generatedActions])
.name('Salary Handler').handledEvent(monthEnd).generatedAction(salary).build();
EventBuilder.series().type('MONTH_END').interval('month-end').build();
EventBuilder.oneOff().type('BONUS').date(new Date(...)).build();The central singleton. Reset on every scenario rebuild via ServiceRegistry.reset(). Holds:
bus— the sharedEventBus.graph— the singleGraphinstance carrying both config and execution layers.graphQueryApi— read-only query facade overgraph.- Domain services:
personService,accountService,realPropertyService,collectibleService. - Wiring services:
eventService,handlerService,actionService,reducerService. scenarioService,scenarioRegistry,simulationRegistry,simulationSync.stateRegistry(semantic state lookups) andschemaRegistry(state-field formatting).simulationContext— a bundled prop bag passed to scenarios.
ScenarioRegistry is intentionally preserved across reset() so the user's in-memory param edits and active-scenario selection survive a Rebuild. Use ServiceRegistry.resetAll() in tests to wipe it too.
All "wiring" services extend BaseService and own a Map<id, item>. Each domain item is also a SimGraphNode and lives in the shared Graph. Mutations follow:
UI edit → service.update(id, changes)
├── update map + graph
└── publish SERVICE_ACTION on bus
└── SimulationSync re-wires the active Simulation
(re-schedules events, re-registers reducers, …)
ConfigGraphView and the workbench panels are display-only — they read through graphQueryApi. The graph is not a source of truth on its own; the service maps are.
ID prefixes: EventService → e, HandlerService → h, ActionService → a (Action ids default to the action type string), ReducerService → r. Domain objects start with id = null and the service assigns the id on register() / create().
Look up state objects by semantic role + optional ownerId rather than hard-coded state.xxxAccount keys. Roles live in src/finance/state/account-roles.js:
ACCOUNT_ROLES.ROTH, ACCOUNT_ROLES.IRA, ACCOUNT_ROLES.K401,
ACCOUNT_ROLES.US_SAVINGS, ACCOUNT_ROLES.US_STOCK, ACCOUNT_ROLES.FIXED_INCOME,
ACCOUNT_ROLES.AU_SAVINGS, ACCOUNT_ROLES.AU_STOCK, ACCOUNT_ROLES.SUPER,
const rothState = stateRegistry.getAccount(state, ACCOUNT_ROLES.ROTH, primary.id);
const stateKey = stateRegistry.getStateKey(ACCOUNT_ROLES.ROTH, primary.id);This is the prerequisite for spouse accounts and multi-person households — handlers no longer assume a specific key shape.
Maps state field paths → ValueType descriptors (currency('USD'), rate(), percentage(), …) so the UI can format raw journal values consistently. Resolution order: exact path → first glob match → unknown. registerAccount(stateKey, account) stamps exact paths from a registered Account, overriding glob matches.
A scenario declares (a) the persons / accounts it operates on and (b) the toolsets that contribute schedules, handlers, and reducers. The ScenarioCompiler consumes that declarative input and registers everything with the simulation services.
Thin coordinator. Subclasses override three statics:
class IntlRetirementScenario extends BaseScenario {
static getParamSchema() { /* typed param entries */ }
static getToolsets() { /* ['US_RETIREMENT', 'AU_RETIREMENT', …] */ }
static buildDefaultConfig(params, simStart, simEnd) { /* persons + accounts + toolsets */ }
}buildDefaultConfig() returns:
{
toolsets: [...], // toolset IDs (strings)
parameters: { ... }, // key → value (flat map)
params: [ ... ], // typed UI form: { name, label, type, group, value, node? }
persons: [ ... ],
accounts: [ ... ],
realProperties: [ ... ],
collectibles: [ ... ]
}Single entry point for populating services from a serialized scenario:
- If
cfg.toolsets?.length > 0→ runScenarioCompiler.compile(cfg, services). The compiler mutatessim.stateand registers schedules/handlers/reducers. The resulting state is snapshotted back intocfg.initialStateand the compiled graph is snapshotted intocfg.events/handlers/actions/reducersso the next save/load can round-trip. - Else if the cfg has a saved graph → restore via
ScenarioSerializer.deserializeGraph(cfg, services). Used for hand-built scenarios that don't declare toolsets.
Persons / accounts / real-properties / collectibles are restored via ScenarioSerializer.deserializePersonsAccounts() before the compile step, so the toolset compiler can read them.
cfg.params is also param→node-cascaded: any param schema entry with a node declaration (e.g. { type: 'person', id: …, field: 'birthDate' }) drives a field update on the matching cfg.persons / cfg.accounts record before compilation, so edited UI params propagate into the compiled scenario.
A toolset is a plain object:
export const US_RETIREMENT = {
id: 'US_RETIREMENT',
capabilities: ['retirement'],
dependencies: ['US_BANKING', 'US_TAX', 'US_INCOME', 'US_BROKERAGE'],
paramSchema(context) { return [ /* typed param entries */ ]; },
state(context) { return { /* state patches */ }; },
schedules(context) { return [ /* EventSeries / OneOff */ ]; },
handlers(context) { return [ /* HandlerEntry */ ]; },
reducers(context) { return [ /* Reducer */ ]; },
};ToolsetRegistry maps IDs → toolset objects. ScenarioLoader ships with the production set: US_BANKING, US_TAX, US_RETIREMENT, US_INCOME, US_BROKERAGE, US_REAL_PROPERTY, US_COLLECTIBLES, US_ROTH_CONVERSION, AU_BANKING, AU_TAX, AU_RETIREMENT, AU_INCOME, AU_BROKERAGE, AU_REAL_PROPERTY, US_AU_CROSS_BORDER.
ScenarioCompiler.compile():
- Resolves the toolset dependency graph (DFS, no duplicates).
- Builds a
contextobject withparameters,paramSchema,services,persons,accounts, etc. - Collects state patches, schedules, handlers, reducers from each toolset (in dependency order).
- Warns on duplicate
EventSeriestypes. - Patches
sim.state(viastructuredClone), then registers everything with the services.
The production scenario. Combines all US + AU + cross-border toolsets, exposes ~50 typed parameters via getParamSchema(), and seeds defaults in INTL_RETIREMENT_DEFAULTS. See src/scenarios/intl-retirement-scenario.js.
ScenarioSerializer:
_serializeEvent/_serializeHandler/_serializeReducer/_serializePerson/_serializeAccount/_serializeRealProperty/_serializeCollectible— per-type serializers.deserializeGraph(cfg, services)— reconstructs graph nodes via concrete-class registries (_ACCOUNT_SERVICE_REDUCERS,_NO_ARG_HANDLERS, …).deserializePersonsAccounts(cfg, services)— domain objects only.hasSerializedGraph(cfg)— guard for the fallback branch.
ScenarioStorage wraps localStorage (a separate InMemoryStorage exists at src/storage/ for tests).
Person—birthDate,lifeExpectancy,citizen(array, e.g.['US']or['US','AUS']),monthlyWage,socialSecurityMonthly,retirementDate. Residency is derived fromstate.isAuResident, not stored on Person.Account/CheckingAccount/SavingsAccount/InvestmentAccount/BrokerageAccount/FourOhOneKAccount/RothAccount/TraditionalIRAAccount/SuperannuationAccount.RealPropertyandCollectible— appreciation, planned sale year, primary-residence handling.- Each account is stamped at registration with:
stateKey(slot insim.state),currency(USD/AUD),role(ACCOUNT_ROLESvalue), and ownership (ownerId,ownershipType).
TaxEngine is a per-country, per-year registry of BaseTaxModule instances.
get(countryCode, year)returns the highest registered year<= year, falling back to the earliest year if none qualifies.registerDynamic(pipeline, countryCodes)— registers per-action-type wrapper reducers atPRIORITY.TAX_CALCthat readstate.currentPeriods[cc]at runtime to determine which year's module to delegate to. This is what makes multi-year simulations honor year-over-year rule changes.
Production modules: UsTaxModule2024/2025/2026, AuTaxModule2024/2025/2026, with rate tables in *-rates-*.js and per-year forms in *-tax-document-*.js registered via TaxDocumentRegistry.
Same registry pattern as TaxEngine. Per-year UsAccountModule2024/2025/2026 and AuAccountModule2024/2025/2026 encode contribution limits, RMD rules, super preservation, early-withdrawal penalties, etc. Handler+reducer pairs for each account type live in dedicated files (ira-classes.js, k401-classes.js, roth-classes.js, roth-rollover-classes.js, ira-rollover-classes.js, roth-conversion-classes.js, au-super-classes.js, au-brokerage-classes.js, us-brokerage-classes.js, us-rmd-uniform-table.js, etc.).
PeriodService + builders (buildUsCalendarYear, buildAuFiscalYear) define fiscal/calendar periods. state.currentPeriods[cc] is the per-country current-period pointer the dynamic tax reducers consult. PERIOD_ADVANCE and TAX_SETTLE are now single EventSeries instances driven by data.periods rather than N one-off events per year.
src/finance/handlers/— high-level event handlers (MonthlyWagesHandler,MonthlySocialSecurityHandler,MonthlyExpensesHandler,OutOfFundsHandler,DividendScheduledHandler,ChangeResidencyHandler,IntlTransferHandlers,*EarningsHandler, ...). Account-module-specific handlers (e.g.RothContributionHandler,K401AnnualRmdHandler) are colocated with their reducers underaccount-rules/.src/finance/reducers/— top-level reducers (ExpenseDebitReducer,ReplenishSavingsReducer,OutOfFundsReducer,AccumulateDeficitReducer,SetOutOfFundsDateReducer,InflationAdjustReducer,ChangeResidencyApplyReducer, ...).state.scenarioFailedis set when funds run out;ScenarioRunner.summarize()reports a success rate alongside p10/p50/p90.
IntlRetirementMcConfig— declarative MC config (per-param distribution).IntlRetirementMcRunner— runs N simulations, evaluates objectives, returns per-run results + summary.
IntlRetirementOptConfig,IntlRetirementOptimizer,OptimizationObjectives.- Iterates parameter space toward a configured objective (max final balance, min cumulative deficit, etc.).
Dockable, plugin-based UI. Composition:
WorkbenchShell— assembles the layout (outer vertical split: main / bottom; inner horizontal split: left / center / right, with optional nested center-a / center-b split).WorkbenchLayoutModel— persisted layout (per-storage-key) + saved-template management.TabGroup,SplitPane,WorkbenchComponent— the dock primitives.PluginRegistry,WorkbenchRuntime— plugin descriptor registry and shared event bus for plugins (SCENARIO_READY,BREAKPOINT_HIT, etc.).plugin-sdk.jsexportsdefinePlugin()plusPLUGIN_CATEGORIES/PLUGIN_PANES.
The current finance plugin set, exported as FINANCE_PLUGINS + FINANCE_DEFAULT_LAYOUT:
| Plugin ID | Title | Purpose |
|---|---|---|
scenario |
Scenario | Scenario picker + param editor + Rebuild / Load Defaults / Save |
mc-config / mc-results / mc-runs |
Monte Carlo | Configure, run, and inspect MC sweeps |
opt-config / opt-results / opt-runs |
Optimize | Same triad for optimization |
config-list |
Nodes | Flat list of all graph nodes (search + add) |
inspector |
Edit | Selected-node editor (mounts the right *-editor.js) |
config-graph |
Graph | The dockable SVG/echarts node-and-edge canvas |
timeline |
Timeline | Journal timeline with filters + CSV download |
chart |
Chart | Time-series chart (ECharts) of selected state series |
state-panel |
State | Live state inspector + per-node state-change diffs |
action-detail |
Action Detail | Selected journal-entry details + payload + before/after |
exec-history |
Node History | Per-node execution history (uses ExecutionGraph) |
lineage |
Lineage | Upstream/downstream causal tracing for a selected execution node |
dashboard |
Dashboard | Headline KPI cards |
perf |
Performance | Sim-loop performance telemetry |
WorkbenchApp (src/apps/workbench-app.js) instantiates the shell with these plugins and offers built-in workspace templates: Default, Analysis, Debugging, Review. Users can save their own templates per storageKey (sim-workbench-layout-prod).
accounts/,people/,assets/— domain editors mounted by the Configuration List (configuration/configuration-list.js) and the Node Edit Modal (components/node-edit-modal.js).graph-builder/—ConfigGraphView,GraphBuilderPresenter, the orthogonal edge router, collision detection, column layout, the graph inspector panel, exec history, lineage.components/— base editors (event-editor,handler-editor,action-editor,reducer-editor), node-edit modal, multi-select chips, the ECharts graph renderer + node-renderer registry.chart/,timeline/,simulation/,monte-carlo/,optimization/,scenario/— Presenter/View/Controller triads for each plugin's content.
BaseApp(base-app.js) — composition root. Instantiates presenters / views / controllers, wires the scenario lifecycle (initScenario/destroyScenario), and bridges sim-bus events to the workbench runtime.WorkbenchApp(workbench-app.js) — replaces the legacy static DOM withWorkbenchShell-based layout; adds workspace-template handling and bus-bridging.SimulationWorkbench(simulation-workbench.js) — the production entry point. Registers prebuilt scenarios (currently justIntlRetirementScenario) and the chart series.
src/index.js is the library entry point and is auto-generated by scripts/build-index.js. Run npm run build:index whenever you add or remove an exported class. The library build (vite.lib.config.js, npm run build:lib) emits ESM only into dist-lib/.
"exports": { ".": { "import": "./dist-lib/index.esm.js" } }Exported namespaces include Core (simulation engine + builders + actions + reducers + journal + PRIORITY + ScenarioRunner), Finance (accounts, persons, periods, TaxEngine, AccountRulesEngine, jurisdiction modules), Misc (BaseApp, BaseScenario), and Visualization. The exact surface follows whatever build-index.js discovers under src/ — re-run it after structural changes.
Action.actionClass, Reducer.reducerType, and HandlerEntry.handlerClass all return this.constructor.name. These drive serialization round-trips and editor dispatch. The library Vite config preserves class names; if you swap minifiers, verify the equivalent option is enabled or save/load and type dispatch will silently break.
npm run test:unit # node --test tests/unit/**/*.test.mjs (~77 unit files)
npm run test:viz # jest + jsdom tests/viz/
npm test # bothTests import directly from src/ (no build step), so all imports use explicit .js extensions. The tests/helpers/assert.js helper provides Assert.datesEqual for time-of-day-tolerant date checks. Mocks live in tests/__mocks__/.
Notable suites:
| Category | Where |
|---|---|
| Simulation core | simulation.test.mjs, simulation-history.test.mjs, simulation-breakpoints.test.mjs, simulation-event-graph.test.mjs, journal.test.mjs, event-bus.test.mjs |
| Builders / domain | action-builder.test.mjs, reducer-builder.test.mjs, handler-builder.test.mjs, event-builder.test.mjs, reducers.test.mjs, action-definition.test.mjs |
| Finance domain | account.test.mjs, asset.test.mjs, investment-account.test.mjs, person.test.mjs, period-service.test.mjs, asset-rules.test.mjs |
Per-event scenarios (EVT-X convention) |
evt-401k.test.mjs, evt-ira.test.mjs, evt-roth.test.mjs, evt-roth-conversion.test.mjs, evt-us-brokerage.test.mjs, evt-au-brokerage.test.mjs, evt-real-property.test.mjs, evt-collectible.test.mjs, evt-super.test.mjs, evt-au-savings.test.mjs, evt-income.test.mjs, early-withdrawal.test.mjs, inflation.test.mjs |
| Tax | tax-rates.test.mjs, tax-documents.test.mjs |
| Toolsets / scenarios | toolset-compiler.test.mjs, toolset-us-retirement.test.mjs, toolset-au-retirement.test.mjs, toolset-cross-border.test.mjs, toolset-joe-retirement.test.mjs, toolset-mortgage-payment.test.mjs, intl-retirement-scenario.test.mjs, intl-retirement-mc-runner.test.mjs, intl-retirement-optimizer.test.mjs |
| Services / serialization | service-registry.test.mjs, base-scenario.test.mjs, scenario-loader.test.mjs, scenario-serializer.test.mjs, scenario-roundtrip.test.mjs, serializer-finance-roundtrip.test.mjs, serializer-framework-execution.test.mjs, state-registry.test.mjs, type-registries.test.mjs |
| Graph / viz | graph-geometry.test.mjs, graph-query-api.test.mjs, orthogonal-edge-router.test.mjs, column-layout.test.mjs, collision-detector.test.mjs, simulation-animator.test.mjs |
scripts/check-requirements.js (npm run requirements) cross-references the JP Spec xlsx against EVT-X: test names.
- State is plain data. No class instances with methods inside
sim.state.structuredCloneis used for snapshots. Use service objects (AccountService,StateRegistry, …) outside the state to operate on plain state data. Re-stamping happens at deserialization. - Handlers return actions; reducers return state. Handlers bridge events to the reducer pipeline; reducers must be pure.
- IDs are owned by services. Domain objects start with
id = null(exceptAction, which setsid = typein its constructor). Never assign IDs manually outside a service. - All UI mutations flow through services. Editors call
service.updateX(id, changes); they never mutate domain objects in place. The bus +SimulationSyncre-wires the active sim. - Look up state by role, not by stateKey string. Use
stateRegistry.getAccount(state, ACCOUNT_ROLES.ROTH, ownerId)instead ofstate.rothAccount. This is what makes multi-person households work. - Format state for display via
StateSchemaRegistry. Register exact paths or patterns when adding new state fields so the chart/state-panel/timeline render correct currency / precision. - Prefer the toolset path over hand-built scenarios. Add a new toolset when you need new domain mechanics; only fall back to hand-built
loadDefaults/ScenarioSerializershapes for one-offs. - Imports use explicit
.jsextensions (even from.mjstest files). Tests import directly fromsrc/. src/index.jsis auto-generated. Runnpm run build:indexafter restructuring exports.- No inline styles for structural layout. Use CSS classes (
assets/css/...). Only truly dynamic runtime values (drag positions, computed heights) belong inline. - No runtime dependencies. Keep
dependenciesempty; everything user-facing must work in the browser without a bundler at consumer-time. - UI changes need browser verification. Type checks / unit tests prove correctness but not feature behavior — start the dev server and exercise the feature path before reporting done.
- Decide which toolset owns it (
US_RETIREMENT,AU_BROKERAGE, …) or create a new toolset undersrc/scenarios/toolsets/. - Add handler + reducer pair under
src/finance/handlers/andsrc/finance/reducers/(or colocate insideaccount-rules/<cc>/<account>-classes.jsif it belongs to an account module). - Wire it into the toolset's
schedules()/handlers()/reducers(). - Add an
evt-<feature>.test.mjsundertests/unit/following theEVT-X:naming convention (used bynpm run requirements). - If it touches new state fields, register their
ValueTypeinStateSchemaRegistry.
- Drop a new
Us|AuTaxModule20XXundersrc/finance/tax/us|au/, extendingBaseTaxModule. - Add a matching
*-tax-rates-20XX.jsand (if applicable)*-tax-document-20XX.js. - Register in the relevant toolset's
state()step (taxEngine.register(...)) andTaxDocumentRegistryif you added a document. - The dynamic per-year reducer (
TaxEngine.registerDynamic) automatically picks up the new year — no caller-side dispatch needed.
- Subclass
WorkbenchComponent(src/visualization/workbench/component.js). - Construct a descriptor with
definePlugin({ id, title, component, category, defaultPane }). - Add it to
FINANCE_PLUGINSinsrc/visualization/workbench/plugins/finance/finance-plugin-package.js, and to whichever workspace templates should include it by default. - The plugin gets the
WorkbenchRuntimein its constructor — subscribe to runtime events (SCENARIO_READY,BREAKPOINT_HIT, …) and read services viaServiceRegistry.getInstance().
- Add a typed entry to the owning toolset's
paramSchema(context)(key, label, type, group, defaultValue,mc,opt). - Reference it in the toolset's
state()/schedules()/handlers()viacontext.parameters.<key>. - If the param should drive a person/account field, add a
node: { type, id|stateKey, field }declaration soScenarioLoader's cascade applies it before compile.
Architecture decisions and forward-looking proposals live in design/:
0-period-engine.md— UTC-ms period model.1-adjustment-entry-system.md— manual ledger adjustments.2-unified-event-schema.md— single event stream proposal.3-branching-event-streams.md/4-branch-diff-insight-engine.md/5-branch-merge-reconciliation.md— branching simulations.6-workbench-ui.md,7-workbench-ui-plan.md— workbench overhaul (implemented).8-serialization-test-plan.md.9-toolset-compiler.md— toolset architecture (implemented).10-display-settings-service.md.11-taxservice-declarative-refactor.md.12-toolset-ownership-refactor.md.13-prebuilt-scenario-parameters.md— typed param round-tripping (implemented).14-vite-migration.md.
Known structural issues, leaky boundaries, and rework candidates: design/inconsistencies.md.
Apache 2.0. See LICENSE.