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:
- Plant —
plant/(4D Plant Digital Twin, 4,924 lines; simulation coresrc/sim/1,343 lines) - Factory —
factory/(Digital Energy Factory, 8,016 lines; simulation coresrc/sim/626 lines, agent worldsrc/world/909 lines)
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:
- Stepper (not "DynamicsModel"): a function, with a version string and a parameter record.
- Schedule (not "Environment" and not "Intervention"): both apps reduce environment and intervention to "inputs as a function of time, edited from t onward". Environment vs intervention is a per-channel label, not a type.
- Trajectory: the cached fold. This is the twin's memory.
- Branch: a trajectory that shares history with its parent.
- VariableRegistry:
{ key, get(S) → number, tier, threshold, mode }— Plant'sCAUSAL_VARS, generalised. - Analysis: divergences + time-to-effect over two trajectories.
- Layout: domain-owned, dependency-free presentation model (Plant's
PlantLayout, Factory's world layout + flow list). - Provenance: tags on parameter records and schedule channels, inherited by variables by rule.
Names that did not survive:
- TwinDefinition / TwinInstance — there is exactly one definition per app and it is the module itself. A
TwinDefinitionobject would be a bag of the five functions above; call it that (TwinModule<S, I, P>), and do not add registry, discovery or plugin loading until a third domain exists. - Entity / Environment classes — see the ENTITY and ENVIRONMENT rows; accessors and channels cover both apps without new object models.
- CausalGraph — Plant's graph is a hand-drawn diagram with coordinates (
causal.ts:133). It is domain content, not a runtime facility. - Prediction / Uncertainty as types — the future half of a trajectory and an optional bounds function.
- TelemetryAdapter — premature; section 7 defines the ingest contract only.
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:
- No imports of three, react, zustand, DOM, or
windowinsidetwin-core. (Factory'sstore.tsline 2 is the one current violation; it lives in the app layer anyway.) - The stepper receives inputs and parameters as arguments; it never reads module state that changes (Factory's
eventId,_sunsetmust become part ofPor the emitted-events channel). - Randomness comes only from
rng(seed, id, salt).Math.randomis permitted only outside the core (Factory's agents, which are renderer-side life). - 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:
pushEvent→ return events fromstep(theStepResult.eventschannel); the rolling 40-cap becomes a UI concern.eventIdglobal → derive ids from(k, index).
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):
values(key, k)— accessor arithmetic (causal.ts:91).- Divergence detection: first index where
|alt − base|exceeds a threshold (relative or absolute, with a floor) and persists one more step (causal.ts:101–118). The persistence rule is the part that makes it robust to integer-valued variables (fruit count) and should be kept. - Ordering by
(k, tierRank)andtimeToEffect= first divergence per tier (causal.ts:119–124). Needs only an ordered list of tier names from the domain. act()normalisation for graph intensity (causal.ts:96).- Intervention reconstruction from schedule edits (
causal.ts:80–88). preview(T, k, edit)— branch + analyze without commit.- Difference listing at a time (Ask's "differences at this day",
ask.ts:88).
Domain (stays in the plant package; Factory writes its own):
- The
CAUSAL_VARSentries: keys, getters, thresholds, tiers, phrasing (up/downverbs) (causal.ts:29–58). RESPONSE_OF,CORE,TAILchain tables — an ordering of mechanism that encodes plant physiology (causal.ts:186–197). Factory's equivalent would beirradiance → pvPotential → flows.pvToBattery → batterySoc → fuelCellPower → gridImport.GRAPH_NODESwith pixel coordinates andGRAPH_EDGESwithviavariables — diagram content.organDeltas— address matching is domain (it knows axes and ranks), though the kernel can offerdiffByAddress(mapA, mapB)once both sides areMap<addr, number>.limitingFactors— re-runs the model's response functions; there is no domain-free notion of "limiting factor". Factory's analogue is the flow ledger (which source served the load), a different computation.- Ask vocabulary (
ENV_WORDS,TARGET_WORDS) — domain; the four intent classes (what-if, when-diverged, differences, why) are generic dispatch, and can live in the kernel as an intent enum with domain-supplied matchers.
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:
- Plant records provenance at the parameter source level, thoroughly: each species JSON carries
source { model, repository, license, files, cultivar, ecotype, retrieved }(data/species/*.json);ARCH_SOURCESandDM[].sourcecite literature for botanical facts (species.ts:70–103); the header comments intypes.ts,model.ts,response.tsdeclare the equations "illustrative"; the UI stampsSIMULATED,SIMULATED OBSERVATION,PREDICTED,DSSAT CSM v4.8 CROPGRObadges (TwinStatePanel.tsx). - Factory records it at the file level:
config.tsopens with "Every number here is a demonstration assumption";engine.tswith "carry no engineering, safety, sizing, financial or operational meaning"; the UI shows aSIMULATEDchip; renderer assets haveASSET-LICENSES.md. - Neither attaches provenance to a runtime value.
Design principle: provenance is attached to producers, not to numbers. Three producer kinds exist in both apps, and each gets tagged once:
- 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' }. - Input channels — per channel, per time range:
SCHEDULED(user plan; class ASSUMED) before assimilation,MEASUREDafter (replaceInputwrites the tag with the observation'ssource). - State variables — inherited by rule, not stored per number: a state path produced by the stepper is
SCIENTIFIC_MODELif the stepper's declared class is scientific, elseASSUMED; afteroverwriteby an estimator it isMEASUREDat that k; after a domain nudge it isESTIMATED; a variable registered asDERIVED(Plant'sheight,lai; Factory'srenewableShare) is DERIVED regardless. The kernel stores only the deltas per k (ProvenanceDelta, usually empty) and resolvesprovenanceOf(path, k)by looking up delta → variable default → stepper default. - Presentation values — anything computed in
layout()(Plant's leaf yaw fromrand01(7, id), branch droop, Factory's road jitter) isPROCEDURALby 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:
- fruit mass:
species.TM.fruitFreshMass=fruitDryMass / dm.fruit;fruitDryMass=WTPSD × SDPDV / THRSH(DSSAT cultivar, SCIENTIFIC_MODEL) ÷dm.fruit(USDA, DERIVED) → the parameter resolves to DERIVED with two cited sources; a fruit's runtimebiomassinherits SCIENTIFIC_MODEL from the stepper until phenotyping overwrites it (MEASURED). - battery SOC: ASSUMED initial (
initialSoc: 0.62), SCIENTIFIC_MODEL while integrated, MEASURED once BMS telemetry is assimilated. - branch orientation: PROCEDURAL (produced by
computeLayout).
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:
- Layout is computed on the runtime side and is three-free. Plant's
computeLayoutis the model for this; it even feeds back one number (canopyWidth) into the state, which is fine because it is pure. Factory's staticworld/layout.tsis the same idea for geometry that never changes; its dynamic part isflows(already in state). - Multiple instances per frame. Plant renders up to three trajectories in split panes with one camera.
Snapshot.branches[]carries them; the adapter decides pane layout (a view concern). - Interpolation belongs to the runtime.
stateAt(t)with the domainlerphook produces the sub-step state; the adapter never interpolates domain state itself. - Ambient life is adapter-side. Factory's
Traffic(workers, vehicles, forklift fork state) is visual, non-deterministic and frame-stepped; it must stay in the adapter. Its single feedback (refuelQueueKg) becomes an input channel (refuelRequestKgper step) so the renderer emits an input event instead of writing sim state. That closes the one upstream write. - Picking returns addresses. Plant:
L:m:12; Factory:bess. The runtime resolves an address to variables for the info panel. - Headless adapter is the null adapter:
render()ignored, snapshots consumed by scripts. Plant'ssim-check.tsalready is this; Factory'sverify.mjsneeds the browser only because the driver is inside R3F, which the kernel driver removes.
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:
- Substep size depends on frame time:
sub = ceil(simHours / (1/60)); h = simHours / sub(Scene.tsx:36–39). A 16 ms frame at 10× producesh ≈ 0.044 min, a 33 ms frameh ≈ 0.09 min; ramp limiters (towards(..., rampMWPerMin · dt · 60)) andclampmake the result step-size-dependent. Two runs differ at the third decimal. - Global
eventIdand_sunsetcache (benign but impure). - Agents use
Math.random(acceptable: they are adapter-side). - No model version string anywhere; Plant has none either, only the DSSAT
retrieveddate.
Runtime design:
- Fixed-step accumulator in the driver:
acc += dtVisual · speed; while (acc ≥ dt) { step(dt); acc −= dt }. Trajectory index, not frame, is the time base. ReproKey = hash(stepper.version, JSON(params), JSON(schedule.segments), seed, clock)stored on every trajectory and printed by the headless check. Two trajectories with equal keys must have byte-equalstates.stepper.versionis mandatory and bumped by hand;paramsprovenance (section 10) carries data versions (retrieved).- The determinism check is a kernel invariant, run by both
sim-checkandverify.
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:
- Split
twin.tsinto kernelTrajectory/Schedule(the class body minusevents(),observation(), species) and a domainplantTwin: TwinModule.DigitalTwinremains as a thin façade sostore.ts,render/,ui/and the scripts keep compiling. - Move
rng.tsto the kernel verbatim. - 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. events()becomesderivedEvents: DerivedEvent<PlantState>[](eight predicates already written).PlantObservationbecomes anObservationbinding table plus one domainEstimator(the nudge rules in section 7) — the only new code.- Provenance ledger: generated from the species JSON
sourceblocks plus ~20 hand entries forASSIM_K,MAINT_*,HYPOCOTYL,GROUND_AREA, response-function constants. - Acceptance:
sim-check.tsoutput byte-identical before and after;causal-check.tsoutput identical;ReproKeyprinted.
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.
- Inputs out of state. Define
FactoryInputs(weather, gridAvailable, pvBoost, loadMult, cloudFactorTarget, refuelRequestKg).step(s, inputs, PLANT, dt, rng).applyScenariobecomes a schedule edit;setWeathera schedule edit; the cloud event awindow. Keep mirrored copies in state for the HUD during the transition. - Events as output.
pushEventreturns throughStepResult.events; the store keeps the rolling 40.eventIdremoved. - Fixed-step driver. Replace the
Tickersubstep arithmetic with the kernel accumulator atdt = 1 min. This alone makes runs reproducible. - Rolling trajectory. Keep 48 h of states (2,880 × ~100 numbers).
setHourbecomes "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). - Variables + tiers. ~15
VariableSpecentries (irradiance,pvPotential,load,batteryPower,batterySoc,electrolyserPower,h2Stored,fuelCellPower,gridImport,gridExport,loadShed,renewableShare,carbonIntensity) over tiersenvironment → dispatch → storage → kpi. With that,analyze()gives Factory "when did the outage branch diverge, and what reached the KPI first" for free. - Branching UI. Scenario switch becomes
branch(now) + edit; the control room gains a Sankey per branch. Optional; the kernel does not require UI changes. - Agents stay put in
world/+scene/, butrefuelQueueKgflows through the input channel. - Provenance ledger from
config.ts(every entry ASSUMED with the existing comments as notes) andsun.ts(SCIENTIFIC_MODEL: declination/hour-angle geometry, ASSUMED: 0.7^AM^0.678 clear-sky constant). - Acceptance:
verify.mjsstill 31/31;balanceResidual< 0.01; new headlessfactory-check.tsprints 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:
- Organ topology and lifecycle (
Axis/Node/Leaf/Truss/Flower/Fruit,nextId, senescence) — Plant only. - Plant layout physics (
computeLayout: phyllotaxis, gravity sag, truss bending) — Plant only, 319 lines that would not lose a line by being "generic". - EMS dispatch (the
if (net ≥ 0) … else …block) — Factory only; it is the model. - Road and walkway graphs, agents, forklift fork state machine — Factory renderer-side life.
- Scenario contents, camera presets, tour scripts, HUD/panels — per app.
- Causal chain tables, graph coordinates, Ask vocabulary — domain content (section 9).
- Uncertainty — one cosmetic band is not a subsystem.
- Entity/Environment object models — accessors and channels suffice (sections 1, 2).
- A plugin registry / dynamic loading — there are two modules and they are both statically imported.
- A shared UI kit — the two UIs share zustand and CSS conventions only.
- Interpolation —
interpolateStateknows organs; keep it a domain hook.
16. Risks of premature abstraction
- 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.
- 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".
- Time-regime mismatch. Episodic (Plant) vs rolling (Factory) trajectories are different products. A unified
Trajectorywithnow/horizon/window handles both, but the UI semantics (scrub vs live) will still differ and must not be forced into one timeline widget. - 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.
- 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
overwriteandreplaceInputgeneric; everything else domain. - 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.
- 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.
- 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 |