@operato/twin-kernel 0.11.11 → 0.11.12

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
  }
@@ -1467,15 +1467,23 @@ export class FlowEngine {
1467
1467
  }
1468
1468
  dispatchInner(cmd) {
1469
1469
  const ok = () => ({ commandId: cmd.commandId, accepted: true });
1470
- // 거절 사유 = 언어 중립 코드 + 원시 파라미터. error 는 영어 폴백(로그·개발자용).
1471
- const fail = (errorCode, errorParams) => ({ commandId: cmd.commandId, accepted: false, errorCode, errorParams, error: errorCode });
1470
+ /*
1471
+ * 거절 사유 = 언어 중립 코드 + 원시 파라미터 + 영어 문장.
1472
+ *
1473
+ * `error` 는 **문장**이어야 한다 — 코드 문자열을 다시 넣지 않는다. 화면은 모르는 코드를 만나면 `error` 를
1474
+ * 그대로 내는데(ADR-0042 ③, 매핑 표 없음), 그 자리에 코드가 들어 있으면 사람이 `kind-required` 같은 날
1475
+ * 코드를 본다. 그리고 「무엇이 없었나」를 말하는 코드(`*-not-found`)는 그 무엇을 params 에 싣는다 — 다음에
1476
+ * 할 일(다시 고르기)은 없었던 것의 이름을 알아야 할 수 있다.
1477
+ */
1478
+ const fail = (errorCode, error, errorParams) => ({ commandId: cmd.commandId, accepted: false, errorCode, errorParams, error });
1479
+ const noEquipment = (resourceId) => fail('resource-not-found', `no equipment with id "${resourceId}" in this twin`, { resourceId });
1472
1480
  switch (cmd.type) {
1473
1481
  case CMD.orderHold:
1474
1482
  case CMD.orderResume: {
1475
1483
  const orderId = cmd.args?.orderId;
1476
1484
  const order = orderId ? this.orders.get(orderId) : undefined;
1477
1485
  if (!order)
1478
- return fail('order-not-found', { orderId: orderId ?? '' });
1486
+ return fail('order-not-found', `no order with id "${orderId ?? ''}" in this twin`, { orderId: orderId ?? '' });
1479
1487
  order.held = cmd.type === CMD.orderHold;
1480
1488
  this.emitOrder(order);
1481
1489
  return ok();
@@ -1496,9 +1504,10 @@ export class FlowEngine {
1496
1504
  // Operable 코어 — 자원(설비·설비) 제어. capability-keyed(resourceId), 모든 operable 자원 공통.
1497
1505
  case CMD.resourceHold:
1498
1506
  case CMD.resourceResume: {
1499
- const m = this.equipment.get(cmd.args?.resourceId ?? '');
1507
+ const resourceId = cmd.args?.resourceId ?? '';
1508
+ const m = this.equipment.get(resourceId);
1500
1509
  if (!m)
1501
- return fail('resource-not-found');
1510
+ return noEquipment(resourceId);
1502
1511
  m.held = cmd.type === CMD.resourceHold; // 계획 정지 → 배정 스킵
1503
1512
  this.emitEquipment(m);
1504
1513
  return ok();
@@ -1507,7 +1516,7 @@ export class FlowEngine {
1507
1516
  const a = cmd.args;
1508
1517
  const m = this.equipment.get(a?.resourceId ?? '');
1509
1518
  if (!m)
1510
- return fail('resource-not-found');
1519
+ return noEquipment(a?.resourceId ?? '');
1511
1520
  if (m.status !== 'down') {
1512
1521
  m.status = 'down'; // 비계획 고장 주입 — 기존 고장 machinery(processFailures)가 downMs 누적·수리 처리
1513
1522
  m.repairUntilMs = this.clockMs + (Number(a?.durationMs) || m.mttrMs || 60_000);
@@ -1516,9 +1525,10 @@ export class FlowEngine {
1516
1525
  return ok();
1517
1526
  }
1518
1527
  case CMD.resourceRepair: {
1519
- const m = this.equipment.get(cmd.args?.resourceId ?? '');
1528
+ const resourceId = cmd.args?.resourceId ?? '';
1529
+ const m = this.equipment.get(resourceId);
1520
1530
  if (!m)
1521
- return fail('resource-not-found');
1531
+ return noEquipment(resourceId);
1522
1532
  if (m.status === 'down') {
1523
1533
  m.status = m.taskId ? 'busy' : 'idle'; // 진행중 task 있으면 재개(고장모델 수리 로직과 동형)
1524
1534
  m.repairUntilMs = undefined;
@@ -1529,9 +1539,10 @@ export class FlowEngine {
1529
1539
  return ok();
1530
1540
  }
1531
1541
  case CMD.resourceResetMetrics: {
1532
- const m = this.equipment.get(cmd.args?.resourceId ?? '');
1542
+ const resourceId = cmd.args?.resourceId ?? '';
1543
+ const m = this.equipment.get(resourceId);
1533
1544
  if (!m)
1534
- return fail('resource-not-found');
1545
+ return noEquipment(resourceId);
1535
1546
  m.runMs = 0;
1536
1547
  m.setupMs = 0;
1537
1548
  m.downMs = 0;
@@ -1547,9 +1558,9 @@ export class FlowEngine {
1547
1558
  // 새 설비는 즉시 배정 대상(다음 tick). 유일 id 생성(충돌 회피). 좌표/persistence 는 호스트 몫(커널=위상만).
1548
1559
  const a = cmd.args;
1549
1560
  if (!a?.kind)
1550
- return fail('kind-required');
1561
+ return fail('kind-required', 'resource.add needs args.kind');
1551
1562
  if (!a?.homeLocation || !this.locations.has(a.homeLocation))
1552
- return fail('home-location-not-found', { homeLocation: a?.homeLocation ?? '' });
1563
+ return fail('home-location-not-found', `no location "${a?.homeLocation ?? ''}" to use as home`, { homeLocation: a?.homeLocation ?? '' });
1553
1564
  const count = Math.max(1, Math.min(50, Number(a.count) || 1));
1554
1565
  let seq = this.equipment.size;
1555
1566
  for (let i = 0; i < count; i++) {
@@ -1569,7 +1580,7 @@ export class FlowEngine {
1569
1580
  }
1570
1581
  /** 도메인 커맨드 처리(order.release 등). 기본은 거절 — 도메인이 override. */
1571
1582
  handleCommand(cmd) {
1572
- return { commandId: cmd.commandId, accepted: false, errorCode: 'unknown-command', errorParams: { type: cmd.type }, error: `unknown-command: ${cmd.type}` };
1583
+ return { commandId: cmd.commandId, accepted: false, errorCode: 'unknown-command', errorParams: { type: cmd.type }, error: `no command of type "${cmd.type}"` };
1573
1584
  }
1574
1585
  scenario = {
1575
1586
  load: (def) => {
@@ -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,26 @@ 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?.epc || !d?.status)
577
+ break;
578
+ if (this.stale(`material-lot:${d.epc}`, e))
579
+ return;
580
+ const parts = [...this.items.values()].filter(i => i.epc === d.epc);
581
+ if (!parts.length) {
582
+ this.noteUnhandled(e, `${OP_EVENT.materialLot}:unknown-lot`);
583
+ break;
584
+ }
585
+ for (const part of parts)
586
+ part.status = d.status;
587
+ break;
588
+ }
569
589
  case OP_EVENT.observation: {
570
590
  /*
571
591
  * **자리의 물리 관측** — 속성마다 마지막 값 하나만 든다(§`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,22 @@ 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",
653
671
  /**
654
672
  * **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 그것을 알린다.
655
673
  *
@@ -1025,8 +1043,14 @@ var MES_BIZSTEP = {
1025
1043
  // 원자재 수령
1026
1044
  producing: "urn:epcglobal:cbv:bizstep:commissioning",
1027
1045
  // 생산(제품 최초 생성)
1028
- storing: "urn:epcglobal:cbv:bizstep:storing"
1046
+ storing: "urn:epcglobal:cbv:bizstep:storing",
1029
1047
  // 완제품 저장
1048
+ /**
1049
+ * **출하** — CBV `shipping`: 물품이 시설을 떠난다. 자재를 트윈 밖으로 내는 것은 이 사건이다(disposition
1050
+ * `in_transit`, readPoint = 출하 dock). 출하 **승인**은 이 사건이 아니라 로트 상태(`OP_EVENT.materialLot`)다 —
1051
+ * 승인됐는데 안 나간 배치가 보여야 한다(2026-09-16, ADR-0046 곁).
1052
+ */
1053
+ shipping: "urn:epcglobal:cbv:bizstep:shipping"
1030
1054
  };
1031
1055
  var BTT_PRODORDER = "urn:epcglobal:cbv:btt:prodorder";
1032
1056
  function sgtinUri(companyPrefix, itemRef, serial) {
@@ -2038,6 +2062,21 @@ var SPECS = {
2038
2062
  },
2039
2063
  enums: { decision: DISPOSITION_DECISION }
2040
2064
  },
2065
+ /*
2066
+ * **로트 상태** — ISA-95 `MaterialLot.Status`. 출하 승인(batch release)이 들어오는 문(§`OP_EVENT.materialLot`).
2067
+ *
2068
+ * `disposition`(subjectId + decision) 과 필드가 겹치지 않는다 — 여기는 `epc` + `status`. 상태 낱말은 열려
2069
+ * 있어 enum 이 없다(오더 상태와 같은 규율). 로트 전체의 사실이므로 `subLotId` 는 받지 않는다 — 부분마다 다른
2070
+ * 상태가 필요해지면 그것은 다른 사실이다.
2071
+ */
2072
+ "material-lot": {
2073
+ eventType: OP_EVENT.materialLot,
2074
+ match: ["epc", "status"],
2075
+ matchOrder: 85,
2076
+ identity: "epc",
2077
+ required: ["epc", "status"],
2078
+ fields: { epc: "string", status: "string", decidedBy: "string", reason: "string", decidedAt: "string", recordTime: "string" }
2079
+ },
2041
2080
  test: {
2042
2081
  eventType: OP_EVENT.test,
2043
2082
  match: ["testableObjectId"],
@@ -2190,11 +2229,11 @@ function computeOee(c, nowMs) {
2190
2229
  missing.push("planned-run-time-per-item");
2191
2230
  else if (c.runMs <= 0)
2192
2231
  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;
2232
+ const performance2 = c.plannedRunTimePerItemMs != null && c.runMs > 0 ? Math.min(1, c.plannedRunTimePerItemMs * produced / c.runMs) : void 0;
2233
+ const overall = availability != null && performance2 != null && quality != null ? availability * performance2 * quality : void 0;
2195
2234
  return {
2196
2235
  ...availability != null ? { availability } : {},
2197
- ...performance != null ? { performance } : {},
2236
+ ...performance2 != null ? { performance: performance2 } : {},
2198
2237
  ...quality != null ? { quality } : {},
2199
2238
  ...overall != null ? { overall } : {},
2200
2239
  ...missing.length ? { missing } : {},
@@ -2258,21 +2297,52 @@ var TwinHistory = class {
2258
2297
  return t;
2259
2298
  }
2260
2299
  };
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;
2300
+ var BREATH_EVERY = 25;
2301
+ var TICK_GUARD = 1e6;
2302
+ function* pairedTicks(withAlt, baseline, target, step) {
2303
+ let guard = 0;
2304
+ while (clockOf(withAlt) < target && guard++ < TICK_GUARD) {
2305
+ withAlt.tick(step);
2306
+ baseline.tick(step);
2307
+ yield guard;
2308
+ }
2309
+ }
2310
+ function branch(base, opts) {
2266
2311
  const withAlt = base.fork();
2267
2312
  opts.alternative(withAlt);
2268
2313
  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()) };
2314
+ return { withAlt, baseline };
2315
+ }
2316
+ function settle(atSimMs, withAlt, baseline) {
2317
+ const a = withAlt.getSnapshot();
2318
+ const b = baseline.getSnapshot();
2319
+ return { atSimMs, horizonMs: Math.min(a.simClockMs, b.simClockMs) - atSimMs, withAlt: a, baseline: b, effect: compareStates(a, b) };
2320
+ }
2321
+ function counterfactualFrom(base, opts) {
2322
+ const atSimMs = clockOf(base);
2323
+ const step = opts.tickMs ?? 1e3;
2324
+ const { withAlt, baseline } = branch(base, opts);
2325
+ for (const _ of pairedTicks(withAlt, baseline, atSimMs + opts.horizonMs, step)) {
2326
+ }
2327
+ return settle(atSimMs, withAlt, baseline);
2328
+ }
2329
+ async function counterfactualFromAsync(base, opts, breath) {
2330
+ const atSimMs = clockOf(base);
2331
+ const step = opts.tickMs ?? 1e3;
2332
+ const every = breath.every ?? BREATH_EVERY;
2333
+ const now = breath.now ?? (() => performance.now());
2334
+ const started = now();
2335
+ const { withAlt, baseline } = branch(base, opts);
2336
+ for (const n of pairedTicks(withAlt, baseline, atSimMs + opts.horizonMs, step)) {
2337
+ if (breath.budgetMs !== void 0 && now() - started >= breath.budgetMs) break;
2338
+ if (n % every === 0) await breath.breathe();
2339
+ }
2340
+ return settle(atSimMs, withAlt, baseline);
2341
+ }
2342
+ function counterfactualAt(history, atSimMs, opts) {
2343
+ const base = history.at(atSimMs);
2344
+ if (!base) return void 0;
2345
+ return counterfactualFrom(base, opts);
2276
2346
  }
2277
2347
 
2278
2348
  // src/forecast.ts
@@ -2849,6 +2919,18 @@ var ObservedReducer = class {
2849
2919
  for (const part of parts) part.nonconformance = { ...d };
2850
2920
  break;
2851
2921
  }
2922
+ case OP_EVENT.materialLot: {
2923
+ const d = e.data;
2924
+ if (!d?.epc || !d?.status) break;
2925
+ if (this.stale(`material-lot:${d.epc}`, e)) return;
2926
+ const parts = [...this.items.values()].filter((i) => i.epc === d.epc);
2927
+ if (!parts.length) {
2928
+ this.noteUnhandled(e, `${OP_EVENT.materialLot}:unknown-lot`);
2929
+ break;
2930
+ }
2931
+ for (const part of parts) part.status = d.status;
2932
+ break;
2933
+ }
2852
2934
  case OP_EVENT.observation: {
2853
2935
  const d = e.data;
2854
2936
  if (!d?.locationId || !d?.propertyId) break;
@@ -4616,13 +4698,14 @@ var FlowEngine = class {
4616
4698
  }
4617
4699
  dispatchInner(cmd) {
4618
4700
  const ok = () => ({ commandId: cmd.commandId, accepted: true });
4619
- const fail = (errorCode, errorParams) => ({ commandId: cmd.commandId, accepted: false, errorCode, errorParams, error: errorCode });
4701
+ const fail = (errorCode, error, errorParams) => ({ commandId: cmd.commandId, accepted: false, errorCode, errorParams, error });
4702
+ const noEquipment = (resourceId) => fail("resource-not-found", `no equipment with id "${resourceId}" in this twin`, { resourceId });
4620
4703
  switch (cmd.type) {
4621
4704
  case CMD.orderHold:
4622
4705
  case CMD.orderResume: {
4623
4706
  const orderId = cmd.args?.orderId;
4624
4707
  const order = orderId ? this.orders.get(orderId) : void 0;
4625
- if (!order) return fail("order-not-found", { orderId: orderId ?? "" });
4708
+ if (!order) return fail("order-not-found", `no order with id "${orderId ?? ""}" in this twin`, { orderId: orderId ?? "" });
4626
4709
  order.held = cmd.type === CMD.orderHold;
4627
4710
  this.emitOrder(order);
4628
4711
  return ok();
@@ -4638,8 +4721,9 @@ var FlowEngine = class {
4638
4721
  // Operable 코어 — 자원(설비·설비) 제어. capability-keyed(resourceId), 모든 operable 자원 공통.
4639
4722
  case CMD.resourceHold:
4640
4723
  case CMD.resourceResume: {
4641
- const m = this.equipment.get(cmd.args?.resourceId ?? "");
4642
- if (!m) return fail("resource-not-found");
4724
+ const resourceId = cmd.args?.resourceId ?? "";
4725
+ const m = this.equipment.get(resourceId);
4726
+ if (!m) return noEquipment(resourceId);
4643
4727
  m.held = cmd.type === CMD.resourceHold;
4644
4728
  this.emitEquipment(m);
4645
4729
  return ok();
@@ -4647,7 +4731,7 @@ var FlowEngine = class {
4647
4731
  case CMD.resourceDown: {
4648
4732
  const a = cmd.args;
4649
4733
  const m = this.equipment.get(a?.resourceId ?? "");
4650
- if (!m) return fail("resource-not-found");
4734
+ if (!m) return noEquipment(a?.resourceId ?? "");
4651
4735
  if (m.status !== "down") {
4652
4736
  m.status = "down";
4653
4737
  m.repairUntilMs = this.clockMs + (Number(a?.durationMs) || m.mttrMs || 6e4);
@@ -4656,8 +4740,9 @@ var FlowEngine = class {
4656
4740
  return ok();
4657
4741
  }
4658
4742
  case CMD.resourceRepair: {
4659
- const m = this.equipment.get(cmd.args?.resourceId ?? "");
4660
- if (!m) return fail("resource-not-found");
4743
+ const resourceId = cmd.args?.resourceId ?? "";
4744
+ const m = this.equipment.get(resourceId);
4745
+ if (!m) return noEquipment(resourceId);
4661
4746
  if (m.status === "down") {
4662
4747
  m.status = m.taskId ? "busy" : "idle";
4663
4748
  m.repairUntilMs = void 0;
@@ -4667,8 +4752,9 @@ var FlowEngine = class {
4667
4752
  return ok();
4668
4753
  }
4669
4754
  case CMD.resourceResetMetrics: {
4670
- const m = this.equipment.get(cmd.args?.resourceId ?? "");
4671
- if (!m) return fail("resource-not-found");
4755
+ const resourceId = cmd.args?.resourceId ?? "";
4756
+ const m = this.equipment.get(resourceId);
4757
+ if (!m) return noEquipment(resourceId);
4672
4758
  m.runMs = 0;
4673
4759
  m.setupMs = 0;
4674
4760
  m.downMs = 0;
@@ -4681,8 +4767,8 @@ var FlowEngine = class {
4681
4767
  }
4682
4768
  case CMD.resourceAdd: {
4683
4769
  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 ?? "" });
4770
+ if (!a?.kind) return fail("kind-required", "resource.add needs args.kind");
4771
+ 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
4772
  const count = Math.max(1, Math.min(50, Number(a.count) || 1));
4687
4773
  let seq = this.equipment.size;
4688
4774
  for (let i = 0; i < count; i++) {
@@ -4700,7 +4786,7 @@ var FlowEngine = class {
4700
4786
  }
4701
4787
  /** 도메인 커맨드 처리(order.release 등). 기본은 거절 — 도메인이 override. */
4702
4788
  handleCommand(cmd) {
4703
- return { commandId: cmd.commandId, accepted: false, errorCode: "unknown-command", errorParams: { type: cmd.type }, error: `unknown-command: ${cmd.type}` };
4789
+ return { commandId: cmd.commandId, accepted: false, errorCode: "unknown-command", errorParams: { type: cmd.type }, error: `no command of type "${cmd.type}"` };
4704
4790
  }
4705
4791
  scenario = {
4706
4792
  load: (def) => {
@@ -9858,6 +9944,8 @@ function electricityCost(input) {
9858
9944
  compareStates,
9859
9945
  constantDuration,
9860
9946
  counterfactualAt,
9947
+ counterfactualFrom,
9948
+ counterfactualFromAsync,
9861
9949
  demandWindowStart,
9862
9950
  deriveAttentions,
9863
9951
  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.12",
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.20"
32
32
  }
33
33
  }