octopusgames
Research/Twin runtime architecture

Is there a Digital Twin Runtime inside Plant and Factory?

Architecture research report. 2026-09-18. No code was changed; every claim below points at the existing files.

Repositories studied:


0. The answer first

Yes, but for a much smaller runtime than the question implies. The two applications share a real, non-cosmetic core: a pure step function over a plain state object, driven by a piecewise-constant input schedule, cached as a trajectory that is immutable in the past and recomputable in the future, with branching by structural sharing and causality derived purely from trajectory differences. Plant already implements all of that in ~400 lines (twin.ts 111, causal.ts 283) and none of it mentions tomatoes in its control flow. Factory implements the first quarter (the step function) and lacks the trajectory, which is exactly the part it would need to gain counterfactuals, "why" questions and observation correction.

What the evidence does not support is a large framework of TwinDefinition/Entity/Environment/CausalGraph classes. The two domains agree on control flow and disagree on almost every data shape: organs versus power flows, integer days versus fractional hours, immutable functional updates versus in-place mutation, derived events versus pushed events. Everything that touches those shapes should stay in the domain.

The smallest useful common runtime is five things:

Kernel piece Plant already has it Factory already has it Cost to make generic
Pure stepper S × Inputs × dt → S stepDay (model.ts:124) step (engine.ts:265) but inputs are hidden inside S Small (signature only)
Input schedule (piecewise inputs by time) EnvSegment[], envAt, setEnvFrom, applyWindow (twin.ts:26–59) Scenario flags mutated into state (engine.ts:234) Small
Trajectory with recompute-from-t and shared-history clone states[], computeFrom, clone (twin.ts:33–70) Absent (live state + 7-frame snapshots) Small, already written once
Variable registry + divergence detection + time-to-effect CAUSAL_VARS, analyze (causal.ts:29–126) Absent Small; the registry entries stay domain
Deterministic RNG + invariant checks hash32(seed, id, salt) (rng.ts), sim-check.ts balanceResidual (engine.ts:492), verify.mjs Small

Together these are roughly 500 lines of TypeScript with no dependency on three.js, React or zustand. That is the runtime. Everything else in the wish list (Entity, Environment, Prediction, Uncertainty, CausalGraph, TelemetryAdapter) either has one real implementation, zero real implementations, or two implementations that share a word and nothing else. Those are recorded below as deferred or domain, with the reason.


1. Existing-system comparison

Concept by concept. "Common" means a mechanism both codebases already exhibit or could adopt with a signature change; "Remainder" is what must stay in the domain package.

STATE

Plant Factory
Shape PlantState (types.ts:119): aggregates + Physiology + organ arrays (axes, leaves, trusses, flowers, fruits) + nextId SimState (engine.ts:41): ~35 flat scalars + flows + today accumulators + totals + events[] + refuelQueueKg
Update style Functional: cloneState(prev) then mutate the clone, return it (model.ts:60,128) In place: step(s, dt) mutates s (engine.ts:265)
Derived fields inside state finalizeAggregates recomputes height, lai, counts, canopyWidth (via layout) (model.ts:104) KPIs renewableShare, carbonIntensity, selfSufficiency recomputed each step (engine.ts:455)
Inputs stored in state env (the environment applied that day) weather, scenario, gridAvailable, cloudTimer, refuelQueueKg (inputs and transients live in the state)
Snapshotting for UI none needed; states are immutable values snapshot() shallow copy every 7 frames (store.ts:131)

Common: state is a plain serialisable object; JSON.stringify equality is already the determinism test in Plant (sim-check.ts). The runtime should treat S as an opaque generic and require only clone(S) and equals-by-serialisation.

Remainder: all field names. Organ arrays with ids and the nextId counter are Plant's; the flow ledger and daily accumulators are Factory's.

TIME

Plant Factory
Unit Integer day, 0…120 (MAX_DAY) Fractional hour with day counter; hour wraps at 24
Step Exactly one day per stepDay Variable dtHours, caller splits into ≤ 1-minute substeps (Scene.tsx:35)
Continuous view stateAt(t) linearly interpolates organs between days (twin.ts:72, model.ts:376) State is already continuous; agents get a separate visualDt
Speed presets 1 / 10 / 100 / 500 → days per second (store.ts:9) 0 / 1 / 10 / 100 / 500 → sim-seconds per real second (store.ts:8)
Scrubbing setDay anywhere; past and future both available setHour jumps the live clock; no past
Horizon Fixed 120-day episode, always fully computed Open-ended, day rolls over forever

Common: a Clock { t, dt, speed } and the rule "the stepper never reads wall-clock time". Both apps already keep animation time apart from simulation time (wind.uTime in Plant, runtime.clock / visualDt in Factory).

Remainder: epoch vs continuous is a genuine difference, not a naming difference. The runtime must support both an episodic trajectory (fixed grid, N states) and a rolling trajectory (fixed step, sliding window). Do not unify them into one timeline class with flags; provide one Trajectory<S> over a fixed step dt and let Plant use dt = 1 day and Factory dt = 1 minute with a window.

ENTITY

Plant Factory
Entities Organs: Axis, Node, Leaf, Truss, Flower, Fruit; each with numeric id and a structural address (L:m:12, R:b9:0:2) computed by organAddresses (layout.ts:49) Facilities: FACILITIES static table (layout.ts:153); their state is flattened into SimState scalars (batterySoc, electrolyserPower …). Buildings/PV areas are static config (config.ts)
Birth/death Organs are created and dropped during the step (s.leaves.filter, nextId++) Fixed population
Cross-branch identity Addresses, not ids: "matched by structural address … so it works across trajectories" (causal.ts:232) Facility ids are global constants, so trivially stable

Common: the only thing both need is addressable values: a string path that resolves to a number in any state of any branch. Plant already reduces entities to (addr → value) maps before diffing (causal.ts:233–249). The runtime should standardise the accessor, not the entity.

Remainder: entity lifecycle. Forcing Factory's scalars into an Entity[] collection would add indirection to a model whose whole value is being 30 lines of arithmetic per device. Do not do it.

OBSERVATION

Plant Factory
Record type PlantObservation { timestamp, source: 'SIMULATED' | 'MEASURED', plantHeight, leafArea, biomassEstimate, temperature, humidity, CO2, PAR, soilMoisture } (types.ts:162) None
Producer twin.observation(day) reads it out of the simulated state (twin.ts:97) None
Consumer TwinStatePanel displays "OBSERVED STATE · SIMULATED OBSERVATION" (TwinStatePanel.tsx:40) None
Ingest interface ObservationSource { observe(day) } declared, never implemented (twin.ts:111) README promises "a telemetry feed could replace step()" (README.md:75)
Correction of state None None

Common: nothing executable yet. Both have the intent and the same shape of intent: measurements arrive as flat channels keyed by name with a timestamp and a source tag. Section 7 designs this.

ENVIRONMENT

Plant Factory
Definition Environment { light, water, temperature, co2, nutrients, humidity } — six controlled greenhouse inputs (types.ts:9) Computed inside the step: sunAt(hour) (astronomy, sun.ts) × WEATHER[s.weather].pvFactor × cloudFactor (engine.ts:289); loadProfile(hour) (engine.ts:203)
How it varies Piecewise-constant schedule set by the user Function of time plus a categorical weather id set by the user
Relation to intervention Identical: an intervention is a new environment segment Distinct: weather is environment, gridAvailable/loadMult are interventions, both are bundled in Scenario

Common: an Inputs(t) provider that the stepper receives as an argument. Plant's envAt(day) is one; Factory's would be { sun: sunAt(hour), weather: WEATHER[id] }.

Remainder: what an environment contains. Plant's environment is a controlled variable (the grower sets water); Factory's is largely uncontrolled (the sun). The runtime must not pretend to know which is which; that is a per-channel provenance/controllability flag (section 10), not a class hierarchy.

DYNAMICS

Plant Factory
Function stepDay(prev, env, { seed, species }) → next (model.ts:124) step(s, dtHours) (engine.ts:265)
Purity Pure given arguments; randomness via rand01(seed, organId, salt) Pure given s and dt except: module globals eventId (engine.ts:138) and _sunset cache (engine.ts:216); reads PLANT/WEATHER constants
Parameters SpeciesParams derived from DSSAT records (species.ts:113) PLANT constant object (config.ts:6)
Internal forecast none EMS estimates expectedSurplusMWh from sunsetHour() to decide battery/electrolyser sharing (engine.ts:346–353) — a small predictor embedded in the dynamics
Side effects none pushEvent from inside the step (engine.ts:276, 451)

Common: the DynamicsModel<S, I, P> signature (state, inputs, params, dt, rng) → state plus a version string. Both fit after Factory moves weather, scenario and refuelQueueKg from state to inputs (they can also stay mirrored in state for display).

Remainder: the equations. Nothing inside either step function generalises, and nothing should.

SIMULATION (the driver loop)

Plant Factory
Driver DigitalTwin.computeFrom(day) runs the whole episode eagerly at construction and on every change (twin.ts:33) R3F Ticker runs step every frame with sub = ceil(simHours / (1/60)) substeps (Scene.tsx:29–39)
Warm-up none needed createSim steps from midnight to the start hour so accumulators are coherent (engine.ts:185)
Cost Full 120-day run measured in sim-check.ts (milliseconds) 600 substeps at startup, 1–8 per frame
Headless npm run sim runs the engine under Node with no browser (package.json) Engine is importable without three, but the only driver is the render loop; verify.mjs drives it through the browser

Common: a driver that advances a trajectory by whole steps independent of frames. Plant has it. Factory's loop is frame-coupled: the substep size h = simHours / sub depends on the frame's dt, so two runs of the same scenario produce slightly different trajectories. The runtime's fixed-step accumulator fixes this for free.

INTERVENTION

Plant Factory
Form Environment segment from day D (setEnvFrom) or a window [start, start+duration) (applyWindow) (twin.ts:42–59) applyScenario(s, id) mutates flags, jumps the clock, starts a cloud transient, pushes alarm events (engine.ts:234); setHour, setWeather from the store
Applies to Future only; past states are never rewritten (the README states this as a design rule) The live present; there is no past
Recorded Yes: segments[] carry label; interventionsOf reconstructs the event list from segments (causal.ts:80) Only as a text event in events[] (capped at 40)

Common: an intervention is an edit to the input schedule from time t. Both apps' interventions reduce to that: Plant literally, Factory once gridAvailable, loadMult, pvBoost, weather are schedule channels. The window form (applyWindow) is generic and useful for maintenance/outage interventions in Factory ("grid outage 14:00–15:30").

Remainder: clock jumps (setHour: 22.5 in Factory's night scenario) are a view action, not an intervention, and should be treated as such.

TRAJECTORY

Plant Factory
Storage states: PlantState[], index = day (twin.ts:14) None. events[] is the only history and is capped at 40 entries (engine.ts:199)
Recompute computeFrom(d) truncates and re-steps d+1…120 n/a
Branch clone() copies segments, shares states by reference (this.states.slice() of immutable values) (twin.ts:62) n/a
Cost of a branch One array copy plus the recompute from the branch day n/a

Common: Trajectory<S> is the single most valuable transferable piece. It is already domain-free in Plant: it calls stepDay and envAt and nothing else.

Remainder: Plant's interpolation between grid points (interpolateState) knows organs; it is a domain lerp(S, S, f) hook.

COUNTERFACTUAL

Plant Factory
Mechanism Three store slots: compare (alternative branch from day), recovery (branch of the alternative), reference (automatic clone of the schedule before the last edit, so a single trajectory still has a counterfactual) (store.ts:31,121–147,171) None; switching scenario overwrites the live state
Preview without commit previewIntervention clones, applies, reports Day-120 rows (causal.ts:275) None
Rendering Split panes, one synchronised camera, N PlantRenderers each bound to a DigitalTwin (Scene.tsx:211–260) n/a

Common: branch(trajectory, t, edit). The reference trick (keep the pre-edit branch automatically) is domain-independent and worth keeping in the kernel.

CAUSALITY

Plant Factory
Divergence detection Per registered variable, first day where the difference exceeds a threshold and persists to the next day (causal.ts:101–118) None
Time-to-effect First divergence per tier (environment → physiology → morphology → reproductive) (causal.ts:122–124) None
Explanation chain Hand-written ordering tables RESPONSE_OF, CORE, TAIL filled with derived events (causal.ts:186–219) None
Graph Static GRAPH_NODES/GRAPH_EDGES with x/y layout, activation from divergences (causal.ts:133–180) None
Organ deltas Address-matched diff of leaves, flowers, fruits (causal.ts:233) None
Limiting factors Re-evaluates the model's own response functions on a state (causal.ts:256) None (but flows already expose which source served which sink, which is the analogous decomposition)
Q&A Regex intent matching over the analysis (ask.ts) None

Section 9 draws the boundary. Short version: divergence, persistence, tiers-by-order and time-to-effect are generic; chains, graph, organ matching, limiting factors and vocabulary are domain.

PREDICTION

Plant Factory
What is predicted Everything after day is "PREDICTED" by construction; the UI labels states[MAX_DAY] as prediction (TwinStatePanel.tsx:56) Only the EMS's internal expectedSurplusMWh heuristic
Model Same dynamics as history (no separate forecaster) Same

Common: prediction = the future part of the trajectory. There is no separate predictor in either app and no reason to introduce one in the runtime.

UNCERTAINTY

Plant Factory
Implementation A band u(d) = 0.03 + 0.22 · (d − now)/120 drawn on charts and quoted as ± in the panel, explicitly "Illustrative band, not a validated confidence interval" (Charts.tsx:25, TwinStatePanel.tsx:17,75) None

Common: nothing. Do not build an uncertainty subsystem from one cosmetic band. Reserve a slot: a trajectory may carry an optional bounds(t, path) → [lo, hi], and an ensemble (N seeds) is the natural first real implementation because the kernel already makes seeded branches cheap.

RENDERING

Plant Factory
Boundary src/sim/ has zero three.js imports (verified by grep). Renderer calls twin.stateAt(day) → computeLayout(state, arch) (pure, in sim/layout.ts) → instanced geometry (PlantRenderer.tsx:112) src/sim/ and src/world/ have zero three.js imports; src/state/store.ts imports THREE for the dev handle (store.ts:2). Scene modules read runtime.sim directly (Lighting.tsx:101, EnergyFlows.tsx:122, EnvMap.tsx)
Presentation model PlantLayout (stems, leaf/flower/fruit placements with addr) — a domain-owned, three-free intermediate (layout.ts:31) Static layout.ts (buildings, roads, facilities) plus flow tubes reading sim.flows
Ambient life none Traffic agents stepped from the render loop with visual time; use Math.random (agents.ts:13–14); feed one number back into the sim (refuelQueueKg)
Picking → selection organ addr/id → Selection in store facility id → selected in store
Multi-instance Yes, up to three twins in split panes No

Common: renderer reads (state, layout) snapshots and emits selection ids; never mutates state. Section 11 formalises this.

Remainder: all geometry. Plant's layout.ts is the right pattern (domain computes a three-free presentation model); Factory's world/ is the same pattern for statics.

EVENTS

Plant Factory
Kind Derived: events() scans the trajectory with predicates (twin.ts:80); causal divergences are also derived Emitted: pushEvent inside step/applyScenario, capped rolling list, global id counter (engine.ts:138,197)
Recomputable after a branch Yes, automatically No, they are consumed by the UI as they happen

Common: both are Event { t, key, level, text }. The kernel should prefer derived events (predicates over trajectory) because they survive branching and recompute, and allow emitted events as a stepper output channel rather than a mutation of state.

SCENARIOS

Plant Factory
Content Named Environment presets (scenarios.ts) Named bundle: pvBoost, loadMult, gridAvailable, cloud transient, optional setHour, weather (engine.ts:13–35)

Common: Scenario = { name, inputsPatch, interventions[], view? }. Fits both once Factory's flags become schedule channels.

VALIDATION

Plant Factory
Engine checks sim-check.ts: calibration table, drought comparison, history identical assertion, deterministic assertion (two twins, same seed → identical Day-120 JSON), timing balanceResidual(s) invariant (engine.ts:492)
Causal checks causal-check.ts: interventions, time-to-effect, chains, organ deltas, preview none
Browser checks capture.mjs contact sheets verify.mjs: 31 checks incl. energy balance < 0.01 MW, outage response, PV vs sun, 3D pick, fps

Common: Invariant<S> = (s) => number (residual) and determinism as a standard check. Domain: the assertions themselves.


2. Common conceptual model

Derived from the tables, not from the wish list:

                ┌──────────────────────────────────────────────┐
                │  Trajectory<S>                                │
   Schedule<I>  │  states[k] at t0 + k·dt                      │
   (inputs by t)│  history (k ≤ now) immutable                  │
        │       │  future (k > now) = fold(step, states[now])   │
        ▼       │  branch(t, edit) shares history by reference  │
   step(S, I, P, dt, rng) → S  (+ emitted events)               │
                └──────────────────────────────────────────────┘
                         │                    │
        VariableRegistry │                    │ Layout<S> (domain, three-free)
        path → number    ▼                    ▼
        divergence / time-to-effect      RendererAdapter (three, 2D, headless…)
        (generic)                         reads snapshots, emits selection

Names that survived contact with the code:

Names that did not survive:


3. Proposed runtime boundary

packages/twin-core           (future; NOT to be created yet)
  clock.ts        Clock, fixed-step accumulator
  schedule.ts     Schedule<I>: segments, at(t), setFrom(t, patch), window(t0, t1, patch)
  trajectory.ts   Trajectory<S,I,P>: states, computeFrom, branch, stateAt(lerp hook)
  rng.ts          hash32 / rand01 (verbatim from plant/src/sim/rng.ts)
  variables.ts    VariableSpec, registry, divergence(), timeToEffect(), values()
  events.ts       DerivedEvent predicates, EmittedEvent channel
  provenance.ts   ProvenanceTag, ledger, inherit rules
  observe.ts      Observation, Estimator interface, assimilate()
  invariants.ts   Invariant<S>, determinismCheck()
  adapter.ts      Snapshot<S,L>, RendererAdapter interface (types only)

Rules the boundary must enforce, all of which Plant already satisfies and Factory nearly does:

  1. No imports of three, react, zustand, DOM, or window inside twin-core. (Factory's store.ts line 2 is the one current violation; it lives in the app layer anyway.)
  2. The stepper receives inputs and parameters as arguments; it never reads module state that changes (Factory's eventId, _sunset must become part of P or the emitted-events channel).
  3. Randomness comes only from rng(seed, id, salt). Math.random is permitted only outside the core (Factory's agents, which are renderer-side life).
  4. Time is t0 + k·dt. Frame time never reaches the stepper.

4. Domain / plugin boundary

A domain package supplies exactly:

plant-twin (existing plant/src/sim, repackaged)      factory-twin (existing factory/src/sim, repackaged)
  types.ts        PlantState, Environment              engine.ts   SimState, FactoryInputs
  model.ts        stepDay  → Stepper                   engine.ts   step      → Stepper (inputs as args)
  species.ts      SpeciesParams + DSSAT provenance     config.ts   PLANT + WEATHER + provenance tags
  layout.ts       PlantLayout (three-free)             world/layout.ts + flows (three-free)
  causal.ts       CAUSAL_VARS entries, tiers,          (new) VARS: batterySoc, gridImport, loadShed… ,
                  RESPONSE_OF/CORE/TAIL chains,               tiers: environment→dispatch→storage→kpi
                  GRAPH_NODES/EDGES, organDeltas,
                  limitingFactors
  scenarios.ts    presets                              engine.ts   SCENARIOS as schedule patches
  ask.ts          vocabulary tables                    —

The plugin contract is the TwinModule<S, I, P> record in section 5. It is deliberately a record of functions and tables, not a class to extend. Both apps can satisfy it with re-exports and one signature change each (section 13, 14).


5. TypeScript interface sketches

Only what the evidence justifies. Names are proposals.

// ---------- time
export interface Clock { t0: number; dt: number; unit: 'day' | 'hour' | 'minute' | 's' }
export const index = (c: Clock, t: number) => Math.floor((t - c.t0) / c.dt + 1e-9)

// ---------- inputs (environment + interventions, per channel)
export type Channels = Record<string, number | string | boolean>
export interface Segment<I extends Channels> { from: number; inputs: I; label?: string }
export interface Schedule<I extends Channels> {
  segments: Segment<I>[]
  at(t: number): I                                     // plant twin.envAt
  setFrom(t: number, patch: Partial<I>, label?: string): void   // plant setEnvFrom
  window(t0: number, t1: number, patch: Partial<I>, label?: string): void // plant applyWindow
  edits(): { t: number; key: keyof I; from: unknown; to: unknown }[]     // plant interventionsOf
}

// ---------- dynamics
export type Rng = (id: number, salt?: number) => number    // plant rand01 bound to a seed
export interface StepResult<S> { state: S; events?: EmittedEvent[] }
export interface Stepper<S, I extends Channels, P> {
  version: string                                          // model version for reproducibility
  init(params: P, seed: number): S
  step(prev: S, inputs: I, params: P, dt: number, rng: Rng): StepResult<S>
  clone(s: S): S                                           // plant cloneState; factory structuredClone-able
  lerp?(a: S, b: S, f: number): S                          // plant interpolateState (optional)
}

// ---------- trajectory + branching
export interface Trajectory<S, I extends Channels, P> {
  readonly id: string
  readonly parent?: string
  readonly clock: Clock
  readonly seed: number
  readonly params: P
  readonly stepper: Stepper<S, I, P>
  schedule: Schedule<I>
  states: S[]                                              // states[k] = state at t0 + k·dt
  now: number                                              // index of the last non-predicted state
  horizon: number                                          // last index computed (plant: 120)
  computeFrom(k: number): void                             // plant computeFrom
  branch(k: number, label: string): Trajectory<S, I, P>    // plant clone(): shares states[0..k]
  stateAt(t: number): S                                    // plant stateAt with lerp hook
}

// ---------- variables and analysis (plant CAUSAL_VARS, generalised)
export type Tier = string                                   // ordered per domain
export interface VariableSpec<S> {
  key: string; label: string; tier: Tier
  get(s: S): number
  mode: 'rel' | 'abs'; threshold: number; scale: number; min?: number
  fmt?(v: number): string
  provenance?: ProvenanceTag                               // default class for this variable
}
export interface Divergence { key: string; tier: Tier; k: number; base: number; alt: number; delta: number; rel: number; direction: 'up' | 'down' }
export interface Analysis<S> {
  base: Trajectory<S, any, any>; alt: Trajectory<S, any, any>; fromK: number
  divergences: Divergence[]                                // plant analyze(): first persistent exceedance
  byKey: Record<string, Divergence | undefined>
  timeToEffect: Record<Tier, number | null>                // first divergence per tier
  values(key: string, k: number): { base: number; alt: number; delta: number; rel: number }
  act(key: string, k: number): number                      // normalised −1…1 for graph intensity
}
export function analyze<S>(vars: VariableSpec<S>[], tiers: Tier[], base: Trajectory<S, any, any>, alt: Trajectory<S, any, any>, fromK: number): Analysis<S>

// ---------- events
export interface EmittedEvent { t: number; level: 'info' | 'warn' | 'alarm'; key: string; text: string }
export interface DerivedEvent<S> { key: string; label: string; first(s: S): boolean }   // plant twin.events()

// ---------- observation / estimation (section 7)
export interface Observation { t: number; source: string; channels: Record<string, { value: number; quality?: number }> }
export interface Estimator<S> { assimilate(prior: S, obs: Observation, t: number): { state: S; provenance: ProvenanceDelta } }

// ---------- provenance (section 10)
export type ProvenanceClass = 'MEASURED' | 'ESTIMATED' | 'SCIENTIFIC_MODEL' | 'DERIVED' | 'ASSUMED' | 'PROCEDURAL'
export interface ProvenanceTag { class: ProvenanceClass; source?: string; ref?: string; retrieved?: string; note?: string }
export type ProvenanceLedger = Record<string /* path */, ProvenanceTag>
export type ProvenanceDelta = Record<string, ProvenanceTag>

// ---------- renderer boundary (section 11)
export interface Snapshot<S, L> { trajectoryId: string; t: number; state: S; layout: L; branches?: Snapshot<S, L>[] }
export interface RendererAdapter<S, L> {
  mount(host: unknown): void
  render(snap: Snapshot<S, L>, view: ViewState): void
  onSelect(cb: (addr: string | null) => void): void
  unmount(): void
}

// ---------- the plugin record
export interface TwinModule<S, I extends Channels, P, L> {
  name: string
  clock: Clock
  stepper: Stepper<S, I, P>
  defaultInputs: I
  defaultParams: P
  layout(s: S, p: P): L                                    // three-free presentation model
  variables: VariableSpec<S>[]
  tiers: Tier[]
  derivedEvents: DerivedEvent<S>[]
  invariants: { name: string; residual(s: S): number; tolerance: number }[]
  provenance: ProvenanceLedger                             // for params and input channels
  scenarios: { name: string; inputs?: Partial<I>; interventions?: { t: number; patch: Partial<I> }[] }[]
}

That is the whole surface. Note what is absent: no Entity, no Environment class, no CausalGraph, no Prediction, no Uncertainty beyond the optional bounds slot mentioned earlier.


6. State transition model

The invariant

STATE(k+1) = step( STATE(k), SCHEDULE.at(t_k), PARAMS, dt, rng(seed) )

Plant holds it exactly today. computeFrom is literally this fold (twin.ts:36–38): stepDay(states[d], envAt(d), { seed, species }). Inputs are the environment schedule; interventions are edits to it; params are SpeciesParams; randomness is rand01(seed, organId, salt).

Factory holds it after one signature change. Today step(s, dt) reads its inputs from inside s: s.weather (set by the store), s.scenario (set by applyScenario), s.gridAvailable, s.cloudTimer, s.refuelQueueKg (written by the traffic layer). The equations themselves are already a function of (s, sun(hour), WEATHER[weather], scenario flags, PLANT, dt). Moving those to an inputs argument:

interface FactoryInputs { weather: WeatherId; gridAvailable: boolean; pvBoost: number; loadMult: number; cloudFactorTarget: number; refuelRequestKg: number }
step(prev, inputs, PLANT, dt, rng) → next

changes ~15 lines of step and applyScenario and nothing in the arithmetic. The cloud transient becomes a scheduled cloudFactorTarget window (which is what it already is: cloudTimer counts down minutes). The EMS forecast (sunsetHour) is a function of PLANT.site and belongs in params.

Two things must move out of the state to make the fold pure:

History vs prediction

Both apps need the same split, and only Plant has it: states[0..now] are history (in a real twin: estimated from observations), states[now+1..horizon] are prediction (the fold). Editing the schedule at t ≥ now recomputes only the prediction; the history is never rewritten. This is the rule Plant's README states and its setEnvFrom enforces; it is the rule that makes observation correction (section 7) and counterfactuals (section 8) fall out of the same mechanism.

For Factory the window matters: at dt = 1 min a 48-hour rolling trajectory is 2,880 states of ~100 numbers, trivially cheap (Factory already runs 600 substeps at startup in createSim).


7. Observation / state-estimation model

Neither app assimilates anything today; Plant has the record shape and an unused interface; Factory has a README sentence. The design below is the smallest one that turns "SIMULATED OBSERVATION" into "MEASURED" without rewriting either model.

Contract

Observation(t) ──► Estimator.assimilate(prior = states[k(t)], obs) ──► states[k] := posterior
                                                                       provenance[path] := MEASURED / ESTIMATED
                                                                       trajectory.now = k
                                                                       computeFrom(k)          (re-predict)

One Observation is a flat map of channels. Channels resolve to either state paths (they correct the state) or input channels (they replace a scheduled or modelled input). The TwinModule declares which is which in a binding table; the kernel does not guess.

Plant binding (from PlantObservation)

Channel Binds to Estimator
temperature, humidity, CO2, PAR, soilMoisture (environment sensors) input channels env.temperature … Replace the scheduled segment value for that step (the schedule becomes "measured up to now, planned after now")
plantHeight (RGB-D / LiDAR) state height → but height is derived from axes[0].nodes[].internode (model.ts:106) Nudge: scale all main-stem internodes by measured / modelled (domain estimator)
leafArea (RGB segmentation, phenotyping) state leafArea → derived from leaves[].area × health Nudge: scale live leaf areas proportionally (domain estimator)
biomassEstimate (3DGS volume × density) state biomass → sum of organ masses Nudge partition-wise (domain)
organ-level phenotyping (per-leaf area, fruit diameter) organ by structural address L:m:12, R:m:9:1 Direct overwrite (domain, address-matched — the same matching organDeltas uses)

The important structural fact: Plant's aggregates are derived, so a generic "set height = 1.42" is meaningless. The kernel provides the pipeline; the domain provides the estimator that knows how to push an aggregate back into organs. The kernel's generic estimators are only overwrite (for paths that are primary state) and replaceInput (for input channels).

Factory binding (SCADA / IoT / meters / BMS / EMS)

Channel Binds to Estimator
BMS SOC, pack temperature state batterySoc, batteryTempC (primary) overwrite
Meter PCC import/export, PV inverter kW, electrolyser kW, fuel cell kW state gridImport/gridExport/pv/electrolyserPower/fuelCellPower (primary this step) overwrite, then the residual balanceResidual becomes a measurement-consistency check instead of a model invariant
Pyranometer irradiance input channel (replaces sunAt(hour).irradiance × pvFactor × cloud) replaceInput
Pressure transmitter h2Pressure, mass h2Stored primary state overwrite
SCADA breaker status input gridAvailable replaceInput
EMS setpoints input channels (chargeSetpoint…) — currently decided inside the model; a real EMS turns the dispatch block into an input replaceInput — this is the biggest modelling change and is Factory-specific

Factory is the easier assimilation case because its state is mostly primary scalars; the hard part is that its dispatch logic is inside the step. When a real EMS exists, the dispatch decision arrives as an input and the model degrades gracefully to "physics only" (SOC integration, H₂ balance, KPIs). The runtime should support a stepper that accepts optional externally-supplied decision channels — which is just more input channels, no new mechanism.

Where "now" comes from

Plant's now is the UI day; Factory's is wall-clock. With assimilation, now is the timestamp of the latest accepted observation. Everything at k ≤ now carries MEASURED/ESTIMATED provenance; everything after is SCIENTIFIC_MODEL/ASSUMED. That is precisely the "OBSERVED · ESTIMATED · PREDICTED" panel Plant already draws with simulated data (TwinStatePanel.tsx).


8. Counterfactual branching model

Plant's implementation is already the generic one; the analysis is whether the same mechanism serves the other domains.

                         trajectory T (schedule S, states[0..now])
                                      │
                    branch(now)       │       branch(now)
          ┌───────────────────────────┼─────────────────────────┐
          ▼                           ▼                         ▼
   BASELINE = T                 A = T.branch(now)          B = T.branch(now)
   (unchanged schedule)         A.schedule.setFrom(now,    B.schedule.window(now, now+90 min,
                                  { water: 25 })              { gridAvailable: false })
          │                           │                         │
   computeFrom(now)             computeFrom(now)          computeFrom(now)

Each branch is segments copy + states.slice() (history shared by reference because states are immutable values; Plant relies on cloneState producing fresh objects each step). Mapped onto the four requested cases:

Case Schedule edit Works with today's mechanism?
Plant water intervention setFrom(now, { water }) or window(...) Yes, it is today's mechanism
Factory battery dispatch policy Policy is not an input today; it is code inside step. Two options: (a) make policy parameters (socTarget, socReserve, peakHours) part of P and allow branches to carry different P; (b) expose dispatch decisions as input channels (a) is a two-line kernel change (branch(k, label, paramsPatch?)); Plant already varies species the same way
Energy grid outage window(now, now+Δ, { gridAvailable: false }) Yes; Factory's applyScenario becomes this call
Infrastructure maintenance window(t0, t1, { assetOnline: false }) plus a params patch for post-maintenance efficiency Yes, same two mechanisms

So the generic branching needs exactly two edit kinds — schedule edits and parameter patches — and Plant already has the first; the second is trivial. The "recovery" branch (branch of a branch) and the automatic "reference" (pre-edit branch) need no additions.

What does not generalise: the Day-120 previewIntervention row list (causal.ts:279) is domain (which variables to report); the kernel offers preview(T, k, edit) → Analysis and the domain picks the rows.


9. Causal-analysis boundary

Reading causal.ts line by line, the split is clean:

Generic (no domain semantics, keep in kernel):

Domain (stays in the plant package; Factory writes its own):

Boundary statement: the kernel answers whether, when, and by how much two trajectories differ per registered variable and per tier; the domain answers through which mechanism and in which words.


10. Provenance model

What exists:

Design principle: provenance is attached to producers, not to numbers. Three producer kinds exist in both apps, and each gets tagged once:

  1. Parameters — a ledger entry per parameter path at module definition time. Plant: species.TM.lfmax → { class: SCIENTIFIC_MODEL, source: 'DSSAT CSM v4.8 CROPGRO', ref: 'TMGRO048.CUL', retrieved: '2026-09-18' }; species.TM.dm.fruit → { class: DERIVED, source: 'USDA FDC tomato 94.5 % water' }; ASSIM_K → { class: ASSUMED, note: 'tomato calibration constant' }. Factory: PLANT.bess.energyMWh → { class: ASSUMED } until a nameplate arrives; PLANT.grid.carbonKgPerMWh → { class: ASSUMED, note: 'marginal, coal-heavy region' }.
  2. Input channels — per channel, per time range: SCHEDULED (user plan; class ASSUMED) before assimilation, MEASURED after (replaceInput writes the tag with the observation's source).
  3. State variables — inherited by rule, not stored per number: a state path produced by the stepper is SCIENTIFIC_MODEL if the stepper's declared class is scientific, else ASSUMED; after overwrite by an estimator it is MEASURED at that k; after a domain nudge it is ESTIMATED; a variable registered as DERIVED (Plant's height, lai; Factory's renewableShare) is DERIVED regardless. The kernel stores only the deltas per k (ProvenanceDelta, usually empty) and resolves provenanceOf(path, k) by looking up delta → variable default → stepper default.
  4. Presentation values — anything computed in layout() (Plant's leaf yaw from rand01(7, id), branch droop, Factory's road jitter) is PROCEDURAL by definition of the layout function; no per-value work.

Cost: one Record<path, ProvenanceTag> per module (Plant's is already 80 % written as JSON and comments), one optional delta map per trajectory index, and a three-line resolver. No arithmetic changes. The UI badges Plant already draws become provenanceOf(...) lookups instead of hard-coded strings.

Worked examples from the brief:


11. Renderer adapter

Current dependency direction, verified:

plant/src/sim  ──(types + layout)──►  plant/src/render (three)      ✔ no reverse edge
factory/src/sim, world ──────────►  factory/src/scene (three)       ✔ no reverse edge
factory/src/state/store.ts ──► three   (dev handle only)            ✘ app-layer leak, harmless today
factory/src/scene/Scene.tsx  ──► runtime.sim.refuelQueueKg += …     ✘ renderer writes into sim state

The contract that both already follow in practice:

Runtime ──► Snapshot { t, state, layout, branches[] } ──► RendererAdapter.render(snapshot, view)
Runtime ◄── adapter.onSelect(addr)                                    (the only upstream message)

Details that the evidence fixes:

What must not enter the adapter interface: camera presets, DoF, post-processing, textures, instancing strategy, LOD, split-pane geometry. These differ completely between the two apps and are the bulk of their code (Plant render/ 1,861 lines; Factory scene/ 5,079 lines).


12. Determinism and versioning

Plant's property, verbatim: "same initial state, model version, parameters, interventions, random seed → same trajectory." Evidence: rand01(seed, organId, salt) is order-independent hashing (rng.ts); sim-check.ts asserts JSON.stringify(t2.states[120]) === JSON.stringify(t.states[120]) and that a branch's states[45] is identical to the parent's; layout.ts uses a fixed seed = 7.

Factory's gaps, verbatim:

  1. Substep size depends on frame time: sub = ceil(simHours / (1/60)); h = simHours / sub (Scene.tsx:36–39). A 16 ms frame at 10× produces h ≈ 0.044 min, a 33 ms frame h ≈ 0.09 min; ramp limiters (towards(..., rampMWPerMin · dt · 60)) and clamp make the result step-size-dependent. Two runs differ at the third decimal.
  2. Global eventId and _sunset cache (benign but impure).
  3. Agents use Math.random (acceptable: they are adapter-side).
  4. No model version string anywhere; Plant has none either, only the DSSAT retrieved date.

Runtime design:


13. Migration strategy for Plant

Plant is already 90 % on the target shape. The migration is a repackaging, and it can be done without changing a single equation:

  1. Split twin.ts into kernel Trajectory/Schedule (the class body minus events(), observation(), species) and a domain plantTwin: TwinModule. DigitalTwin remains as a thin façade so store.ts, render/, ui/ and the scripts keep compiling.
  2. Move rng.ts to the kernel verbatim.
  3. Split causal.ts: analyze, values, act, divergence loop, tierRank, interventionsOf, previewIntervention's branch part → kernel; CAUSAL_VARS, chains, graph, organDeltas, limitingFactors, row lists → plant-twin.
  4. events() becomes derivedEvents: DerivedEvent<PlantState>[] (eight predicates already written).
  5. PlantObservation becomes an Observation binding table plus one domain Estimator (the nudge rules in section 7) — the only new code.
  6. Provenance ledger: generated from the species JSON source blocks plus ~20 hand entries for ASSIM_K, MAINT_*, HYPOCOTYL, GROUND_AREA, response-function constants.
  7. Acceptance: sim-check.ts output byte-identical before and after; causal-check.ts output identical; ReproKey printed.

Risk: low. The refactor is mechanical and the tests already exist.

14. Migration strategy for Factory

Factory needs behavioural additions, not just repackaging, so it should go second and only after the kernel has proven itself on Plant.

  1. Inputs out of state. Define FactoryInputs (weather, gridAvailable, pvBoost, loadMult, cloudFactorTarget, refuelRequestKg). step(s, inputs, PLANT, dt, rng). applyScenario becomes a schedule edit; setWeather a schedule edit; the cloud event a window. Keep mirrored copies in state for the HUD during the transition.
  2. Events as output. pushEvent returns through StepResult.events; the store keeps the rolling 40. eventId removed.
  3. Fixed-step driver. Replace the Ticker substep arithmetic with the kernel accumulator at dt = 1 min. This alone makes runs reproducible.
  4. Rolling trajectory. Keep 48 h of states (2,880 × ~100 numbers). setHour becomes "scrub within the trajectory" backwards and "fast-forward the fold" forwards, which is more honest than today's clock jump (today, jumping the hour leaves SOC and H₂ at their pre-jump values).
  5. Variables + tiers. ~15 VariableSpec entries (irradiance, pvPotential, load, batteryPower, batterySoc, electrolyserPower, h2Stored, fuelCellPower, gridImport, gridExport, loadShed, renewableShare, carbonIntensity) over tiers environment → dispatch → storage → kpi. With that, analyze() gives Factory "when did the outage branch diverge, and what reached the KPI first" for free.
  6. Branching UI. Scenario switch becomes branch(now) + edit; the control room gains a Sankey per branch. Optional; the kernel does not require UI changes.
  7. Agents stay put in world/ + scene/, but refuelQueueKg flows through the input channel.
  8. Provenance ledger from config.ts (every entry ASSUMED with the existing comments as notes) and sun.ts (SCIENTIFIC_MODEL: declination/hour-angle geometry, ASSUMED: 0.7^AM^0.678 clear-sky constant).
  9. Acceptance: verify.mjs still 31/31; balanceResidual < 0.01; new headless factory-check.ts prints the ReproKey and asserts two runs equal.

Risk: moderate. Step 4 changes user-visible time semantics; step 1 touches the dispatch block. Both are contained in engine.ts/store.ts/Scene.tsx.


15. What should NOT be abstracted

Each item below appears in one app only, or in both with incompatible meaning, or is cheap to duplicate:


16. Risks of premature abstraction

  1. n = 2. Every "common" pattern here is common to two samples written by the same hands within the same day, in the same stack. The trajectory/branch/divergence core is defensible because it is already domain-free in the code; anything beyond it is extrapolation.
  2. The kernel is the size of Plant's sim. Plant's whole engine is 1,343 lines; a 500-line kernel is a large fraction of the thing it abstracts. If the kernel grows past ~800 lines it has stopped being "smallest useful".
  3. Time-regime mismatch. Episodic (Plant) vs rolling (Factory) trajectories are different products. A unified Trajectory with now/horizon/window handles both, but the UI semantics (scrub vs live) will still differ and must not be forced into one timeline widget.
  4. Factory's live feel. Today scenario switches are instantaneous mutations of a live sim; a branch-and-recompute model may feel slower or more "analytical". Keep a "commit branch to live" action so the live experience survives.
  5. Estimator complexity. Plant's aggregates are derived from organs, so assimilation needs bespoke inverse rules. If the kernel tries to be clever here (generic nudging of aggregates) it will be wrong for plants and unnecessary for factories. Keep overwrite and replaceInput generic; everything else domain.
  6. Provenance creep. Tagging every arithmetic result is the failure mode the brief warns about; the producer-level ledger with inheritance (section 10) is the guard. Resist per-number tags.
  7. Renderer temptation. Both apps' realism pipelines (materials, LOD, impostors, DoF) are where most of the code lives and where reuse feels biggest. It is not runtime code and should not be pulled in by the same package boundary; a separate, optional "r3f-kit" could exist later and must not be a dependency of the kernel.
  8. Losing determinism during migration. Factory's frame-coupled stepping must be fixed before it is wrapped in a trajectory, or branch comparisons will show spurious divergences.

17. Minimal proof-of-concept plan

Goal: prove the five kernel pieces on both apps with zero behavioural change to Plant and one behavioural fix to Factory, in a scratch package that is not yet shared code.

Step 1 — Extract, don't design (Plant, ~1 day). Copy twin.ts + rng.ts + the generic half of causal.ts into poc/twin-core/, parameterise by <S, I, P>, re-export DigitalTwin from the plant package as a façade. Acceptance: npm run sim and causal-check.ts print byte-identical output; grep shows zero three/react imports in twin-core.

Step 2 — Signature change (Factory, ~1 day). step(prev, inputs, PLANT, dt) with FactoryInputs; pushEvent → returned events; fixed-step accumulator in Ticker. Acceptance: verify.mjs 31/31; a new factory-check.ts runs 24 h headless twice and asserts equal JSON; balanceResidual < 0.01 throughout.

Step 3 — Trajectory + branch (Factory, ~1 day). Wrap the stepper in Trajectory with dt = 1 min, 48 h window. Script: baseline vs window(14:00, 15:30, { gridAvailable: false }); register 13 variables in four tiers; run analyze. Acceptance: it reports the outage at 14:00, batteryPower divergence within 1 min, fuelCellPower within a few minutes, gridImport immediately, carbonIntensity at the KPI tier — i.e. the same "time-to-effect" narrative Plant produces for drought, from the same code path.

Step 4 — Observation smoke test (both, ~1 day). Feed a synthetic Observation (Factory: BMS SOC 0.40 at 12:00; Plant: plantHeight +10 % at day 40) through assimilate with overwrite / a domain nudge; assert now moves, history before now is untouched, prediction after is recomputed, and provenanceOf('batterySoc', k12:00) === MEASURED.

Step 5 — Decide. If steps 1–4 land under ~600 kernel lines and both apps' existing checks pass unchanged, create the shared package. If the kernel needs domain conditionals to pass, stop and keep two copies.

Deliberately out of scope for the PoC: UI changes, renderer adapter implementation (only the type), uncertainty, Ask, plugin registry.


Appendix A — Line-level evidence index

Claim Where
Plant step is pure and functional plant/src/sim/model.ts:124–373 (cloneState at 60)
Plant randomness is seeded hashing plant/src/sim/rng.ts; model.ts:247,268,291,293,324,325,351
Plant trajectory, recompute, branch plant/src/sim/twin.ts:33–70
Plant counterfactual slots plant/src/store.ts:31,121–147,171–177
Plant causal generic core plant/src/sim/causal.ts:90–126
Plant causal domain tables plant/src/sim/causal.ts:29–58,133–161,186–197
Plant observation shape and unused source plant/src/sim/types.ts:162; twin.ts:97–111
Plant sim has no three imports grep from 'three' in plant/src/sim → none
Plant determinism test plant/scripts/sim-check.ts (last lines)
Plant parameter provenance plant/src/data/species/*.json source; species.ts:70–103
Plant fake uncertainty plant/src/ui/Charts.tsx:25; TwinStatePanel.tsx:17,75
Factory step mutates state and reads inputs from it factory/src/sim/engine.ts:265–489
Factory scenario as state mutation + events factory/src/sim/engine.ts:234–256
Factory globals factory/src/sim/engine.ts:138,216
Factory frame-coupled substepping factory/src/scene/Scene.tsx:29–39
Factory no trajectory; events capped factory/src/sim/engine.ts:197–200; store.ts:131
Factory renderer writes into sim factory/src/scene/Scene.tsx:44–46
Factory agents non-deterministic factory/src/world/agents.ts:13–14,244,362
Factory three import in store factory/src/state/store.ts:2
Factory invariant and checks factory/src/sim/engine.ts:492; scripts/verify.mjs
Factory assumption disclaimers factory/src/sim/config.ts:1–4; engine.ts:1–7; README.md:75