# vsm-engine

> Module created via `npm run new:module vsm-engine`.

## Owner

Wertstrom

## Purpose

Wertstrom P1 (KAR-878/KAR-986, execution-prompt §8 + §17): the Berechnungs-
Engine v2 (Takt, Bestandsreichweite, Timeline-Leiter, Engpass v2, effektive
Kapazität/PCE/Qualität) and the Validierungs-Engine, as a pure, unit-tested
library — **no UI wiring yet**. Every metric returns its value(s) alongside
a `MetricExplain` (formula + data basis + exclusions), the same contract
`components/wertstrom/vsm-metrics.ts` established for P0.

## Architecture decision

New `lib/vsm-engine/` module (ADR 019 golden path) rather than extending
`components/wertstrom/vsm-metrics.ts` in place. Two reasons: (1) the
criterion from the P1 brief — "zentrale, getestete Engine, keine Formeln in
UI-Komponenten" + reuse by API routes/exports later — points at `lib/`, not
`components/`, as the correct layer (`components/` is UI; a server route
importing calculation logic from it would be backwards even though the
current `eslint-plugin-boundaries` config technically permits module→module
imports either direction); (2) `vsm-metrics.ts` and its 425+ regression
tests stay completely untouched (zero diff) — this PR is purely additive,
so there is no risk to the existing, working P0 UI. Wiring these v2 metrics
into `vsm-editor.tsx` (replacing/augmenting `computeTimeline`/
`findBottleneckId`) is explicitly P3 ("das UX-Herz", Capability-Matrix
phase plan), not P1.

## Why no VsmNode/API-schema changes

Every function here reads only VsmNode/VsmConnection fields that already
exist (`cycleTimeSec`, `partsPerCycle`, `capacityPerHour`, `oee`,
`scrapRate`, `quantity`, `waitTimeSec`, `transportTimeSec`,
`vaClass`/`isValueAdded`, `type`). `VsmNode.demand` also exists today, but
is deliberately **not** read directly by any function here: every
demand-shaped input (`TaktInput.demandQuantity`,
`ValidationContext.demandUnitsPerDay`, `InventoryDemandRate.unitsPerDay`) is
the engine's own parameter — the same purity choice as the concepts below,
so a caller can pass a value sourced from `VsmNode.demand`, a form, or a
project setting without this module assuming which one is authoritative.
Concepts the spec needs that do **not** exist on VsmNode today at all —
work-time/shift model, rework rate/time, standalone
availability/performance/parallel-units — are, likewise, the engine's
**own** parameter types (`ShiftModelInput`, `TaktInput`,
`ProcessQualityInput`, `NodeCapacityOverrides`, …), not new VsmNode fields.
This was a deliberate scope choice, not an oversight: it keeps this PR
strictly additive (no `lib/vsm-types.ts` / `lib/api/schemas.ts` / editor
changes at all) and defers "where does e.g. rework rate live on a node" to
whichever phase actually wires up the input UI (P2 data model / P3+ UI) —
that decision deserves its own review, not a side effect of the engine PR.
A future caller assembles these parameter objects from wherever the real
values live (a VsmNode field, a form, a project setting).

## Formula priority / fallback rules (§8.3 double-counting)

`computeEffectiveCapacity` (`internal/capacity.ts`):

1. A **measured** `capacityPerHour` on the node wins outright, used as-is —
   no efficiency factor applied on top (it already reflects real losses).
2. Otherwise, capacity is **derived** from cycle time × parallel units,
   derated by one combined efficiency factor:
   - `node.oee` set → OEE wins outright (already contains
     availability×performance×quality). A separately-supplied
     availability/performance/quality override for the same node is
     **ignored** by the calc — never multiplied on top of OEE.
   - Else → availability/performance/quality overrides multiply together
     (any missing factor defaults to 100%, a documented assumption).
   - Else (nothing at all) → factor 1.0, documented as an assumption.

This calc function never produces a double-counted number. The companion
Validierungs-Engine check `capacity.double-counted-availability` is what
surfaces the underlying **contradictory data entry** (both OEE and a
standalone factor set on the same node) to a user — the calc/validation
split mirrors how §8.3 and §17.3 divide the same concern.

## A11 Validation — spec-bullet mapping

Every §17.1–17.5 bullet is either an implemented check (see
`internal/validation.ts`, one function per check) or a documented gap in
`KNOWN_VALIDATION_GAPS` (exported from the module) with a concrete
`reasonDe`. No check is fake-implemented (e.g. an always-passing stub) to
paper over missing data. Two deliberate merges avoid firing the same
warning twice under different spec-bullet ids:

- `capacity.utilization-above-takt` covers both "capacity below demand" and
  "utilization above 100 percent" (§17.3) — the same condition.
- `inventory.missing-demand-basis` covers both "lead time without demand
  basis" (§17.2) and "inconsistent coverage" (§17.5) — same underlying gap.

Gaps fall into three kinds, all listed in `KNOWN_VALIDATION_GAPS`:
data-model gaps (no product/shift-calendar/safety-stock/reorder-point
concept, no information-flow edge type — matches the brief's own examples),
one **structurally moot** pair (cycle-time/quantity units — the schema
already enforces a single implicit unit, so the check can never fire), and
a handful of **too-ambiguous-to-implement** bullets ("missing process
sequence", "broken material flows", "inconsistent parallel resources")
that the source spec names as bare bullets without defining them further —
implementing them would mean inventing a definition, risking a check that
silently doesn't match the real spec intent.

## Wertstrom P2 addendum (KAR-878/KAR-986, connection.kind)

P2 adds `VsmConnection.kind` (`'materialFlow' | 'information'`, absent =
materialFlow — Capability-Matrix A7). This module's only kind-aware change is
`internal/validation.ts`'s cycle detection (`findFirstCycle`): it now excludes
`kind === 'information'` edges from the directed-cycle search, because a
customer→PPS→supplier information-flow loop closing back against the
material-flow chain is standard VSM topology, not a modelling error — see the
function's own doc comment. Every other check (dangling refs, negative
values, …) stays kind-agnostic on purpose, since those are equally invalid
for either kind. `structural.broken-information-flow` is removed from
`KNOWN_VALIDATION_GAPS` — it is now covered generically by
`checkDanglingConnections`, which never distinguished `kind` in the first
place. No other `internal/*.ts` file reads `VsmConnection` at all (only
`validation.ts` does — capacity/bottleneck/timeline/quality/inventory work
per-node), so no other engine change was needed for A7. A12 (provenance)
remains a purely descriptive `VsmNode` field with no calculation/validation
impact — deliberately not wired into this module.

**Review-Fix F4 (PR #353 adversarial review, corrected A8 scope note):** A8's
`inventoryMaxQuantity` ("FIFO mit Kapazität") is NOT purely descriptive —
`checkNegativeInventoryMaxQuantity` (negative capacity, critical) and
`checkFifoQuantityAboveCapacity` (`quantity > inventoryMaxQuantity` on a FIFO
lane, warning) validate it in `internal/validation.ts`, same
`checkNegativeInventory`-style pattern as `quantity`. `inventoryKind` itself
and every OTHER calculation module (capacity/bottleneck/timeline/quality/
inventory-coverage) still do not read either A8 field — only validation
does.

## Public API

The public surface is the named exports of `index.ts`. Anything in
`internal/` is private and may change without notice.

## Dependencies

- `@/lib/vsm-types` (`VsmNode`, `VsmConnection`) — read-only, no changes.
- No Supabase, no React, no other `lib/` or `components/` module.

## Tests

`__tests__/*.test.ts`, one file per `internal/` module, each with
hand-calculated formula tests (the calculation is shown in the test
comment) plus the documented edge cases (0 vs. `undefined`, missing takt,
OEE-vs-availability double counting, rework 0%/100%, …). Run via
`npm test -- lib/vsm-engine`.

## Conventions (ADR 019)

- No deep imports into other modules' `internal/` directories.
- This module never imports from `lib/customers/**` (ADR 010 / ADR 015).
- Customer-specific behaviour goes via the active composition profile (ADR 013).
