@operato/twin-kernel
v0.11.10
Published
Twin Domain Kernel — framework-agnostic, zero-dep (domain + sim + 3-channel contract). WMS/YMS/MES, EPCIS 2.0 · ISA-95.
Readme
@operato/twin-kernel — a business-layer digital twin kernel
A headless, framework-agnostic, zero-dependency TypeScript kernel that simulates and mirrors the business execution of warehouses, yards, factories and energy systems. It runs in plain Node with no UI, no 3D and no DOM (sim = it generates events, live = it mirrors them — same contract either way).
- Run:
node --test test/*.test.ts— native TS, zero runtime dependencies - Status: v0.7.61 · 1,131 tests green · 122 test files · 4 verticals (WMS/YMS/MES/EMS) · ISA-95 four resources
Layers (dependencies point down only, no dialect)
contract.ts 3-channel contract (State/Command/Scenario) + operational deltas + TwinKernel ← domain/standard agnostic
epcis.ts GS1 EPCIS 2.0 event machinery (types · builders · validator · URIs · CBV) ← standard, domain agnostic
vocabulary.ts retired-name list + guard pragma — renames are enforced by machine
flow-engine.ts FlowEngine base — mechanics (RNG · clock · tick · resource claim · task progress · emit · snapshot)
observed-reducer.ts events → State fold (**the single rule both drivers use**) + resume points ← domain agnostic
state-projector.ts the old name for the same thing (kept for consumers)
allocation-policy.ts AllocationPolicy seam (selectPlacement/selectStock)
capability.ts what a resource can do — find by capability, never by concrete type
capacity.ts capacity and occupancy
operations-capability.ts what an operation requires ↔ what a resource offers
domain-definition.ts operation specs (ISA-95 OperationsSegment: duration · variability · yield · resource needs)
domain-catalog.ts the catalogue of what can be declared, plus the declared relations between axes
duration-estimator.ts seam for measured estimators injected by the host
iso-duration.ts xsd:duration parser (calendar-dependent units are rejected)
make-to-order.ts make-to-order production
task-fold.ts task folding
job-response.ts ISA-95 job response
event-journal.ts the journal + replay/replayFrom — the twin's memory
forecast.ts Monte-Carlo forecast (answers with a distribution)
divergence.ts predicted vs observed → drift, localised
counterfactual.ts branch from a past instant — "what if we had done X"
twin-observer.ts the automatic reconciliation loop
energy-ingest.ts metering intake (totalisers, demand windows)
energy-attribution.ts attributing energy to consumers
operational-ingest.ts operational-fact intake, and the routing that decides which fact a record is
scenario-validate.ts validating a declared scenario
face2-adapter.ts legacy records → canonical EPCIS (declarative mapping + validation) ← ACL
runtime.ts TwinRuntime — host-facing facade + subscription protocol ← domain/transport agnostic
{wms,yms,mes,ems}-profile.ts domain vocabulary only (bizStep / btt / standard anchors)
kernel.ts · {yms,mes,ems}-kernel.ts domain flow verbs (4 hooks) — extend FlowEngineNo dialect in the core.
contract/epcis/flow-engine/policy/reducer/runtimecarry no domain words. EPCIS lives inepcis.tsbecause it is a standard, not because it is WMS.order.kindandtask.resourceTypebelong to the domain. Policy interfaces areselectPlacement/selectStock, neverputaway/pallets.
FlowEngine — one base, four hooks per domain
abstract FlowEngine implements TwinKernel owns all the mechanics. A domain kernel implements only the flow verbs:
| hook | what it means |
|---|---|
| onArrival(spec) | inbound stimulus → create items + EPCIS + an inbound task |
| onOrder(spec) | order stimulus → create the order |
| allocate(order) | a created order → pick stock (policy) + create tasks. Time-window gating lives here too |
| onTaskComplete(task) | what completion means — a move (WMS/YMS) or a transformation (MES). Occupancy, EPCIS and order fulfilment are the domain's |
The base owns only the task lifecycle (claim · progress · complete · release · delta), so it is move-neutral. A new vertical is a profile (vocabulary) plus a kernel (4 hooks).
Four verticals — evidence that one base carries several flow shapes
| vertical | flow shape | how the base copes |
|---|---|---|
| WMS | movement (putaway/pick/pack/ship) + backorders | as-is |
| YMS | movement (spot/pull) + time-window scheduling (appointment ↔ dock door reservation) | scheduling absorbed as domain gating |
| MES | transformation (TransformationEvent) + multi-step routing + heterogeneous resources (cutter/welder) | resource matching = FlowTask.resourceType, a minimal base extension |
| EMS | the flow of measurement — totalising, window closing, peaks, attribution | intake + attribution modules (a "place" here is an electrical segment) |
EMS anchors to a different standard: logistics and production are measured against ISA-95/EPCIS, energy against IEC 61850 (equipment data model) and ISO 50001 (energy management). Where the standard has no matching name, that cell is left empty — forcing a nearby name would make the conformance table lie.
Four resources (ISA-95)
A task's resource needs are held as the standard's four kinds. Why not one bucket: people do not break down and live by shifts, equipment does break down and is judged by OEE, pallets come back. Mix them and one vocabulary attaches to the wrong thing, making both false.
| ISA-95 | kernel | requirement spec |
|---|---|---|
| Personnel | persons · PersonState | personnelSpecification = class + headcount |
| Equipment | equipment · EquipmentState | equipmentSpecification = class + count |
| PhysicalAsset | assets · AssetState (GS1 GRAI) | physicalAssetSpecification |
| Material | items · ItemState (EPCIS) | — |
Claim discipline: never start with a partial claim. If any kind is short, wait — half-claiming ties resources up with no work to show for it. That is why claiming (claim*) and committing (assign*) are separate. Everything is released on completion except the asset, which stays where it arrived (it is a physical thing — that is where recovery starts).
Why a pallet is not an item: in GS1, an SSCC (a logistic unit) and a GRAI (the returnable pallet itself) are different things. The same GRAI carries one SSCC today and another tomorrow. So we model them separately and link them (
AssetState.carrying↔ItemState.carriedBy).
Resources carry qualifications. A class requires tests (testSpecificationIds) and an instance holds results (testResults) — a required test that is missing, expired or failed means the qualification does not hold (meetsTests). "No result" and "failed" are never the same value.
Places and their physical conditions
locations · LocationState — a place is a node in the ISA-95 equipment hierarchy (Enterprise→Site→Area→StorageZone→StorageUnit).
A place holds physical conditions: observations (the latest reading per property — temperature, humidity …) and testSpecificationIds (the criteria that apply there). Without conditions you cannot judge the state of the goods, and only the twin can make this join — the metering system does not know where the goods were, and the logistics system does not know the conditions.
| axis | standard | what we added |
|---|---|---|
| observation | OperationsEvent + OperationsRecordTemplate (EffectiveTimestamp · EffectiveEndDate · HierarchyScope) | we narrowed the value to ValueType — a physical quantity with no unit cannot be judged |
| criteria | TestSpecificationCriteria (Expression, free text) | numeric limits limit: {minimum, maximum, uom} — free text cannot be evaluated |
| verdict | TestResult.EvaluatedCriterionResult (TextType) | narrowed to pass/fail · CBV dispositions conformant/non_conformant |
outsideLimit() answers three ways — outside (true), inside (false), cannot judge (undefined). It refuses when there is no numeric limit, when the value is not a number, or when the two sides declare different units. This keeps "unknown" from collapsing into "conformant"; in a regulated record, losing that distinction is not a bug but an incident. Missing evidence is counted separately by testEvidenceGaps().
Observations arrive as events (OP_EVENT.observation = location.measured) — an axis that lives only in state is an axis that quietly disappears. An interval observation (effectiveEndTime) is true only within its interval: if the source says "that morning", we do not invent 09:00.
Lots carry their test evidence
ItemState.testResults — the latest result per specification, each carrying the measurements behind it.
A record that says only "non-conformant" and one that says "we measured 4.2 °C and it was over the limit" are different objects. Regulated records are read after an incident, so a verdict without evidence does nothing at the moment it is needed. And the twin is the only thing that can carry this forward: lots cross systems, and the system that judged does not know what happened next.
In the standard the verdict is a separate record that points at its subject (TestResult.TestableObjectID), so events point and state folds it into the object — our narrowing, recorded in the contract. Only the latest result per specification is kept: history is a question for the journal, not for state.
When the source measures but does not judge, the kernel judges from the declared criteria and marks the verdict derived. A verdict from the field carries no mark — absence means the field said it. The kernel never overrides a verdict the source gave.
Operation specs and three layers of duration
OperationDef carries the ISA-95 OperationsSegment — duration (xsd:duration) · variability · parameters (yield, setup) · the three resource requirements. Duration is resolved strongest evidence first:
measured estimator (host-injected) > declared spec > kernel constant
specCoverage() counts which was used and reports the forecast's standing — relative (all constants: comparison only) · partial · absolute-capable · calibrated. It never refuses for lack of evidence; it says what the answer rests on.
What the standard does not fix: ISA-95 defines resource structure but not parameter-ID vocabulary. So
OP_PARAM(yield, setupDuration) is defined in one place, and the fact that we invented it is not hidden.
The three-channel contract (Face 1) and subscription
- State:
getSnapshot()plus a delta stream. Deltas are EPCIS (stock/place/aggregation/transformation) + operational deltas (task/equipment/order.status— the half EPCIS cannot reconstruct) + observations (location.measured) + test results (test.result). - Command (act):
dispatch(cmd)actually mutates state and therefore produces State deltas (closing the loop).order.hold/resumeare common; domains extend through thehandleCommandseam. - Scenario:
scenario.load/start/pause/setSpeed. A declared scenario is validated byscenario-validate.ts. - TwinRuntime:
subscribe(snapshot→delta, continuous revision) ·resync·tick. Sparse streaming — transitions and move-starts only, not every tick.
StateSnapshot axes: locations · items · equipment · persons · assets · tasks · orders · attentions · energy · conformance · nowTime · identityGrounding · unhandled · stepsWithoutMaterial.
nowTime is the twin speaking its own clock. A consumer that substitutes wall-clock time gets nonsense in a simulated twin, and in observation mode "now" is the last event time we heard. If it is absent, consumers do not measure.
The dual state model
- EPCIS journal (discrete): stock, place, aggregation, transformation. Emitted canonically through
epcis.tsand checked byvalidateEpcisEvent. - Operational and kinematic (continuous): equipment motion (from/to/progress), task and order progress. Mirrored as operational deltas.
ObservedReducerfolds both into the same State, whether the events came from a simulation or from the field.
Two drivers, one rule — the conformance harness
Two things produce state (simulation tick() and observation apply()). If each has its own copy of the mapping to the contract, fixing one silently diverges from the other — nearly every contract field is optional, so "not filling it" is legal and the compiler says nothing.
Invariant: every fact in kernel state must go out as an event (state ⊆ events).
A fact that does not go out is invisible to the mirror, cannot be restored from the journal (time travel), and is missing from the seed a forecast forks from. test/driver-conformance.test.ts runs a simulation, feeds its own events into a mirror, and compares the two snapshots. Legitimate exceptions are held as constants with a stated reason — never as prose — for example the host-integrated oee and interpolated values that do not travel every tick; their anchors must still match.
Restart — fold from a resume point, not from line one
This axis exists so a restart does not re-fold the journal from the beginning. On a real site whose journal runs to tens of millions of rows, that is not a performance question but a question of possible versus impossible.
observedCheckpoint(): ReducerCheckpoint | undefined // take the resume point (undefined unless observation-driven)
restoreObserved(cp: ReducerCheckpoint): void // stand the reducer back up from it
replayFrom(model, checkpoint, events) // fold the resume point plus the tail onlyA state snapshot cannot serve as the seed. The reducer holds more than what consumers see — aggregations awaiting their parent, quantities mid-tally, events counted because there was nowhere to put them. Restore only the state and fold the tail and you quietly get a different answer from folding from zero.
That equivalence (resume point + tail == fold from zero) is proved by test/observed-checkpoint.test.ts. The background, the wrong turns and the host wiring are written up in design/fold-and-resume.md.
Axes the source will not restate are carried separately (hydrateContinuity) — the open demand window's accumulation, totaliser baselines, the peak since observation began, and when each attention started. Observation axes (stock, place, equipment) are deliberately not seeded that way.
What happened does not disappear; what is current does
A later transition that does not mention a field does not erase it — if that field records something that happened. A thing that happened cannot become a thing that did not happen, so materialActual survives until the source restates it (an explicit empty array clears it).
The opposite rule holds for current values. remainingMs, progress and the resource reference are emitted only while a task is running; keeping them would show a finished task still holding time and still gripping a resource it released. Before widening this list, ask whether the field's absence could itself be a fact. If it could, it does not belong here.
Attentions — the kernel judges, but writes no sentences
attentions are judgements the kernel makes for itself (deriveAttentions / collectAttentions). Each carries kind · severity · anchor (whose problem it is) · params (the values behind it).
It writes no sentences. The kernel emits keys and values; wording belongs to the consumer — a kernel that holds Korean sentences is a twin bound to one language. And a judgement always carries its evidence: an out-of-limit observation reports the value, the unit, the limit, when it was measured, and whether it was measured or derived. On a site whose sensor has stopped, a signal without that timestamp reads as though it just happened.
When it cannot judge, it says nothing — it neither invents a violation nor manufactures an unverified pass.
What makes it a twin — forecasting from now, and reconciliation
Observation, prediction and action existing separately is a mirror plus a simulator. A twin is their coupling:
fork()— an isolated engine cloned from the current state, in-flight work included. The original (live or sim) keeps going while the fork rolls forward from now to predict completion times and throughput and to explore what-ifs. State, scenario and RNG state are all cloned, so it is a true continuation.compareStates(predicted, actual)— compares at the same instant and localises drift between model and reality (per item / location / order field). Divergence is the signal to look, or to intervene.monteCarloForecast— N seed-varied forks give the metric as a distribution (min/mean/p50/p90/max). Not "when will it finish" but "P90 completion time, probability of running out".EventJournal+replay/replayFrom— the twin's memory.until(revision)/untilSimTime(iso)reconstruct any past instant (time travel). In the host, append is swapped for a database event table.TwinHistory+counterfactualAt— return to instant T, fork an alternative decision, and compare against the baseline. "What if we had done X."TwinObserver— forks a prediction periodically and compares once reality reaches that instant, raising drift as an event.
A forecast also states its own limits: steps taken without being able to link an item (stepsWithoutMaterial) and orders blocked because the source never named one (blocked-source-omits-material) are reported in the result. Passing over them silently would make a forecast with no lineage look normal.
Extension points
- A new vertical:
{x}-profile.ts(vocabulary) +{x}-kernel.ts(4 hooks). - Find by capability: never bind to a concrete type — resources declare capabilities and operations require them.
- Allocation policy: swap
AllocationPolicy(firstFit/partialFit ship with it; FEFO/nearest/zone can be added). - Duration estimators: the host injects estimators built from measurements.
- Face2 adapter: a real system's payload → declarative mapping → canonical EPCIS.
- Host binding: wrap
TwinRuntimein a GraphQL subscription or service (transport stays a thin host layer).
Not implemented (out of scope)
Host binding (transport, persistence, connectors) · board binding (component ↔ SGLN) · domain depth (WMS multi-line, YMS load/unload AggregationEvent, MES BOM).
Gaps in the model, stated plainly:
- Personnel skill level — classes and qualification tests exist, but skill does not change duration
- Asset recovery work — an empty pallet simply stays where it arrived
equipment/locationslevel conflation — one list holds both mobile equipment and work centres, the other both addressable places and work positions (the rename cleaned up the names, not the levels)- Next-event jumping — ticks are still fixed-interval
- Live forecasting — a mirror's state can be forked, but the live path's own forecast is deliberately not unified yet
- Per-segment resume points for a journal whose structure changed several times — only the latest is kept
1.0 gate: after the equipment / locations levels are either resolved or fixed as additive-only. Shipping 1.0 with a contract break already scheduled makes the next round a 2.0.
Design sources of truth
operato-twin/design/ —
fold-and-resume.md— folding and resume points (the wrong turns, the principles, what is implemented)runtime-state-model.md— live = authority / history = derived, the time axis, identity04-decisions.md— ADRsplans/simulation-spec.md(specs, estimators, forecast standing) ·plans/four-resources-and-conformance.md(four resources, invariants) ·plans/kernel-unification-live-observe.md(driver unification)profiles/ems.md— the energy profile's standard anchors
