@operato/twin-kernel 0.7.54 → 0.7.56

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.
@@ -9,7 +9,7 @@
9
9
  * 통합 타입은 도메인 필드를 옵셔널로 넓혀(FlowItem.gtin?, FlowOrder.shipmentEpc? 등) 두 도메인을 담는다.
10
10
  * (roadmap Phase5 발견 → 추출. [[project_flow_single_base_vision]] FlowLocation 단일 base 방향과 정합.)
11
11
  */
12
- import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure, capabilityOf, requiredTestsFor, priorityRank, dueStatusOf, isOrderTerminal, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf, identityGroundingOf } from "./contract.js";
12
+ import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure, capabilityOf, requiredTestsFor, priorityRank, dueStatusOf, isOrderTerminal, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf, identityGroundingOf, outsideLimit } from "./contract.js";
13
13
  import { ObservedReducer } from "./observed-reducer.js";
14
14
  import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR, CBV_BIZSTEP, objectUri, bizTransactionUri, gdtiUri } from "./epcis.js";
15
15
  import { OP_PARAM } from "./domain-definition.js";
@@ -108,6 +108,56 @@ thresholds) {
108
108
  }
109
109
  }
110
110
  for (const n of view.locations) {
111
+ /*
112
+ * **관측이 한계를 벗어났다** — 판정할 수 있을 때만 낸다.
113
+ *
114
+ * ── 왜 이 자리인가 ────────────────────────────────────────────────────────
115
+ * 기준과 측정값을 계약에 넣어 두고 **아무도 부르지 않았다**(`outsideLimit`). 부르지 않는 판정
116
+ * 함수는 시험만 통과하는 코드이고, 「선언만 있는 능력은 사용자에게 거짓말이 된다」와 같은 자리다.
117
+ *
118
+ * ── 커널이 하지 않는 것 ───────────────────────────────────────────────────
119
+ * 표현식을 읽지 않는다. 숫자 한계가 없으면 `outsideLimit` 이 `undefined` 를 주고, 그때는 **신호를
120
+ * 내지 않는다** — 「판정 못 했다」를 「벗어났다」로도 「괜찮다」로도 만들지 않는다.
121
+ *
122
+ * 그 「판정 못 했다」가 조용히 사라지는 것은 다른 축이 답한다(`criterionSaysNothing` ·
123
+ * `testEvidenceGaps`) — 그것은 설정의 흠이고 이것은 운영의 사실이라, 한 신호에 섞지 않는다.
124
+ *
125
+ * ── 심각도를 벗어난 폭으로 가르지 않는다 ──────────────────────────────────
126
+ * 에너지 초과는 5%·15% 로 갈랐다(계량 오차와 구별하기 어려우므로). 여기서는 그러지 않는다:
127
+ * 식품 안전 한계는 **넘으면 넘은 것**이고, 「조금 넘었다」를 낮게 부르면 그 판단을 커널이 대신
128
+ * 하는 것이 된다. 폭은 `params` 로 함께 내고 판단은 현장이 한다.
129
+ */
130
+ for (const c of n.criteria ?? []) {
131
+ const propertyId = c.evaluatedPropertyId;
132
+ if (!propertyId)
133
+ continue;
134
+ const observed = (n.observations ?? []).find(o => o.propertyId === propertyId);
135
+ if (!observed)
136
+ continue;
137
+ if (outsideLimit(c, observed) !== true)
138
+ continue;
139
+ out.push({
140
+ /* 자리·기준마다 하나 — 같은 방의 온도와 습도가 한 신호로 뭉치지 않는다. */
141
+ id: `observation-out-of-limit:${n.id}:${c.id}`,
142
+ kind: 'observation-out-of-limit',
143
+ severity: 'high',
144
+ anchor: { locationId: n.id },
145
+ /* 언어중립 원시값만 — 문장은 표현계층이 kind 로 골라 렌더한다. */
146
+ params: {
147
+ locationId: n.id,
148
+ criterionId: c.id,
149
+ propertyId,
150
+ /* 값이 없으면 판정이 서지 않으므로 여기 오지 않는다 — 그래도 타입을 좁혀 둔다. */
151
+ value: observed.value ?? '',
152
+ ...(observed.uom ? { uom: observed.uom } : {}),
153
+ ...(c.limit?.minimum !== undefined ? { minimum: c.limit.minimum } : {}),
154
+ ...(c.limit?.maximum !== undefined ? { maximum: c.limit.maximum } : {}),
155
+ /* **언제의 값인가** — 4개월 전 값으로 지금을 판정한 것인지 소비처가 알아야 한다. */
156
+ effectiveTime: observed.effectiveTime,
157
+ ...(observed.derived ? { derived: 'true' } : {})
158
+ }
159
+ });
160
+ }
111
161
  if ((n.capacity ?? 0) > 0) {
112
162
  const r = (n.occupancy ?? 0) / n.capacity;
113
163
  /* 기준은 현장이 정한다 — 없으면 기본값이고, 어느 쪽인지 함께 낸다(조용히 기본값을 진실로 두지 않는다). */
@@ -573,6 +623,47 @@ export class FlowEngine {
573
623
  localParams = new Map();
574
624
  /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
575
625
  observer;
626
+ /**
627
+ * **관측 리듀서의 재개점을 꺼낸다** — 호스트가 저장해 다음 기동에서 되돌릴 수 있게.
628
+ *
629
+ * ── 무엇이 문제였나 (2026-08-24 실측) ──────────────────────────────────────
630
+ * 미러는 재기동마다 저널을 **0부터** 다시 집계했다. 실측으로 저널이 2,960만 줄이고, 그 때문에 기동
631
+ * 직후 몇 분간 상태가 비어 있었다. 계측처럼 경계 없이 자라는 흐름이 들어오면 그 몇 분이 몇십 분이
632
+ * 된다 — 불편이 아니라 벽이다.
633
+ *
634
+ * 재개점 자체는 오래전부터 있었다(`serialize`/`restore`). 조회 경로는 그것을 쓰는데
635
+ * (`replayFrom`) **라이브 경로에는 꺼낼 문이 없었다.** 그래서 호스트가 저장할 수 없었다.
636
+ *
637
+ * ── 상태 스냅샷으로는 대신할 수 없다 ──────────────────────────────────────
638
+ * 리듀서는 소비처가 보는 값 말고도 든다: 부모를 기다리는 담김·집계 중인 수량·담을 줄 몰라 세어 둔
639
+ * 사건. 상태만 되돌리고 이어 집계하면 **0부터 집계한 결과와 조용히 달라진다**(§`ReducerCheckpoint`).
640
+ *
641
+ * 관측 구동이 아니면 `undefined` — 시뮬은 리듀서를 갖지 않는다(저장할 것이 없다).
642
+ */
643
+ observedCheckpoint() {
644
+ return this.observer?.serialize();
645
+ }
646
+ /**
647
+ * **재개점에서 관측 리듀서를 되세운다** — 저널을 0부터 다시 집계하지 않게.
648
+ *
649
+ * 리듀서가 아직 없으면 만든다: 미러는 첫 봉투가 올 때 리듀서를 만드는데(§`apply`), 되돌리기는 그보다
650
+ * 먼저 일어나야 한다(그러지 않으면 첫 봉투가 빈 리듀서를 만들고 되돌린 것을 덮는다).
651
+ *
652
+ * 되돌린 뒤 상태로 옮긴다 — 그러지 않으면 첫 스냅샷이 빈 상태를 보인다.
653
+ *
654
+ * **구조가 다르면 되돌리지 않는다**: 재개점은 그 모델 위에서 만들어진 것이고, 다른 공장의 재개점을
655
+ * 얹으면 없는 자리·설비가 생긴다. 판단은 부르는 쪽이 한다(`structureRev` 를 아는 것은 호스트다) —
656
+ * 여기서는 받은 것을 그대로 세운다.
657
+ */
658
+ restoreObserved(cp) {
659
+ if (!this.observer) {
660
+ this.observer = new ObservedReducer(this.boardDef ?? { locations: [], equipment: [] });
661
+ this.observeMode = true;
662
+ }
663
+ this.observer.restore(cp);
664
+ this.observedDirty = true;
665
+ this.settleObserved();
666
+ }
576
667
  /**
577
668
  * 이 커널의 상태가 **관측에서 왔나** — 미러인가.
578
669
  *
@@ -1663,9 +1754,31 @@ export class FlowEngine {
1663
1754
  collectAttentions() {
1664
1755
  // 계산 층은 순수 함수 deriveAttentions 로 위임 — sim(여기)과 live projector 미러가 공유(face2-inbound-live §1.1).
1665
1756
  const now = this.now();
1757
+ /*
1758
+ * **자리에 걸린 기준을 여기서 푼다** — 순수 함수는 모델을 읽지 않는다(그 규율은 `attentionThresholds`
1759
+ * 와 같다). 종류 선언(`LocationTypeDef.testSpecificationIds`) → 명세 → 기준으로 풀어 넘긴다.
1760
+ *
1761
+ * 선언이 없으면 빈 배열이고, 그러면 그 자리의 관측은 판정되지 않는다 — 관측은 남고 신호만 서지
1762
+ * 않는다(「선언한 것만 제약이 된다」).
1763
+ */
1764
+ const specById = new Map((this.boardDef?.testSpecifications ?? []).map(sp => [sp.id, sp]));
1765
+ const criteriaOf = new Map();
1766
+ if (specById.size) {
1767
+ for (const n of readBoardLocations(this.boardDef ?? {})) {
1768
+ const ids = n.testSpecificationIds;
1769
+ if (!ids?.length)
1770
+ continue;
1771
+ const cs = ids.flatMap(id => specById.get(id)?.criteria ?? []);
1772
+ if (cs.length)
1773
+ criteriaOf.set(n.id, cs);
1774
+ }
1775
+ }
1666
1776
  const out = deriveAttentions({
1667
1777
  equipment: [...this.equipment.values()],
1668
- locations: [...this.locations.values()],
1778
+ locations: [...this.locations.values()].map(n => {
1779
+ const criteria = criteriaOf.get(n.id);
1780
+ return criteria?.length ? { ...n, criteria } : n;
1781
+ }),
1669
1782
  orders: [...this.orders.values()],
1670
1783
  tasks: [...this.tasks.values()]
1671
1784
  }, this._acked, now, // 지연 판정의 "지금" — 관측 중이면 마지막으로 들은 시각이다(§nowMs)
@@ -22,6 +22,23 @@ export interface MonteCarloResult {
22
22
  mean: number;
23
23
  p50: number;
24
24
  p90: number;
25
+ /**
26
+ * **계보를 잇지 못한 채 넘어간 횟수** — 회차들 중 **최대값**(§`StateSnapshot.stepsWithoutMaterial`).
27
+ *
28
+ * ── 왜 예측 결과에 실어야 하나 (2026-08-24) ──────────────────────────────────
29
+ * 미러의 오더는 확보분을 갖지 않는다(원본이 「어느 개체가 잡혀 있나」를 말하지 않는 것이 그 시스템의
30
+ * 정상이다). 그래서 예측이 공정을 넘을 때 개체를 이을 수 없고, 커널은 **넘기되 세어 둔다.**
31
+ *
32
+ * 그런데 예측은 **회차마다 fork 를 만들고 버린다.** 그 카운터가 사본 안에만 있으면 아무에게도 닿지
33
+ * 않는다 — 이 저장소가 이미 그 실수를 겪었다(리듀서가 오래 세어 온 값을 스냅샷이 떨어뜨려, 그것을
34
+ * 읽도록 쓰인 검사가 영원히 조용했다). **세어 놓고 내보내지 않으면 안 센 것과 같다.**
35
+ *
36
+ * 왜 합이 아니라 최대인가: 회차는 **같은 미래의 표본들**이다. 서른 번 굴려 각 회차가 열 칸을
37
+ * 비웠으면 사실은 「열 칸」이고 300이 아니다. 합을 내면 회차 수를 늘리는 것만으로 숫자가 커진다.
38
+ *
39
+ * 0 이면 **싣지 않는다** — 0 을 정보처럼 보이게 하지 않는다.
40
+ */
41
+ stepsWithoutMaterial?: number;
25
42
  }
26
43
  export interface MonteCarloOptions {
27
44
  runs: number;
package/dist/forecast.js CHANGED
@@ -19,14 +19,17 @@ function clockOf(twin) {
19
19
  export function monteCarloForecast(twin, opts) {
20
20
  const now = clockOf(twin);
21
21
  const samples = [];
22
+ let gaps = 0;
22
23
  for (let i = 0; i < opts.runs; i++) {
23
24
  const { fc, step, target } = startRun(twin, opts, now, i);
24
25
  let guard = 0;
25
26
  while (clockOf(fc) < target && guard++ < 1_000_000)
26
27
  fc.tick(step);
27
- samples.push(opts.metric(fc.getSnapshot()));
28
+ const snap = fc.getSnapshot();
29
+ gaps = Math.max(gaps, snap.stepsWithoutMaterial ?? 0);
30
+ samples.push(opts.metric(snap));
28
31
  }
29
- return summarize(opts.runs, samples);
32
+ return summarize(opts.runs, samples, gaps);
30
33
  }
31
34
  /**
32
35
  * 한 회차의 **출발점** — fork(현재 보존) + 그 회차의 미래(seed 변주).
@@ -66,6 +69,7 @@ export async function monteCarloForecastAsync(twin, opts) {
66
69
  */
67
70
  const everyTicks = Math.max(1, opts.yieldEveryTicks ?? 50);
68
71
  const samples = [];
72
+ let gaps = 0;
69
73
  for (let i = 0; i < opts.runs; i++) {
70
74
  const { fc, step, target } = startRun(twin, opts, now, i);
71
75
  let guard = 0;
@@ -77,15 +81,27 @@ export async function monteCarloForecastAsync(twin, opts) {
77
81
  await yieldFn();
78
82
  }
79
83
  }
80
- samples.push(opts.metric(fc.getSnapshot()));
84
+ const snap = fc.getSnapshot();
85
+ gaps = Math.max(gaps, snap.stepsWithoutMaterial ?? 0);
86
+ samples.push(opts.metric(snap));
81
87
  if (i < opts.runs - 1)
82
88
  await yieldFn();
83
89
  }
84
- return summarize(opts.runs, samples);
90
+ return summarize(opts.runs, samples, gaps);
85
91
  }
86
- function summarize(runs, samples) {
92
+ function summarize(runs, samples, stepsWithoutMaterial = 0) {
87
93
  const sorted = [...samples].sort((a, b) => a - b);
88
94
  const pct = (p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
89
95
  const mean = samples.reduce((a, b) => a + b, 0) / (samples.length || 1);
90
- return { runs, samples, min: sorted[0], max: sorted[sorted.length - 1], mean, p50: pct(0.5), p90: pct(0.9) };
96
+ return {
97
+ runs,
98
+ samples,
99
+ min: sorted[0],
100
+ max: sorted[sorted.length - 1],
101
+ mean,
102
+ p50: pct(0.5),
103
+ p90: pct(0.9),
104
+ /* 0 은 싣지 않는다 — 「구멍이 없다」와 「이 축을 모른다」를 화면이 구별할 수 있게. */
105
+ ...(stepsWithoutMaterial > 0 ? { stepsWithoutMaterial } : {})
106
+ };
91
107
  }
@@ -1,4 +1,4 @@
1
- import type { AssetState, MaterialQuantity, TwinModelDef, CanonicalEnvelope, LocationState, ItemState, EquipmentState, PersonState, TaskState, OrderState, StructureShift } from './contract.ts';
1
+ import type { AssetState, MaterialQuantity, TwinModelDef, CanonicalEnvelope, LocationState, ItemState, LocationObservation, EquipmentState, PersonState, TaskState, OrderState, StructureShift } from './contract.ts';
2
2
  interface ProjItem {
3
3
  epc: string;
4
4
  /** 로트의 부분(표준 MaterialSubLot.ID) — 비직렬 로트가 자리마다 갈릴 때만. */
@@ -141,10 +141,17 @@ export interface ReducerCheckpoint {
141
141
  firstAtMs?: number;
142
142
  lastAtMs?: number;
143
143
  }[];
144
+ /** 자리별 · 속성별 마지막 관측 — 이어 접기가 이 축을 0부터 다시 만들지 않게. */
145
+ observations?: {
146
+ id: string;
147
+ values: LocationObservation[];
148
+ }[];
144
149
  }
145
150
  export declare class ObservedReducer {
146
151
  /** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
147
152
  private master;
153
+ /** 자리별 · 속성별 **마지막 관측** — 이력이 아니다(§`OP_EVENT.observation`). */
154
+ private observations;
148
155
  private items;
149
156
  private aggregation;
150
157
  /** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
@@ -41,6 +41,8 @@ function effectiveOf(r) {
41
41
  export class ObservedReducer {
42
42
  /** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
43
43
  master = new Map();
44
+ /** 자리별 · 속성별 **마지막 관측** — 이력이 아니다(§`OP_EVENT.observation`). */
45
+ observations = new Map();
44
46
  items = new Map();
45
47
  aggregation = new Map();
46
48
  /** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
@@ -339,6 +341,27 @@ export class ObservedReducer {
339
341
  this.touchLocation(d.location);
340
342
  break;
341
343
  }
344
+ case OP_EVENT.observation: {
345
+ /*
346
+ * **자리의 물리 관측** — 속성마다 마지막 값 하나만 든다(§`LocationState.observations`).
347
+ *
348
+ * 이력을 들지 않는 이유: 상태가 **시간에 비례해 자라지 않게**. 「그때 몇 도였나」는 저널이
349
+ * 답하는 물음이고, 상태가 답하는 것은 「지금 몇 도냐」다.
350
+ *
351
+ * 늦게 온 옛 관측이 최신을 덮지 않게 `stale` 을 지난다 — 대상 키에 속성을 넣는다(온도가 늦게
352
+ * 와도 습도의 최신은 지켜져야 한다).
353
+ */
354
+ const d = e.data;
355
+ if (!d?.locationId || !d?.propertyId)
356
+ break;
357
+ if (this.stale(`observation:${d.locationId}:${d.propertyId}`, e))
358
+ return;
359
+ this.touchLocation(d.locationId);
360
+ const bin = this.observations.get(d.locationId) ?? new Map();
361
+ bin.set(d.propertyId, { ...d });
362
+ this.observations.set(d.locationId, bin);
363
+ break;
364
+ }
342
365
  case OP_EVENT.attentionAck: {
343
366
  /* 확인한 사실만 담는다 — 그 신호가 지금도 성립하는지는 상태가 답한다(여기서 판단하지 않는다). */
344
367
  const d = e.data;
@@ -747,6 +770,8 @@ export class ObservedReducer {
747
770
  */
748
771
  serialize() {
749
772
  return {
773
+ /* 관측도 재개점에 든다 — 없으면 이어 접기가 그 축을 0부터 다시 만든다. */
774
+ observations: [...this.observations.entries()].map(([id, bin]) => ({ id, values: [...bin.values()] })),
750
775
  revision: this.revision,
751
776
  master: [...this.master.values()].map(n => ({ ...n })),
752
777
  items: [...this.items.values()].map(i => ({ ...i })),
@@ -771,6 +796,7 @@ export class ObservedReducer {
771
796
  * 모델에서 오는 판정 입력(교대·등급·시간대)은 생성자가 이미 세웠으므로 건드리지 않는다.
772
797
  */
773
798
  restore(cp) {
799
+ this.observations = new Map((cp?.observations ?? []).map(o => [o.id, new Map((o.values ?? []).map(v => [v.propertyId, { ...v }]))]));
774
800
  this.revision = cp?.revision ?? 0;
775
801
  this.master = new Map((cp?.master ?? []).map(n => [n.id, { ...n }]));
776
802
  this.items = new Map((cp?.items ?? []).map(i => [i.epc, { ...i }]));
@@ -807,6 +833,11 @@ export class ObservedReducer {
807
833
  ...(n.capacity === undefined ? {} : { capacity: n.capacity }),
808
834
  ...(n.parallelism === undefined ? {} : { parallelism: n.parallelism }),
809
835
  ...(n.parentId ? { parentId: n.parentId } : {}),
836
+ /* 관측된 물리량 — 없으면 **키를 만들지 않는다**(빈 배열은 「센서가 없다」와 「못 들었다」를 같게 만든다). */
837
+ ...(() => {
838
+ const bin = this.observations.get(n.id);
839
+ return bin?.size ? { observations: [...bin.values()] } : {};
840
+ })(),
810
841
  origin: n.origin
811
842
  };
812
843
  }),