@operato/twin-kernel 0.11.11 → 0.11.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,6 +26,11 @@ export declare class TwinHistory {
26
26
  }
27
27
  export interface CounterfactualResult {
28
28
  atSimMs: number;
29
+ /**
30
+ * **실제로 돌린 만큼**이다 — 청한 만큼이 아니다(ADR-0042 ⑤). 예산에 잘리면 여기가 짧아지고,
31
+ * 그 사실은 소비처가 본문에 적는다. 조용히 덜 돌리고 청한 값을 적는 것이 이 칸이 막는 일이다.
32
+ */
33
+ horizonMs: number;
29
34
  withAlt: StateSnapshot;
30
35
  baseline: StateSnapshot;
31
36
  effect: StateDivergence;
@@ -35,6 +40,38 @@ export interface CounterfactualOptions {
35
40
  horizonMs: number;
36
41
  tickMs?: number;
37
42
  }
43
+ /**
44
+ * 사람이 기다리는 자리에서 돌릴 때의 양보·예산 — 미리보기(ADR-0042 ⑤).
45
+ *
46
+ * 양보 간격은 호스트 예측 경로와 같은 25 틱이 기본이다. 틱마다 양보하면 양보가 비용이 되고, 라이브 틱
47
+ * 간격보다 길게 붙잡으면 그 사이 트윈들이 한 박자씩 밀린다(headless-twin 예측 리졸버의 실측 주석).
48
+ */
49
+ export interface CounterfactualBreath {
50
+ /** 몇 틱마다 한 번 내주나. @default 25 */
51
+ every?: number;
52
+ /** 내주는 방법 — 보통 `setImmediate` 한 번. */
53
+ breathe: () => Promise<void>;
54
+ /** 벽시계 예산(ms). 넘으면 **두 분기를 같은 시각에서** 멈추고 `horizonMs` 에 돌린 만큼을 적는다. */
55
+ budgetMs?: number;
56
+ /** 벽시계 — 시험이 바꿔 낀다. @default performance.now */
57
+ now?: () => number;
58
+ }
59
+ /**
60
+ * 반사실 — **base 의 지금**에서 두 분기를 시뮬레이션해 대안의 효과를 잰다.
61
+ * withAlt = base + 대안 → +H / baseline = base 그대로 → +H / effect = 둘의 발산.
62
+ *
63
+ * base 는 live 커널이어도 된다(그 경우가 ADR-0042 의 미리보기다) — fork 만 하고 base 는 건드리지 않는다.
64
+ * 미리보기와 「과거 T 에 그랬다면」이 **같은 몸통**을 쓰는 것이 결정 ① 이다: 미리 본 것과 적용된 것이
65
+ * 갈리는 날을 막는다.
66
+ */
67
+ export declare function counterfactualFrom(base: CounterfactualTwin, opts: CounterfactualOptions): CounterfactualResult;
68
+ /**
69
+ * 같은 반사실을 **양보하며** 돌린다 — 요청 하나 안에서 사람이 기다리는 미리보기용.
70
+ *
71
+ * 몸통은 `counterfactualFrom` 과 같다(`pairedTicks`). 다른 것은 `every` 틱마다 `breathe()` 를 기다리는 것과,
72
+ * `budgetMs` 를 넘으면 그 자리에서 멈추는 것뿐이다. 멈춘 사실은 `horizonMs` 가 청한 값보다 작은 것으로 드러난다.
73
+ */
74
+ export declare function counterfactualFromAsync(base: CounterfactualTwin, opts: CounterfactualOptions, breath: CounterfactualBreath): Promise<CounterfactualResult>;
38
75
  /**
39
76
  * 반사실: history 의 시각 T 상태에서 두 분기를 시뮬레이션해 대안의 효과를 잰다.
40
77
  * withAlt = T 상태 + 대안 → T+H / baseline = T 상태 그대로 → T+H / effect = 둘의 발산.
@@ -42,6 +42,70 @@ export class TwinHistory {
42
42
  return t;
43
43
  }
44
44
  }
45
+ const BREATH_EVERY = 25;
46
+ const TICK_GUARD = 1_000_000;
47
+ /**
48
+ * 두 분기를 **한 틱씩 번갈아** 돌린다 — 그래서 어느 순간에 멈춰도 둘은 같은 시각에 서 있고, 비교가 성립한다.
49
+ * 둘을 따로 끝까지 돌리면 예산에 잘릴 때 한쪽만 앞서 간다.
50
+ */
51
+ function* pairedTicks(withAlt, baseline, target, step) {
52
+ let guard = 0;
53
+ while (clockOf(withAlt) < target && guard++ < TICK_GUARD) {
54
+ withAlt.tick(step);
55
+ baseline.tick(step);
56
+ yield guard;
57
+ }
58
+ }
59
+ /** base 에서 두 분기를 낸다. 대안은 withAlt 에만 들어간다. base 자체는 건드리지 않는다. */
60
+ function branch(base, opts) {
61
+ const withAlt = base.fork();
62
+ opts.alternative(withAlt);
63
+ const baseline = base.fork();
64
+ return { withAlt, baseline };
65
+ }
66
+ function settle(atSimMs, withAlt, baseline) {
67
+ const a = withAlt.getSnapshot();
68
+ const b = baseline.getSnapshot();
69
+ return { atSimMs, horizonMs: Math.min(a.simClockMs, b.simClockMs) - atSimMs, withAlt: a, baseline: b, effect: compareStates(a, b) };
70
+ }
71
+ /**
72
+ * 반사실 — **base 의 지금**에서 두 분기를 시뮬레이션해 대안의 효과를 잰다.
73
+ * withAlt = base + 대안 → +H / baseline = base 그대로 → +H / effect = 둘의 발산.
74
+ *
75
+ * base 는 live 커널이어도 된다(그 경우가 ADR-0042 의 미리보기다) — fork 만 하고 base 는 건드리지 않는다.
76
+ * 미리보기와 「과거 T 에 그랬다면」이 **같은 몸통**을 쓰는 것이 결정 ① 이다: 미리 본 것과 적용된 것이
77
+ * 갈리는 날을 막는다.
78
+ */
79
+ export function counterfactualFrom(base, opts) {
80
+ const atSimMs = clockOf(base);
81
+ const step = opts.tickMs ?? 1000;
82
+ const { withAlt, baseline } = branch(base, opts);
83
+ for (const _ of pairedTicks(withAlt, baseline, atSimMs + opts.horizonMs, step)) {
84
+ /* 끝까지 — 양보 없이. 결정적이다. */
85
+ }
86
+ return settle(atSimMs, withAlt, baseline);
87
+ }
88
+ /**
89
+ * 같은 반사실을 **양보하며** 돌린다 — 요청 하나 안에서 사람이 기다리는 미리보기용.
90
+ *
91
+ * 몸통은 `counterfactualFrom` 과 같다(`pairedTicks`). 다른 것은 `every` 틱마다 `breathe()` 를 기다리는 것과,
92
+ * `budgetMs` 를 넘으면 그 자리에서 멈추는 것뿐이다. 멈춘 사실은 `horizonMs` 가 청한 값보다 작은 것으로 드러난다.
93
+ */
94
+ export async function counterfactualFromAsync(base, opts, breath) {
95
+ const atSimMs = clockOf(base);
96
+ const step = opts.tickMs ?? 1000;
97
+ const every = breath.every ?? BREATH_EVERY;
98
+ const now = breath.now ?? (() => performance.now());
99
+ const started = now();
100
+ const { withAlt, baseline } = branch(base, opts);
101
+ for (const n of pairedTicks(withAlt, baseline, atSimMs + opts.horizonMs, step)) {
102
+ if (breath.budgetMs !== undefined && now() - started >= breath.budgetMs)
103
+ break;
104
+ if (n % every === 0)
105
+ await breath.breathe();
106
+ }
107
+ return settle(atSimMs, withAlt, baseline);
108
+ }
45
109
  /**
46
110
  * 반사실: history 의 시각 T 상태에서 두 분기를 시뮬레이션해 대안의 효과를 잰다.
47
111
  * withAlt = T 상태 + 대안 → T+H / baseline = T 상태 그대로 → T+H / effect = 둘의 발산.
@@ -50,14 +114,5 @@ export function counterfactualAt(history, atSimMs, opts) {
50
114
  const base = history.at(atSimMs);
51
115
  if (!base)
52
116
  return undefined;
53
- const step = opts.tickMs ?? 1000;
54
- const target = atSimMs + opts.horizonMs;
55
- const withAlt = base.fork();
56
- opts.alternative(withAlt);
57
- const baseline = base.fork();
58
- const run = (t) => { let g = 0; while (clockOf(t) < target && g++ < 1_000_000)
59
- t.tick(step); };
60
- run(withAlt);
61
- run(baseline);
62
- return { atSimMs, withAlt: withAlt.getSnapshot(), baseline: baseline.getSnapshot(), effect: compareStates(withAlt.getSnapshot(), baseline.getSnapshot()) };
117
+ return counterfactualFrom(base, opts);
63
118
  }
@@ -1,4 +1,4 @@
1
- import type { MaterialActual, TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, TaskStatus, StructureShift, IdentityGroundingView, IdentityDeclaration, TestSpecificationCriterion, LocationObservation } from '@operato/ops-contract';
1
+ import type { MaterialActual, TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, TaskStatus, StructureShift, IdentityGroundingView, IdentityDeclaration, TestSpecificationCriterion, LocationObservation, DispositionFact } from '@operato/ops-contract';
2
2
  import { type RatedUsage, type UsedUsage } from '@operato/ops-contract';
3
3
  import type { VocabularyElement } from '@operato/ops-contract';
4
4
  import type { ReducerCheckpoint } from './observed-reducer.ts';
@@ -46,6 +46,13 @@ export interface FlowItem {
46
46
  ilmd?: Record<string, unknown>;
47
47
  /** 선언된 모든 수량 — 표준 `MaterialLot.Quantity`(복수). 관측에서 온 물품이 여러 단위를 들 수 있다. */
48
48
  quantities?: MaterialQuantity[];
49
+ /**
50
+ * 로트의 두 표준 칸 — ISA-95 `Disposition`(`nonconformance`) · `Status`(`status`). 관측 모드에서 리듀서가
51
+ * 앉힌 것을 `settleObserved` 가 옮기고 `itemState` 가 스냅샷에 낸다. 둘 중 하나가 빠지면 리듀서에는 있는데
52
+ * 화면에는 없는 칸이 된다(2026-09-16 에 둘 다 그랬다).
53
+ */
54
+ nonconformance?: DispositionFact;
55
+ status?: string;
49
56
  }
50
57
  /** 물리 자산 — 반복사용(팔레트·랙·용기). 설비도 물품도 아니다(GRAI vs SSCC 구분은 계약 주석 참조). */
51
58
  export interface FlowAsset extends EffectivePeriod {
@@ -1034,7 +1034,14 @@ export class FlowEngine {
1034
1034
  ...(it.subLotId ? { subLotId: it.subLotId } : {}),
1035
1035
  ...(it.definitionId ? { definitionId: it.definitionId } : {}),
1036
1036
  gtin: it.gtin, qty: it.qty ?? 1, uom: it.uom, ...(it.quantities?.length ? { quantities: it.quantities } : {}),
1037
- parent: it.parent, carriedBy: it.carriedBy, expiry: it.expiry, ilmd: it.ilmd
1037
+ parent: it.parent, carriedBy: it.carriedBy, expiry: it.expiry, ilmd: it.ilmd,
1038
+ /*
1039
+ * 로트의 두 표준 칸 — ISA-95 `Disposition`(`nonconformance`)과 `Status`(`status`). 이 복사가 칸을 하나씩
1040
+ * 옮기는 자리라, 여기 없는 칸은 리듀서에 앉았어도 **스냅샷에 나오지 않는다**(2026-09-16 에 `status` 를
1041
+ * 넣다가 `nonconformance` 도 여기서 빠져 있던 것을 찾았다 — 자리는 있고 길이 없는 부류). 있을 때만 옮긴다.
1042
+ */
1043
+ ...(it.nonconformance ? { nonconformance: it.nonconformance } : {}),
1044
+ ...(it.status ? { status: it.status } : {})
1038
1045
  });
1039
1046
  }
1040
1047
  for (const m of snap.equipment) {
@@ -1467,15 +1474,23 @@ export class FlowEngine {
1467
1474
  }
1468
1475
  dispatchInner(cmd) {
1469
1476
  const ok = () => ({ commandId: cmd.commandId, accepted: true });
1470
- // 거절 사유 = 언어 중립 코드 + 원시 파라미터. error 는 영어 폴백(로그·개발자용).
1471
- const fail = (errorCode, errorParams) => ({ commandId: cmd.commandId, accepted: false, errorCode, errorParams, error: errorCode });
1477
+ /*
1478
+ * 거절 사유 = 언어 중립 코드 + 원시 파라미터 + 영어 문장.
1479
+ *
1480
+ * `error` 는 **문장**이어야 한다 — 코드 문자열을 다시 넣지 않는다. 화면은 모르는 코드를 만나면 `error` 를
1481
+ * 그대로 내는데(ADR-0042 ③, 매핑 표 없음), 그 자리에 코드가 들어 있으면 사람이 `kind-required` 같은 날
1482
+ * 코드를 본다. 그리고 「무엇이 없었나」를 말하는 코드(`*-not-found`)는 그 무엇을 params 에 싣는다 — 다음에
1483
+ * 할 일(다시 고르기)은 없었던 것의 이름을 알아야 할 수 있다.
1484
+ */
1485
+ const fail = (errorCode, error, errorParams) => ({ commandId: cmd.commandId, accepted: false, errorCode, errorParams, error });
1486
+ const noEquipment = (resourceId) => fail('resource-not-found', `no equipment with id "${resourceId}" in this twin`, { resourceId });
1472
1487
  switch (cmd.type) {
1473
1488
  case CMD.orderHold:
1474
1489
  case CMD.orderResume: {
1475
1490
  const orderId = cmd.args?.orderId;
1476
1491
  const order = orderId ? this.orders.get(orderId) : undefined;
1477
1492
  if (!order)
1478
- return fail('order-not-found', { orderId: orderId ?? '' });
1493
+ return fail('order-not-found', `no order with id "${orderId ?? ''}" in this twin`, { orderId: orderId ?? '' });
1479
1494
  order.held = cmd.type === CMD.orderHold;
1480
1495
  this.emitOrder(order);
1481
1496
  return ok();
@@ -1496,9 +1511,10 @@ export class FlowEngine {
1496
1511
  // Operable 코어 — 자원(설비·설비) 제어. capability-keyed(resourceId), 모든 operable 자원 공통.
1497
1512
  case CMD.resourceHold:
1498
1513
  case CMD.resourceResume: {
1499
- const m = this.equipment.get(cmd.args?.resourceId ?? '');
1514
+ const resourceId = cmd.args?.resourceId ?? '';
1515
+ const m = this.equipment.get(resourceId);
1500
1516
  if (!m)
1501
- return fail('resource-not-found');
1517
+ return noEquipment(resourceId);
1502
1518
  m.held = cmd.type === CMD.resourceHold; // 계획 정지 → 배정 스킵
1503
1519
  this.emitEquipment(m);
1504
1520
  return ok();
@@ -1507,7 +1523,7 @@ export class FlowEngine {
1507
1523
  const a = cmd.args;
1508
1524
  const m = this.equipment.get(a?.resourceId ?? '');
1509
1525
  if (!m)
1510
- return fail('resource-not-found');
1526
+ return noEquipment(a?.resourceId ?? '');
1511
1527
  if (m.status !== 'down') {
1512
1528
  m.status = 'down'; // 비계획 고장 주입 — 기존 고장 machinery(processFailures)가 downMs 누적·수리 처리
1513
1529
  m.repairUntilMs = this.clockMs + (Number(a?.durationMs) || m.mttrMs || 60_000);
@@ -1516,9 +1532,10 @@ export class FlowEngine {
1516
1532
  return ok();
1517
1533
  }
1518
1534
  case CMD.resourceRepair: {
1519
- const m = this.equipment.get(cmd.args?.resourceId ?? '');
1535
+ const resourceId = cmd.args?.resourceId ?? '';
1536
+ const m = this.equipment.get(resourceId);
1520
1537
  if (!m)
1521
- return fail('resource-not-found');
1538
+ return noEquipment(resourceId);
1522
1539
  if (m.status === 'down') {
1523
1540
  m.status = m.taskId ? 'busy' : 'idle'; // 진행중 task 있으면 재개(고장모델 수리 로직과 동형)
1524
1541
  m.repairUntilMs = undefined;
@@ -1529,9 +1546,10 @@ export class FlowEngine {
1529
1546
  return ok();
1530
1547
  }
1531
1548
  case CMD.resourceResetMetrics: {
1532
- const m = this.equipment.get(cmd.args?.resourceId ?? '');
1549
+ const resourceId = cmd.args?.resourceId ?? '';
1550
+ const m = this.equipment.get(resourceId);
1533
1551
  if (!m)
1534
- return fail('resource-not-found');
1552
+ return noEquipment(resourceId);
1535
1553
  m.runMs = 0;
1536
1554
  m.setupMs = 0;
1537
1555
  m.downMs = 0;
@@ -1547,9 +1565,9 @@ export class FlowEngine {
1547
1565
  // 새 설비는 즉시 배정 대상(다음 tick). 유일 id 생성(충돌 회피). 좌표/persistence 는 호스트 몫(커널=위상만).
1548
1566
  const a = cmd.args;
1549
1567
  if (!a?.kind)
1550
- return fail('kind-required');
1568
+ return fail('kind-required', 'resource.add needs args.kind');
1551
1569
  if (!a?.homeLocation || !this.locations.has(a.homeLocation))
1552
- return fail('home-location-not-found', { homeLocation: a?.homeLocation ?? '' });
1570
+ return fail('home-location-not-found', `no location "${a?.homeLocation ?? ''}" to use as home`, { homeLocation: a?.homeLocation ?? '' });
1553
1571
  const count = Math.max(1, Math.min(50, Number(a.count) || 1));
1554
1572
  let seq = this.equipment.size;
1555
1573
  for (let i = 0; i < count; i++) {
@@ -1569,7 +1587,7 @@ export class FlowEngine {
1569
1587
  }
1570
1588
  /** 도메인 커맨드 처리(order.release 등). 기본은 거절 — 도메인이 override. */
1571
1589
  handleCommand(cmd) {
1572
- return { commandId: cmd.commandId, accepted: false, errorCode: 'unknown-command', errorParams: { type: cmd.type }, error: `unknown-command: ${cmd.type}` };
1590
+ return { commandId: cmd.commandId, accepted: false, errorCode: 'unknown-command', errorParams: { type: cmd.type }, error: `no command of type "${cmd.type}"` };
1573
1591
  }
1574
1592
  scenario = {
1575
1593
  load: (def) => {
@@ -3858,7 +3876,10 @@ export class FlowEngine {
3858
3876
  ...(i.uom ? { uom: i.uom } : {}),
3859
3877
  ...(i.quantities?.length ? { quantities: i.quantities } : {}),
3860
3878
  ...(i.expiry !== undefined ? { expiry: i.expiry } : {}),
3861
- ...(i.ilmd ? { ilmd: i.ilmd } : {})
3879
+ ...(i.ilmd ? { ilmd: i.ilmd } : {}),
3880
+ /* 로트의 두 표준 칸(§FlowItem) — 여기서 빠지면 리듀서·엔진에는 있고 스냅샷에는 없다. */
3881
+ ...(i.nonconformance ? { nonconformance: i.nonconformance } : {}),
3882
+ ...(i.status ? { status: i.status } : {})
3862
3883
  };
3863
3884
  }
3864
3885
  /**
@@ -12,6 +12,8 @@ interface ProjItem {
12
12
  disposition?: string;
13
13
  /** ISA-95 의 부적합 처분 — 사람이 정한 결정. 로트 전체의 사실이라 부분마다 같은 값이 앉는다. */
14
14
  nonconformance?: DispositionFact;
15
+ /** ISA-95 `MaterialLot.Status` — `nonconformance` 와 다른 칸(§계약 `ItemState.status`). 마지막 하나만. */
16
+ status?: string;
15
17
  parent?: string;
16
18
  qty?: number;
17
19
  uom?: string;
@@ -566,6 +566,27 @@ export class ObservedReducer {
566
566
  part.nonconformance = { ...d };
567
567
  break;
568
568
  }
569
+ case OP_EVENT.materialLot: {
570
+ /*
571
+ * **로트 상태** — ISA-95 `MaterialLot.Status`. 출하 승인이 이 길로 온다(§계약 `OP_EVENT.materialLot`).
572
+ * 처분과 같은 좁힘이다: 대상을 가리켜 들어오고, 그 이름을 가진 부분 모두에 앉는다. **물품을 지어내지
573
+ * 않는다** — 모르는 로트의 상태는 `unhandled` 로 세어서 낸다.
574
+ */
575
+ const d = e.data;
576
+ if (!d?.lotId || !d?.status)
577
+ break;
578
+ if (this.stale(`material-lot:${d.lotId}`, e))
579
+ return;
580
+ /* `lotId` 는 표준 `MaterialLotID` — 값은 로트의 식별자, 즉 여기서 `epc` 로 든 것과 같다. */
581
+ const parts = [...this.items.values()].filter(i => i.epc === d.lotId);
582
+ if (!parts.length) {
583
+ this.noteUnhandled(e, `${OP_EVENT.materialLot}:unknown-lot`);
584
+ break;
585
+ }
586
+ for (const part of parts)
587
+ part.status = d.status;
588
+ break;
589
+ }
569
590
  case OP_EVENT.observation: {
570
591
  /*
571
592
  * **자리의 물리 관측** — 속성마다 마지막 값 하나만 든다(§`LocationState.observations`).
@@ -40,6 +40,8 @@ __export(index_exports, {
40
40
  compareStates: () => compareStates,
41
41
  constantDuration: () => constantDuration,
42
42
  counterfactualAt: () => counterfactualAt,
43
+ counterfactualFrom: () => counterfactualFrom,
44
+ counterfactualFromAsync: () => counterfactualFromAsync,
43
45
  demandWindowStart: () => demandWindowStart,
44
46
  deriveAttentions: () => deriveAttentions,
45
47
  electricityCost: () => electricityCost,
@@ -650,6 +652,23 @@ var OP_EVENT = {
650
652
  * 이것은 **결정**이다. 누가·언제·왜 그렇게 정했는지의 자리는 그쪽에 없다.
651
653
  */
652
654
  disposition: "nonconformance.disposition",
655
+ /**
656
+ * **로트 상태** — ISA-95 `MaterialLot.Status`(B2MML-Material.xsd). 「이 로트가 지금 어떤 것이냐」의 결정.
657
+ *
658
+ * ── `nonconformance.disposition` 과 다른 사실이다 (2026-09-16) ──────────────
659
+ * 그것은 표준 `Disposition` — **부적합을 어떻게 처리하나**(재작업·특채·폐기). 이것은 표준 `Status` — 적합한
660
+ * 로트에도 붙는 상태(released · quarantine · on-hold …). 출하 승인(batch release)이 이 축이다. 처분에 실으면
661
+ * 정상 출하가 전부 부적합 이력이 된다 — 진짜 term 을 틀린 뜻으로 쓰는 것이 자리가 없는 것보다 나쁘다.
662
+ *
663
+ * ── 낱말은 열려 있다 ──────────────────────────────────────────────────────
664
+ * 오더 상태와 같은 규율 — 커널이 이 낱말로 무엇을 계산하지 않으므로 닫을 근거가 없다. 도메인이 소유한다.
665
+ *
666
+ * ── 출발과 다른 사실이다 ─────────────────────────────────────────────────
667
+ * 승인은 자재를 움직이지 않는다. 자재가 트윈 밖으로 나가는 것은 EPCIS `shipping` 사건이다. 둘을 한 사건으로
668
+ * 접지 않는다 — 승인됐는데 안 나간 배치가 트윈이 보여야 할 상태다.
669
+ */
670
+ materialLot: "material-lot.status",
671
+ // 레코드: { lotId(표준 MaterialLotID = ItemState.epc 의 값), status, decidedBy?, reason?, decidedAt? }
653
672
  /**
654
673
  * **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 그것을 알린다.
655
674
  *
@@ -2038,6 +2057,23 @@ var SPECS = {
2038
2057
  },
2039
2058
  enums: { decision: DISPOSITION_DECISION }
2040
2059
  },
2060
+ /*
2061
+ * **로트 상태** — ISA-95 `MaterialLot.Status`. 출하 승인(batch release)이 들어오는 문(§`OP_EVENT.materialLot`).
2062
+ *
2063
+ * `disposition`(subjectId + decision) 과 필드가 겹치지 않는다 — 여기는 `lotId` + `status`. 정체 이름이 표준
2064
+ * `MaterialLotID` 인 이유가 하나 더 있다: 이 문은 `epc` 를 든 레코드를 EPCIS 어휘로 보고 받지 않는다
2065
+ * (§`operationalKindOf`). 값은 로트의 식별자(`ItemState.epc` 와 같은 값)다. 상태 낱말은 열려 있어 enum 이
2066
+ * 없다(오더 상태와 같은 규율). 로트 전체의 사실이므로 부분(`subLotId`)은 받지 않는다 — 부분마다 다른 상태가
2067
+ * 필요해지면 그것은 다른 사실이다.
2068
+ */
2069
+ "material-lot": {
2070
+ eventType: OP_EVENT.materialLot,
2071
+ match: ["lotId", "status"],
2072
+ matchOrder: 85,
2073
+ identity: "lotId",
2074
+ required: ["lotId", "status"],
2075
+ fields: { lotId: "string", status: "string", decidedBy: "string", reason: "string", decidedAt: "string", recordTime: "string" }
2076
+ },
2041
2077
  test: {
2042
2078
  eventType: OP_EVENT.test,
2043
2079
  match: ["testableObjectId"],
@@ -2190,11 +2226,11 @@ function computeOee(c, nowMs) {
2190
2226
  missing.push("planned-run-time-per-item");
2191
2227
  else if (c.runMs <= 0)
2192
2228
  missing.push("actual-production-time");
2193
- const performance = c.plannedRunTimePerItemMs != null && c.runMs > 0 ? Math.min(1, c.plannedRunTimePerItemMs * produced / c.runMs) : void 0;
2194
- const overall = availability != null && performance != null && quality != null ? availability * performance * quality : void 0;
2229
+ const performance2 = c.plannedRunTimePerItemMs != null && c.runMs > 0 ? Math.min(1, c.plannedRunTimePerItemMs * produced / c.runMs) : void 0;
2230
+ const overall = availability != null && performance2 != null && quality != null ? availability * performance2 * quality : void 0;
2195
2231
  return {
2196
2232
  ...availability != null ? { availability } : {},
2197
- ...performance != null ? { performance } : {},
2233
+ ...performance2 != null ? { performance: performance2 } : {},
2198
2234
  ...quality != null ? { quality } : {},
2199
2235
  ...overall != null ? { overall } : {},
2200
2236
  ...missing.length ? { missing } : {},
@@ -2258,21 +2294,52 @@ var TwinHistory = class {
2258
2294
  return t;
2259
2295
  }
2260
2296
  };
2261
- function counterfactualAt(history, atSimMs, opts) {
2262
- const base = history.at(atSimMs);
2263
- if (!base) return void 0;
2264
- const step = opts.tickMs ?? 1e3;
2265
- const target = atSimMs + opts.horizonMs;
2297
+ var BREATH_EVERY = 25;
2298
+ var TICK_GUARD = 1e6;
2299
+ function* pairedTicks(withAlt, baseline, target, step) {
2300
+ let guard = 0;
2301
+ while (clockOf(withAlt) < target && guard++ < TICK_GUARD) {
2302
+ withAlt.tick(step);
2303
+ baseline.tick(step);
2304
+ yield guard;
2305
+ }
2306
+ }
2307
+ function branch(base, opts) {
2266
2308
  const withAlt = base.fork();
2267
2309
  opts.alternative(withAlt);
2268
2310
  const baseline = base.fork();
2269
- const run = (t) => {
2270
- let g = 0;
2271
- while (clockOf(t) < target && g++ < 1e6) t.tick(step);
2272
- };
2273
- run(withAlt);
2274
- run(baseline);
2275
- return { atSimMs, withAlt: withAlt.getSnapshot(), baseline: baseline.getSnapshot(), effect: compareStates(withAlt.getSnapshot(), baseline.getSnapshot()) };
2311
+ return { withAlt, baseline };
2312
+ }
2313
+ function settle(atSimMs, withAlt, baseline) {
2314
+ const a = withAlt.getSnapshot();
2315
+ const b = baseline.getSnapshot();
2316
+ return { atSimMs, horizonMs: Math.min(a.simClockMs, b.simClockMs) - atSimMs, withAlt: a, baseline: b, effect: compareStates(a, b) };
2317
+ }
2318
+ function counterfactualFrom(base, opts) {
2319
+ const atSimMs = clockOf(base);
2320
+ const step = opts.tickMs ?? 1e3;
2321
+ const { withAlt, baseline } = branch(base, opts);
2322
+ for (const _ of pairedTicks(withAlt, baseline, atSimMs + opts.horizonMs, step)) {
2323
+ }
2324
+ return settle(atSimMs, withAlt, baseline);
2325
+ }
2326
+ async function counterfactualFromAsync(base, opts, breath) {
2327
+ const atSimMs = clockOf(base);
2328
+ const step = opts.tickMs ?? 1e3;
2329
+ const every = breath.every ?? BREATH_EVERY;
2330
+ const now = breath.now ?? (() => performance.now());
2331
+ const started = now();
2332
+ const { withAlt, baseline } = branch(base, opts);
2333
+ for (const n of pairedTicks(withAlt, baseline, atSimMs + opts.horizonMs, step)) {
2334
+ if (breath.budgetMs !== void 0 && now() - started >= breath.budgetMs) break;
2335
+ if (n % every === 0) await breath.breathe();
2336
+ }
2337
+ return settle(atSimMs, withAlt, baseline);
2338
+ }
2339
+ function counterfactualAt(history, atSimMs, opts) {
2340
+ const base = history.at(atSimMs);
2341
+ if (!base) return void 0;
2342
+ return counterfactualFrom(base, opts);
2276
2343
  }
2277
2344
 
2278
2345
  // src/forecast.ts
@@ -2849,6 +2916,18 @@ var ObservedReducer = class {
2849
2916
  for (const part of parts) part.nonconformance = { ...d };
2850
2917
  break;
2851
2918
  }
2919
+ case OP_EVENT.materialLot: {
2920
+ const d = e.data;
2921
+ if (!d?.lotId || !d?.status) break;
2922
+ if (this.stale(`material-lot:${d.lotId}`, e)) return;
2923
+ const parts = [...this.items.values()].filter((i) => i.epc === d.lotId);
2924
+ if (!parts.length) {
2925
+ this.noteUnhandled(e, `${OP_EVENT.materialLot}:unknown-lot`);
2926
+ break;
2927
+ }
2928
+ for (const part of parts) part.status = d.status;
2929
+ break;
2930
+ }
2852
2931
  case OP_EVENT.observation: {
2853
2932
  const d = e.data;
2854
2933
  if (!d?.locationId || !d?.propertyId) break;
@@ -4285,7 +4364,14 @@ var FlowEngine = class {
4285
4364
  parent: it.parent,
4286
4365
  carriedBy: it.carriedBy,
4287
4366
  expiry: it.expiry,
4288
- ilmd: it.ilmd
4367
+ ilmd: it.ilmd,
4368
+ /*
4369
+ * 로트의 두 표준 칸 — ISA-95 `Disposition`(`nonconformance`)과 `Status`(`status`). 이 복사가 칸을 하나씩
4370
+ * 옮기는 자리라, 여기 없는 칸은 리듀서에 앉았어도 **스냅샷에 나오지 않는다**(2026-09-16 에 `status` 를
4371
+ * 넣다가 `nonconformance` 도 여기서 빠져 있던 것을 찾았다 — 자리는 있고 길이 없는 부류). 있을 때만 옮긴다.
4372
+ */
4373
+ ...it.nonconformance ? { nonconformance: it.nonconformance } : {},
4374
+ ...it.status ? { status: it.status } : {}
4289
4375
  });
4290
4376
  }
4291
4377
  for (const m of snap.equipment) {
@@ -4616,13 +4702,14 @@ var FlowEngine = class {
4616
4702
  }
4617
4703
  dispatchInner(cmd) {
4618
4704
  const ok = () => ({ commandId: cmd.commandId, accepted: true });
4619
- const fail = (errorCode, errorParams) => ({ commandId: cmd.commandId, accepted: false, errorCode, errorParams, error: errorCode });
4705
+ const fail = (errorCode, error, errorParams) => ({ commandId: cmd.commandId, accepted: false, errorCode, errorParams, error });
4706
+ const noEquipment = (resourceId) => fail("resource-not-found", `no equipment with id "${resourceId}" in this twin`, { resourceId });
4620
4707
  switch (cmd.type) {
4621
4708
  case CMD.orderHold:
4622
4709
  case CMD.orderResume: {
4623
4710
  const orderId = cmd.args?.orderId;
4624
4711
  const order = orderId ? this.orders.get(orderId) : void 0;
4625
- if (!order) return fail("order-not-found", { orderId: orderId ?? "" });
4712
+ if (!order) return fail("order-not-found", `no order with id "${orderId ?? ""}" in this twin`, { orderId: orderId ?? "" });
4626
4713
  order.held = cmd.type === CMD.orderHold;
4627
4714
  this.emitOrder(order);
4628
4715
  return ok();
@@ -4638,8 +4725,9 @@ var FlowEngine = class {
4638
4725
  // Operable 코어 — 자원(설비·설비) 제어. capability-keyed(resourceId), 모든 operable 자원 공통.
4639
4726
  case CMD.resourceHold:
4640
4727
  case CMD.resourceResume: {
4641
- const m = this.equipment.get(cmd.args?.resourceId ?? "");
4642
- if (!m) return fail("resource-not-found");
4728
+ const resourceId = cmd.args?.resourceId ?? "";
4729
+ const m = this.equipment.get(resourceId);
4730
+ if (!m) return noEquipment(resourceId);
4643
4731
  m.held = cmd.type === CMD.resourceHold;
4644
4732
  this.emitEquipment(m);
4645
4733
  return ok();
@@ -4647,7 +4735,7 @@ var FlowEngine = class {
4647
4735
  case CMD.resourceDown: {
4648
4736
  const a = cmd.args;
4649
4737
  const m = this.equipment.get(a?.resourceId ?? "");
4650
- if (!m) return fail("resource-not-found");
4738
+ if (!m) return noEquipment(a?.resourceId ?? "");
4651
4739
  if (m.status !== "down") {
4652
4740
  m.status = "down";
4653
4741
  m.repairUntilMs = this.clockMs + (Number(a?.durationMs) || m.mttrMs || 6e4);
@@ -4656,8 +4744,9 @@ var FlowEngine = class {
4656
4744
  return ok();
4657
4745
  }
4658
4746
  case CMD.resourceRepair: {
4659
- const m = this.equipment.get(cmd.args?.resourceId ?? "");
4660
- if (!m) return fail("resource-not-found");
4747
+ const resourceId = cmd.args?.resourceId ?? "";
4748
+ const m = this.equipment.get(resourceId);
4749
+ if (!m) return noEquipment(resourceId);
4661
4750
  if (m.status === "down") {
4662
4751
  m.status = m.taskId ? "busy" : "idle";
4663
4752
  m.repairUntilMs = void 0;
@@ -4667,8 +4756,9 @@ var FlowEngine = class {
4667
4756
  return ok();
4668
4757
  }
4669
4758
  case CMD.resourceResetMetrics: {
4670
- const m = this.equipment.get(cmd.args?.resourceId ?? "");
4671
- if (!m) return fail("resource-not-found");
4759
+ const resourceId = cmd.args?.resourceId ?? "";
4760
+ const m = this.equipment.get(resourceId);
4761
+ if (!m) return noEquipment(resourceId);
4672
4762
  m.runMs = 0;
4673
4763
  m.setupMs = 0;
4674
4764
  m.downMs = 0;
@@ -4681,8 +4771,8 @@ var FlowEngine = class {
4681
4771
  }
4682
4772
  case CMD.resourceAdd: {
4683
4773
  const a = cmd.args;
4684
- if (!a?.kind) return fail("kind-required");
4685
- if (!a?.homeLocation || !this.locations.has(a.homeLocation)) return fail("home-location-not-found", { homeLocation: a?.homeLocation ?? "" });
4774
+ if (!a?.kind) return fail("kind-required", "resource.add needs args.kind");
4775
+ if (!a?.homeLocation || !this.locations.has(a.homeLocation)) return fail("home-location-not-found", `no location "${a?.homeLocation ?? ""}" to use as home`, { homeLocation: a?.homeLocation ?? "" });
4686
4776
  const count = Math.max(1, Math.min(50, Number(a.count) || 1));
4687
4777
  let seq = this.equipment.size;
4688
4778
  for (let i = 0; i < count; i++) {
@@ -4700,7 +4790,7 @@ var FlowEngine = class {
4700
4790
  }
4701
4791
  /** 도메인 커맨드 처리(order.release 등). 기본은 거절 — 도메인이 override. */
4702
4792
  handleCommand(cmd) {
4703
- return { commandId: cmd.commandId, accepted: false, errorCode: "unknown-command", errorParams: { type: cmd.type }, error: `unknown-command: ${cmd.type}` };
4793
+ return { commandId: cmd.commandId, accepted: false, errorCode: "unknown-command", errorParams: { type: cmd.type }, error: `no command of type "${cmd.type}"` };
4704
4794
  }
4705
4795
  scenario = {
4706
4796
  load: (def) => {
@@ -6688,7 +6778,10 @@ var FlowEngine = class {
6688
6778
  ...i.uom ? { uom: i.uom } : {},
6689
6779
  ...i.quantities?.length ? { quantities: i.quantities } : {},
6690
6780
  ...i.expiry !== void 0 ? { expiry: i.expiry } : {},
6691
- ...i.ilmd ? { ilmd: i.ilmd } : {}
6781
+ ...i.ilmd ? { ilmd: i.ilmd } : {},
6782
+ /* 로트의 두 표준 칸(§FlowItem) — 여기서 빠지면 리듀서·엔진에는 있고 스냅샷에는 없다. */
6783
+ ...i.nonconformance ? { nonconformance: i.nonconformance } : {},
6784
+ ...i.status ? { status: i.status } : {}
6692
6785
  };
6693
6786
  }
6694
6787
  /**
@@ -9858,6 +9951,8 @@ function electricityCost(input) {
9858
9951
  compareStates,
9859
9952
  constantDuration,
9860
9953
  counterfactualAt,
9954
+ counterfactualFrom,
9955
+ counterfactualFromAsync,
9861
9956
  demandWindowStart,
9862
9957
  deriveAttentions,
9863
9958
  electricityCost,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.11.11",
3
+ "version": "0.11.14",
4
4
  "type": "module",
5
5
  "description": "Twin Domain Kernel — framework-agnostic, zero-dep (domain + sim + 3-channel contract). WMS/YMS/MES, EPCIS 2.0 · ISA-95.",
6
6
  "publishConfig": {
@@ -28,6 +28,6 @@
28
28
  "test": "node --test test/*.test.ts"
29
29
  },
30
30
  "dependencies": {
31
- "@operato/ops-contract": "^0.9.19"
31
+ "@operato/ops-contract": "^0.9.22"
32
32
  }
33
33
  }