@operato/twin-kernel 0.7.26 → 0.7.28

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.
@@ -1,5 +1,5 @@
1
1
  import type { TwinModelDef, CanonicalEnvelope, StructureShift } from './contract.ts';
2
- import { type ProjectedState } from './state-projector.ts';
2
+ import { type ProjectedState, type ReducerCheckpoint } from './state-projector.ts';
3
3
  export declare class EventJournal {
4
4
  private events;
5
5
  /** 이벤트 1건 기록(추가 전용). runtime/kernel 의 onEvent 에 연결. */
@@ -15,6 +15,29 @@ export declare class EventJournal {
15
15
  }
16
16
  /** 이벤트열 → 상태 재구성(시간여행). `model` = 그 시점의 트윈 모델(토폴로지·자원). */
17
17
  export declare function replay(model: TwinModelDef, events: readonly CanonicalEnvelope[]): ProjectedState;
18
+ /**
19
+ * **재개점에서 이어 접는다** — 0부터 다시 접지 않는다.
20
+ *
21
+ * ── 왜 (2026-08-18 실측) ────────────────────────────────────────────────────
22
+ * 저널이 27만 건인 트윈에서 과거 상태를 물으면 전부 다시 접어야 했다. 그런데 우리는 이미 주기적으로
23
+ * 재개점을 남기고 있다 — 그 지점부터 **뒤에 일어난 것만** 접으면 같은 답이 나온다.
24
+ *
25
+ * 「같은 답」은 말로 보장되지 않는다: 재개점이 리듀서의 **내부 상태 전부**여야 하고(보기가 아니라),
26
+ * 그 사실은 시험이 증명한다(0부터 접기 == 재개점 + 꼬리). 그래서 이 함수는 새 재개점도 함께 낸다 —
27
+ * 소비처가 그것을 저장해 다음 꼬리를 또 이어 붙일 수 있게.
28
+ *
29
+ * 구조가 바뀐 구간은 여기서 다루지 않는다(`replaySegments` 의 몫이다) — 재개점은 **한 구조 안에서**
30
+ * 이어 붙이는 것이다. 구조가 바뀌었으면 부르는 쪽이 그 경계에서 갈라야 한다.
31
+ */
32
+ export declare function replayFrom(model: TwinModelDef, checkpoint: ReducerCheckpoint, events: readonly CanonicalEnvelope[]): {
33
+ state: ProjectedState;
34
+ checkpoint: ReducerCheckpoint;
35
+ };
36
+ /** 이벤트열을 접고 **재개점도 함께** 낸다 — 다음 번에 이어 붙일 수 있게. */
37
+ export declare function replayWithCheckpoint(model: TwinModelDef, events: readonly CanonicalEnvelope[]): {
38
+ state: ProjectedState;
39
+ checkpoint: ReducerCheckpoint;
40
+ };
18
41
  /**
19
42
  * 한 구조 아래에서 일어난 이벤트들 — 재생의 한 마디.
20
43
  *
@@ -39,6 +39,34 @@ export function replay(model, events) {
39
39
  proj.apply(e);
40
40
  return proj.snapshot();
41
41
  }
42
+ /**
43
+ * **재개점에서 이어 접는다** — 0부터 다시 접지 않는다.
44
+ *
45
+ * ── 왜 (2026-08-18 실측) ────────────────────────────────────────────────────
46
+ * 저널이 27만 건인 트윈에서 과거 상태를 물으면 전부 다시 접어야 했다. 그런데 우리는 이미 주기적으로
47
+ * 재개점을 남기고 있다 — 그 지점부터 **뒤에 일어난 것만** 접으면 같은 답이 나온다.
48
+ *
49
+ * 「같은 답」은 말로 보장되지 않는다: 재개점이 리듀서의 **내부 상태 전부**여야 하고(보기가 아니라),
50
+ * 그 사실은 시험이 증명한다(0부터 접기 == 재개점 + 꼬리). 그래서 이 함수는 새 재개점도 함께 낸다 —
51
+ * 소비처가 그것을 저장해 다음 꼬리를 또 이어 붙일 수 있게.
52
+ *
53
+ * 구조가 바뀐 구간은 여기서 다루지 않는다(`replaySegments` 의 몫이다) — 재개점은 **한 구조 안에서**
54
+ * 이어 붙이는 것이다. 구조가 바뀌었으면 부르는 쪽이 그 경계에서 갈라야 한다.
55
+ */
56
+ export function replayFrom(model, checkpoint, events) {
57
+ const proj = new StateProjector(model);
58
+ proj.restore(checkpoint);
59
+ for (const e of events)
60
+ proj.apply(e);
61
+ return { state: proj.snapshot(), checkpoint: proj.serialize() };
62
+ }
63
+ /** 이벤트열을 접고 **재개점도 함께** 낸다 — 다음 번에 이어 붙일 수 있게. */
64
+ export function replayWithCheckpoint(model, events) {
65
+ const proj = new StateProjector(model);
66
+ for (const e of events)
67
+ proj.apply(e);
68
+ return { state: proj.snapshot(), checkpoint: proj.serialize() };
69
+ }
42
70
  /**
43
71
  * **구조가 바뀐 이력까지 이어서 재생한다.**
44
72
  *
@@ -210,6 +210,39 @@ interface Rng {
210
210
  * FlowEngine.computeAttentions(sim) 와 live projector 미러가 **공유**하는 계산 층(face2-inbound-live §1.1):
211
211
  * sim/live 가 같은 임계값·규칙을 쓴다. good/scrap 미제공(관측 상태에 없음)이면 scrap-high 는 자연 스킵(metric 층 갭).
212
212
  */
213
+ /**
214
+ * 주의 판정의 **기준값 어휘** — 현장이 정하는 수다.
215
+ *
216
+ * ── 왜 선언으로 받나 (2026-08-18) ───────────────────────────────────────────
217
+ * 혼잡 90%·불량 15%는 **우리가 정한 수**였다(코드 상수). 그런데 통로를 95%로 채워 쓰는 창고는 그 기준에서
218
+ * 영구히 「병목」이고, 초기 라인(불량 20% 예상)은 계속 「불량 과다」로 운다. 경보가 소음이 되면 사람이
219
+ * 경보 자체를 읽지 않는다 — 그때 진짜 경보도 함께 묻힌다.
220
+ *
221
+ * 그래서 기준을 현장이 넣을 수 있게 하고, **넣지 않으면 기본값을 쓰되 그 사실을 밝힌다**(`basis`).
222
+ * 조용히 기본값을 진실처럼 두지 않는다.
223
+ *
224
+ * 퍼센트다: 90 이 90%이고 0.9 는 0.9%다(SOC 에서 한 번 겪은 함정 — 명세가 그 말을 한다).
225
+ */
226
+ export declare const ATTENTION_PROPERTY: {
227
+ /** 자리의 혼잡 판정 기준(%) — 점유/용량이 이 값을 넘으면 병목으로 본다. */
228
+ readonly congestionRatio: "attention.congestionRatio";
229
+ /** 설비의 불량률 판정 기준(%) — 이 값을 넘으면 불량 과다로 본다. */
230
+ readonly scrapRate: "attention.scrapRate";
231
+ /** 불량률을 재기 시작할 최소 표본 수 — 적은 표본의 비율은 판정 근거가 못 된다. */
232
+ readonly scrapMinSamples: "attention.scrapMinSamples";
233
+ };
234
+ /** 선언이 없을 때 쓰는 기본값 — **드러내 둔다**(코드 안에 숨은 상수가 아니라 계약의 일부다). */
235
+ export declare const ATTENTION_DEFAULTS: {
236
+ readonly congestionPct: 90;
237
+ readonly scrapPct: 15;
238
+ readonly scrapMinSamples: 10;
239
+ };
240
+ /** 판정에 쓸 기준 — 대상별 선언이 있으면 그것, 없으면 기본값(그 사실을 함께 든다). */
241
+ export interface AttentionThresholds {
242
+ congestionPctOf?: (locationId: string) => number | undefined;
243
+ scrapPctOf?: (equipmentId: string) => number | undefined;
244
+ scrapMinSamplesOf?: (equipmentId: string) => number | undefined;
245
+ }
213
246
  export declare function deriveAttentions(view: {
214
247
  equipment: {
215
248
  id: string;
@@ -243,7 +276,13 @@ export declare function deriveAttentions(view: {
243
276
  }[];
244
277
  }, acked?: ReadonlySet<string>,
245
278
  /** 지금(ISO) — 납기 판정에 필요하다. **주지 않으면 지연을 판정하지 않는다**(모르면 판단하지 않는다). */
246
- nowIso?: ISOTime): Attention[];
279
+ nowIso?: ISOTime,
280
+ /**
281
+ * 현장이 선언한 판정 기준 — 주지 않으면 기본값으로 판정하고 그 사실을 `params.basis` 로 밝힌다.
282
+ *
283
+ * 부르는 쪽이 모델에서 뽑아 넘긴다(이 함수는 순수하게 남는다 — 모델을 읽는 규칙이 두 곳이 되지 않게).
284
+ */
285
+ thresholds?: AttentionThresholds): Attention[];
247
286
  /** OEE 계측 카운터 — sim 은 tick 으로 누적, live 는 실 텔레메트리 또는 이벤트 누적기가 채운다(face2-inbound-live §1.1). */
248
287
  export interface OeeCounters {
249
288
  runMs: number;
@@ -491,6 +530,14 @@ export declare abstract class FlowEngine implements TwinKernel {
491
530
  getSnapshot(): StateSnapshot;
492
531
  protected computeAttentions(): Attention[];
493
532
  protected collectAttentions(): Attention[];
533
+ /**
534
+ * 현장이 선언한 판정 기준을 모델에서 뽑는다 — **규칙은 한 곳**(순수 함수는 모델을 읽지 않는다).
535
+ *
536
+ * 자리·설비의 속성에서 읽고, 없으면 `undefined` 를 돌려 기본값이 쓰이게 한다(그 사실은 판정 층이
537
+ * `basis` 로 밝힌다). 값이 수가 아니거나 범위를 벗어나면 **없는 것으로 본다** — 사람이 손으로 넣는
538
+ * 값이라 잘못 들어올 수 있고, 잘못된 기준으로 판정하는 것은 기본값보다 나쁘다.
539
+ */
540
+ protected attentionThresholds(): AttentionThresholds;
494
541
  /**
495
542
  * fork — 현재 상태를 정확히 복제한 새 엔진 (디지털트윈 본연: "현재로부터 예측").
496
543
  * 원본(live/sim)은 계속 진행, fork 는 what-if 를 앞으로 시뮬레이션해 forecast·발산(predicted vs actual) 검사에 쓴다.
@@ -44,9 +44,38 @@ function mulberry32(seed) {
44
44
  * FlowEngine.computeAttentions(sim) 와 live projector 미러가 **공유**하는 계산 층(face2-inbound-live §1.1):
45
45
  * sim/live 가 같은 임계값·규칙을 쓴다. good/scrap 미제공(관측 상태에 없음)이면 scrap-high 는 자연 스킵(metric 층 갭).
46
46
  */
47
+ /**
48
+ * 주의 판정의 **기준값 어휘** — 현장이 정하는 수다.
49
+ *
50
+ * ── 왜 선언으로 받나 (2026-08-18) ───────────────────────────────────────────
51
+ * 혼잡 90%·불량 15%는 **우리가 정한 수**였다(코드 상수). 그런데 통로를 95%로 채워 쓰는 창고는 그 기준에서
52
+ * 영구히 「병목」이고, 초기 라인(불량 20% 예상)은 계속 「불량 과다」로 운다. 경보가 소음이 되면 사람이
53
+ * 경보 자체를 읽지 않는다 — 그때 진짜 경보도 함께 묻힌다.
54
+ *
55
+ * 그래서 기준을 현장이 넣을 수 있게 하고, **넣지 않으면 기본값을 쓰되 그 사실을 밝힌다**(`basis`).
56
+ * 조용히 기본값을 진실처럼 두지 않는다.
57
+ *
58
+ * 퍼센트다: 90 이 90%이고 0.9 는 0.9%다(SOC 에서 한 번 겪은 함정 — 명세가 그 말을 한다).
59
+ */
60
+ export const ATTENTION_PROPERTY = {
61
+ /** 자리의 혼잡 판정 기준(%) — 점유/용량이 이 값을 넘으면 병목으로 본다. */
62
+ congestionRatio: 'attention.congestionRatio',
63
+ /** 설비의 불량률 판정 기준(%) — 이 값을 넘으면 불량 과다로 본다. */
64
+ scrapRate: 'attention.scrapRate',
65
+ /** 불량률을 재기 시작할 최소 표본 수 — 적은 표본의 비율은 판정 근거가 못 된다. */
66
+ scrapMinSamples: 'attention.scrapMinSamples'
67
+ };
68
+ /** 선언이 없을 때 쓰는 기본값 — **드러내 둔다**(코드 안에 숨은 상수가 아니라 계약의 일부다). */
69
+ export const ATTENTION_DEFAULTS = { congestionPct: 90, scrapPct: 15, scrapMinSamples: 10 };
47
70
  export function deriveAttentions(view, acked,
48
71
  /** 지금(ISO) — 납기 판정에 필요하다. **주지 않으면 지연을 판정하지 않는다**(모르면 판단하지 않는다). */
49
- nowIso) {
72
+ nowIso,
73
+ /**
74
+ * 현장이 선언한 판정 기준 — 주지 않으면 기본값으로 판정하고 그 사실을 `params.basis` 로 밝힌다.
75
+ *
76
+ * 부르는 쪽이 모델에서 뽑아 넘긴다(이 함수는 순수하게 남는다 — 모델을 읽는 규칙이 두 곳이 되지 않게).
77
+ */
78
+ thresholds) {
50
79
  // 언어 중립: kind + params(원시값) + 조치 code 만 방출. 사람이 읽는 title/detail/rationale/라벨은
51
80
  // 표현계층(클라 i18next)이 kind/code 로 렌더한다(무방언·다국어 — design/plans/i18n.md L3).
52
81
  const out = [];
@@ -67,12 +96,19 @@ nowIso) {
67
96
  for (const n of view.locations) {
68
97
  if ((n.capacity ?? 0) > 0) {
69
98
  const r = (n.occupancy ?? 0) / n.capacity;
70
- if (r >= 0.9) {
99
+ /* 기준은 현장이 정한다 — 없으면 기본값이고, 어느 쪽인지 함께 낸다(조용히 기본값을 진실로 두지 않는다). */
100
+ const declaredPct = thresholds?.congestionPctOf?.(n.id);
101
+ const thresholdPct = declaredPct ?? ATTENTION_DEFAULTS.congestionPct;
102
+ if (r * 100 >= thresholdPct) {
71
103
  const saturated = r >= 1;
72
104
  out.push({
73
105
  id: `bottleneck:${n.id}`, kind: 'bottleneck', severity: saturated ? 'high' : 'medium',
74
106
  anchor: { locationId: n.id },
75
- params: { locationId: n.id, occupancy: n.occupancy ?? 0, capacity: n.capacity ?? 0, ratioPct: Math.round(r * 100), saturated: saturated ? 1 : 0 },
107
+ params: {
108
+ locationId: n.id, occupancy: n.occupancy ?? 0, capacity: n.capacity ?? 0, ratioPct: Math.round(r * 100),
109
+ saturated: saturated ? 1 : 0,
110
+ thresholdPct, basis: declaredPct === undefined ? 'default' : 'declared'
111
+ },
76
112
  recommendedActions: [{ code: 'advice.add-resource' }, { code: 'advice.downstream-priority' }]
77
113
  // 권고만(command 없음) — 표현계층이 advice.* 코드로 렌더
78
114
  });
@@ -130,13 +166,21 @@ nowIso) {
130
166
  }
131
167
  for (const m of view.equipment) {
132
168
  const total = (m.goodCount ?? 0) + (m.scrapCount ?? 0);
133
- if (total >= 10) {
169
+ const declaredMin = thresholds?.scrapMinSamplesOf?.(m.id);
170
+ const minSamples = declaredMin ?? ATTENTION_DEFAULTS.scrapMinSamples;
171
+ if (total >= minSamples) {
134
172
  const rate = (m.scrapCount ?? 0) / total;
135
- if (rate >= 0.15)
173
+ const declaredPct = thresholds?.scrapPctOf?.(m.id);
174
+ const thresholdPct = declaredPct ?? ATTENTION_DEFAULTS.scrapPct;
175
+ /* 「높음」은 기준의 두 배에서 — 기준이 현장의 것이면 그 두 배도 현장의 것이다(상수 두 벌을 두지 않는다). */
176
+ if (rate * 100 >= thresholdPct)
136
177
  out.push({
137
- id: `scrap:${m.id}`, kind: 'scrap-high', severity: rate >= 0.3 ? 'high' : 'medium',
178
+ id: `scrap:${m.id}`, kind: 'scrap-high', severity: rate * 100 >= thresholdPct * 2 ? 'high' : 'medium',
138
179
  anchor: { moverId: m.id, locationId: m.location },
139
- params: { moverId: m.id, goodCount: m.goodCount ?? 0, scrapCount: m.scrapCount ?? 0, ratePct: Math.round(rate * 100) },
180
+ params: {
181
+ moverId: m.id, goodCount: m.goodCount ?? 0, scrapCount: m.scrapCount ?? 0, ratePct: Math.round(rate * 100),
182
+ thresholdPct, basis: declaredPct === undefined ? 'default' : 'declared'
183
+ },
140
184
  recommendedActions: [
141
185
  { code: 'act.hold-for-inspection', command: CMD.resourceHold, args: { resourceId: m.id } },
142
186
  { code: 'act.reset-metrics', command: CMD.resourceResetMetrics, args: { resourceId: m.id } }
@@ -1105,10 +1149,40 @@ export class FlowEngine {
1105
1149
  locations: [...this.locations.values()],
1106
1150
  orders: [...this.orders.values()],
1107
1151
  tasks: [...this.tasks.values()]
1108
- }, this._acked, now // 지연 판정의 "지금" — 관측 중이면 마지막으로 들은 시각이다(§nowMs)
1109
- );
1152
+ }, this._acked, now, // 지연 판정의 "지금" — 관측 중이면 마지막으로 들은 시각이다(§nowMs)
1153
+ this.attentionThresholds());
1110
1154
  return out;
1111
1155
  }
1156
+ /**
1157
+ * 현장이 선언한 판정 기준을 모델에서 뽑는다 — **규칙은 한 곳**(순수 함수는 모델을 읽지 않는다).
1158
+ *
1159
+ * 자리·설비의 속성에서 읽고, 없으면 `undefined` 를 돌려 기본값이 쓰이게 한다(그 사실은 판정 층이
1160
+ * `basis` 로 밝힌다). 값이 수가 아니거나 범위를 벗어나면 **없는 것으로 본다** — 사람이 손으로 넣는
1161
+ * 값이라 잘못 들어올 수 있고, 잘못된 기준으로 판정하는 것은 기본값보다 나쁘다.
1162
+ */
1163
+ attentionThresholds() {
1164
+ const pct = (props, id) => {
1165
+ const raw = props?.find(p => p.id === id)?.value;
1166
+ if (raw === undefined)
1167
+ return undefined;
1168
+ const n = Number(raw);
1169
+ return Number.isFinite(n) && n > 0 && n <= 100 ? n : undefined;
1170
+ };
1171
+ const count = (props, id) => {
1172
+ const raw = props?.find(p => p.id === id)?.value;
1173
+ if (raw === undefined)
1174
+ return undefined;
1175
+ const n = Number(raw);
1176
+ return Number.isFinite(n) && n >= 1 ? Math.floor(n) : undefined;
1177
+ };
1178
+ const locProps = new Map((this.boardDef?.locations ?? []).map(l => [l.id, l.properties]));
1179
+ const eqProps = new Map(readBoardEquipment(this.boardDef ?? {}).map(e => [e.id, e.properties]));
1180
+ return {
1181
+ congestionPctOf: id => pct(locProps.get(id), ATTENTION_PROPERTY.congestionRatio),
1182
+ scrapPctOf: id => pct(eqProps.get(id), ATTENTION_PROPERTY.scrapRate),
1183
+ scrapMinSamplesOf: id => count(eqProps.get(id), ATTENTION_PROPERTY.scrapMinSamples)
1184
+ };
1185
+ }
1112
1186
  /**
1113
1187
  * fork — 현재 상태를 정확히 복제한 새 엔진 (디지털트윈 본연: "현재로부터 예측").
1114
1188
  * 원본(live/sim)은 계속 진행, fork 는 what-if 를 앞으로 시뮬레이션해 forecast·발산(predicted vs actual) 검사에 쓴다.
@@ -1,4 +1,22 @@
1
- import type { AssetState, TwinModelDef, CanonicalEnvelope, LocationState, ItemState, EquipmentState, PersonState, TaskState, OrderState, StructureShift } from './contract.ts';
1
+ import type { AssetState, MaterialQuantity, TwinModelDef, CanonicalEnvelope, LocationState, ItemState, EquipmentState, PersonState, TaskState, OrderState, StructureShift } from './contract.ts';
2
+ interface ProjItem {
3
+ epc: string;
4
+ /** 로트의 부분(표준 MaterialSubLot.ID) — 비직렬 로트가 자리마다 갈릴 때만. */
5
+ subLotId?: string;
6
+ gtin?: string;
7
+ gtinKey?: string;
8
+ lot?: string;
9
+ location: string;
10
+ disposition?: string;
11
+ parent?: string;
12
+ qty?: number;
13
+ uom?: string;
14
+ /** 선언된 모든 수량 — 표준 `MaterialLot.Quantity`(복수). 첫 항목만 쓰던 것을 고쳤다. */
15
+ quantities?: MaterialQuantity[];
16
+ /** 받은 개체·로트 마스터데이터 원문 — 이름을 모르는 속성도 잃지 않는다. */
17
+ ilmd?: Record<string, unknown>;
18
+ expiry?: number;
19
+ }
2
20
  /**
3
21
  * 마스터 동기 — 선언적 로케이션 upsert/remove.
4
22
  *
@@ -63,6 +81,50 @@ export interface ProjectedState {
63
81
  */
64
82
  acked: string[];
65
83
  }
84
+ /**
85
+ * 리듀서의 **완전한 재개점** — 이어 접기의 씨앗.
86
+ *
87
+ * `ProjectedState`(보기)와 구별한다: 여기에는 소비처가 보지 않는 것도 들어간다(보류된 담김·집합·
88
+ * 반영 못 한 사건 집계). 그것이 빠지면 「이어 접은 결과」가 「0부터 접은 결과」와 조용히 달라진다.
89
+ */
90
+ export interface ReducerCheckpoint {
91
+ revision: number;
92
+ master: {
93
+ id: string;
94
+ type: string;
95
+ capacity?: number;
96
+ parallelism?: number;
97
+ parentId?: string;
98
+ origin: 'master' | 'observed';
99
+ }[];
100
+ items: ProjItem[];
101
+ aggregation: {
102
+ parent: string;
103
+ children: string[];
104
+ }[];
105
+ pendingParent: {
106
+ child: string;
107
+ parent: string;
108
+ }[];
109
+ tasks: TaskState[];
110
+ equipment: EquipmentState[];
111
+ persons: PersonState[];
112
+ assets: AssetState[];
113
+ orders: OrderState[];
114
+ acked: string[];
115
+ corrections: {
116
+ declaredAt: string;
117
+ reason?: string;
118
+ correctiveEventIDs: string[];
119
+ eventID?: string;
120
+ }[];
121
+ unhandled: {
122
+ eventType: string;
123
+ count: number;
124
+ firstAtMs?: number;
125
+ lastAtMs?: number;
126
+ }[];
127
+ }
66
128
  export declare class ObservedReducer {
67
129
  /** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
68
130
  private master;
@@ -215,5 +277,27 @@ export declare class ObservedReducer {
215
277
  * **이벤트 없이 시각만으로** 넘어간다. 델타로 받아 두면 만료된 자격이 영원히 유효하게 남는다.
216
278
  */
217
279
  private capabilityPart;
280
+ /**
281
+ * **재개점(checkpoint)** — 스냅샷과 다르다.
282
+ *
283
+ * ── 왜 스냅샷으로는 이어 접을 수 없나 (2026-08-18) ─────────────────────────
284
+ * `snapshot()` 은 **소비처가 보는 값**이다(파생된 판정·정리된 목록). 그것으로 리듀서를 되세우면
285
+ * 보이지 않는 것들이 사라진다 — 아직 등장하지 않은 자식의 담김(`pendingParent`), 집합 관계
286
+ * (`aggregation`), 반영 못 한 사건 집계(`unhandled`), 정정 선언(`corrections`). 그 상태에서 뒤 이벤트를
287
+ * 접으면 **0부터 접은 결과와 달라진다.** 다르면 그 차이는 조용하다(오류가 없다).
288
+ *
289
+ * 그래서 재개점은 **내부 상태 전부**다. 「이어 접기」의 정합성은 시험이 증명한다:
290
+ * 0부터 접은 결과 == 앞부분 재개점 + 뒷부분 접기.
291
+ *
292
+ * 모델에서 오는 것(교대·등급·시간대)은 담지 않는다 — 되세울 때 같은 모델을 받기 때문이다.
293
+ */
294
+ serialize(): ReducerCheckpoint;
295
+ /**
296
+ * 재개점에서 되세운다 — **모르는 것은 지어내지 않는다**(없는 축은 비운다).
297
+ *
298
+ * 모델에서 오는 판정 입력(교대·등급·시간대)은 생성자가 이미 세웠으므로 건드리지 않는다.
299
+ */
300
+ restore(cp: ReducerCheckpoint): void;
218
301
  snapshot(): ProjectedState;
219
302
  }
303
+ export {};
@@ -635,6 +635,57 @@ export class ObservedReducer {
635
635
  capability: capabilityOf({ ...r, ...(decl?.window ? { window: decl.window } : {}), ...(decl?.workCalendar ? { workCalendar: decl.workCalendar } : {}) }, { at, utcOffsetMinutes: this.utcOffsetMinutes, requiredTests: requiredTestsFor(directClassIds, defs, at) })
636
636
  };
637
637
  }
638
+ /**
639
+ * **재개점(checkpoint)** — 스냅샷과 다르다.
640
+ *
641
+ * ── 왜 스냅샷으로는 이어 접을 수 없나 (2026-08-18) ─────────────────────────
642
+ * `snapshot()` 은 **소비처가 보는 값**이다(파생된 판정·정리된 목록). 그것으로 리듀서를 되세우면
643
+ * 보이지 않는 것들이 사라진다 — 아직 등장하지 않은 자식의 담김(`pendingParent`), 집합 관계
644
+ * (`aggregation`), 반영 못 한 사건 집계(`unhandled`), 정정 선언(`corrections`). 그 상태에서 뒤 이벤트를
645
+ * 접으면 **0부터 접은 결과와 달라진다.** 다르면 그 차이는 조용하다(오류가 없다).
646
+ *
647
+ * 그래서 재개점은 **내부 상태 전부**다. 「이어 접기」의 정합성은 시험이 증명한다:
648
+ * 0부터 접은 결과 == 앞부분 재개점 + 뒷부분 접기.
649
+ *
650
+ * 모델에서 오는 것(교대·등급·시간대)은 담지 않는다 — 되세울 때 같은 모델을 받기 때문이다.
651
+ */
652
+ serialize() {
653
+ return {
654
+ revision: this.revision,
655
+ master: [...this.master.values()].map(n => ({ ...n })),
656
+ items: [...this.items.values()].map(i => ({ ...i })),
657
+ aggregation: [...this.aggregation.entries()].map(([parent, children]) => ({ parent, children: [...children] })),
658
+ pendingParent: [...this.pendingParent.entries()].map(([child, parent]) => ({ child, parent })),
659
+ tasks: [...this.tasks.values()].map(t => ({ ...t })),
660
+ equipment: [...this.equipment.values()].map(m => ({ ...m })),
661
+ persons: [...this.persons.values()].map(x => ({ ...x })),
662
+ assets: [...this.assets.values()].map(x => ({ ...x })),
663
+ orders: [...this.orders.values()].map(o => ({ ...o })),
664
+ acked: [...this.acked],
665
+ corrections: this.corrections.map(c => ({ ...c })),
666
+ unhandled: [...this.unhandled.entries()].map(([eventType, v]) => ({ eventType, ...v }))
667
+ };
668
+ }
669
+ /**
670
+ * 재개점에서 되세운다 — **모르는 것은 지어내지 않는다**(없는 축은 비운다).
671
+ *
672
+ * 모델에서 오는 판정 입력(교대·등급·시간대)은 생성자가 이미 세웠으므로 건드리지 않는다.
673
+ */
674
+ restore(cp) {
675
+ this.revision = cp?.revision ?? 0;
676
+ this.master = new Map((cp?.master ?? []).map(n => [n.id, { ...n }]));
677
+ this.items = new Map((cp?.items ?? []).map(i => [i.epc, { ...i }]));
678
+ this.aggregation = new Map((cp?.aggregation ?? []).map(a => [a.parent, [...a.children]]));
679
+ this.pendingParent = new Map((cp?.pendingParent ?? []).map(x => [x.child, x.parent]));
680
+ this.tasks = new Map((cp?.tasks ?? []).map(t => [t.id, { ...t }]));
681
+ this.equipment = new Map((cp?.equipment ?? []).map(m => [m.id, { ...m }]));
682
+ this.persons = new Map((cp?.persons ?? []).map(x => [x.id, { ...x }]));
683
+ this.assets = new Map((cp?.assets ?? []).map(x => [x.id, { ...x }]));
684
+ this.orders = new Map((cp?.orders ?? []).map(o => [o.id, { ...o }]));
685
+ this.acked = new Set(cp?.acked ?? []);
686
+ this.corrections = (cp?.corrections ?? []).map(c => ({ ...c }));
687
+ this.unhandled = new Map((cp?.unhandled ?? []).map(({ eventType, ...v }) => [eventType, { ...v }]));
688
+ }
638
689
  snapshot() {
639
690
  const occ = new Map();
640
691
  for (const it of this.items.values())
@@ -1,2 +1,2 @@
1
1
  export { ObservedReducer, ObservedReducer as StateProjector } from './observed-reducer.ts';
2
- export type { MasterUpdate, ProjectedState } from './observed-reducer.ts';
2
+ export type { MasterUpdate, ProjectedState, ReducerCheckpoint } from './observed-reducer.ts';
@@ -19,6 +19,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
19
19
  // src/index.ts
20
20
  var index_exports = {};
21
21
  __export(index_exports, {
22
+ ATTENTION_DEFAULTS: () => ATTENTION_DEFAULTS,
23
+ ATTENTION_PROPERTY: () => ATTENTION_PROPERTY,
22
24
  BIZSTEP: () => BIZSTEP,
23
25
  BTT: () => BTT,
24
26
  BTT_DELIVERY: () => BTT_DELIVERY,
@@ -144,7 +146,9 @@ __export(index_exports, {
144
146
  relationsFrom: () => relationsFrom,
145
147
  relationsTo: () => relationsTo,
146
148
  replay: () => replay,
149
+ replayFrom: () => replayFrom,
147
150
  replaySegments: () => replaySegments,
151
+ replayWithCheckpoint: () => replayWithCheckpoint,
148
152
  requiredTestsFor: () => requiredTestsFor,
149
153
  retiredVocabularyIn: () => retiredVocabularyIn,
150
154
  sgtinClass: () => sgtinClass,
@@ -1584,6 +1588,57 @@ var ObservedReducer = class {
1584
1588
  )
1585
1589
  };
1586
1590
  }
1591
+ /**
1592
+ * **재개점(checkpoint)** — 스냅샷과 다르다.
1593
+ *
1594
+ * ── 왜 스냅샷으로는 이어 접을 수 없나 (2026-08-18) ─────────────────────────
1595
+ * `snapshot()` 은 **소비처가 보는 값**이다(파생된 판정·정리된 목록). 그것으로 리듀서를 되세우면
1596
+ * 보이지 않는 것들이 사라진다 — 아직 등장하지 않은 자식의 담김(`pendingParent`), 집합 관계
1597
+ * (`aggregation`), 반영 못 한 사건 집계(`unhandled`), 정정 선언(`corrections`). 그 상태에서 뒤 이벤트를
1598
+ * 접으면 **0부터 접은 결과와 달라진다.** 다르면 그 차이는 조용하다(오류가 없다).
1599
+ *
1600
+ * 그래서 재개점은 **내부 상태 전부**다. 「이어 접기」의 정합성은 시험이 증명한다:
1601
+ * 0부터 접은 결과 == 앞부분 재개점 + 뒷부분 접기.
1602
+ *
1603
+ * 모델에서 오는 것(교대·등급·시간대)은 담지 않는다 — 되세울 때 같은 모델을 받기 때문이다.
1604
+ */
1605
+ serialize() {
1606
+ return {
1607
+ revision: this.revision,
1608
+ master: [...this.master.values()].map((n) => ({ ...n })),
1609
+ items: [...this.items.values()].map((i) => ({ ...i })),
1610
+ aggregation: [...this.aggregation.entries()].map(([parent, children]) => ({ parent, children: [...children] })),
1611
+ pendingParent: [...this.pendingParent.entries()].map(([child, parent]) => ({ child, parent })),
1612
+ tasks: [...this.tasks.values()].map((t) => ({ ...t })),
1613
+ equipment: [...this.equipment.values()].map((m) => ({ ...m })),
1614
+ persons: [...this.persons.values()].map((x) => ({ ...x })),
1615
+ assets: [...this.assets.values()].map((x) => ({ ...x })),
1616
+ orders: [...this.orders.values()].map((o) => ({ ...o })),
1617
+ acked: [...this.acked],
1618
+ corrections: this.corrections.map((c) => ({ ...c })),
1619
+ unhandled: [...this.unhandled.entries()].map(([eventType, v]) => ({ eventType, ...v }))
1620
+ };
1621
+ }
1622
+ /**
1623
+ * 재개점에서 되세운다 — **모르는 것은 지어내지 않는다**(없는 축은 비운다).
1624
+ *
1625
+ * 모델에서 오는 판정 입력(교대·등급·시간대)은 생성자가 이미 세웠으므로 건드리지 않는다.
1626
+ */
1627
+ restore(cp) {
1628
+ this.revision = cp?.revision ?? 0;
1629
+ this.master = new Map((cp?.master ?? []).map((n) => [n.id, { ...n }]));
1630
+ this.items = new Map((cp?.items ?? []).map((i) => [i.epc, { ...i }]));
1631
+ this.aggregation = new Map((cp?.aggregation ?? []).map((a) => [a.parent, [...a.children]]));
1632
+ this.pendingParent = new Map((cp?.pendingParent ?? []).map((x) => [x.child, x.parent]));
1633
+ this.tasks = new Map((cp?.tasks ?? []).map((t) => [t.id, { ...t }]));
1634
+ this.equipment = new Map((cp?.equipment ?? []).map((m) => [m.id, { ...m }]));
1635
+ this.persons = new Map((cp?.persons ?? []).map((x) => [x.id, { ...x }]));
1636
+ this.assets = new Map((cp?.assets ?? []).map((x) => [x.id, { ...x }]));
1637
+ this.orders = new Map((cp?.orders ?? []).map((o) => [o.id, { ...o }]));
1638
+ this.acked = new Set(cp?.acked ?? []);
1639
+ this.corrections = (cp?.corrections ?? []).map((c) => ({ ...c }));
1640
+ this.unhandled = new Map((cp?.unhandled ?? []).map(({ eventType, ...v }) => [eventType, { ...v }]));
1641
+ }
1587
1642
  snapshot() {
1588
1643
  const occ = /* @__PURE__ */ new Map();
1589
1644
  for (const it of this.items.values()) occ.set(it.location, (occ.get(it.location) ?? 0) + 1);
@@ -1651,6 +1706,17 @@ function replay(model, events) {
1651
1706
  for (const e of events) proj.apply(e);
1652
1707
  return proj.snapshot();
1653
1708
  }
1709
+ function replayFrom(model, checkpoint, events) {
1710
+ const proj = new ObservedReducer(model);
1711
+ proj.restore(checkpoint);
1712
+ for (const e of events) proj.apply(e);
1713
+ return { state: proj.snapshot(), checkpoint: proj.serialize() };
1714
+ }
1715
+ function replayWithCheckpoint(model, events) {
1716
+ const proj = new ObservedReducer(model);
1717
+ for (const e of events) proj.apply(e);
1718
+ return { state: proj.snapshot(), checkpoint: proj.serialize() };
1719
+ }
1654
1720
  function replaySegments(segments) {
1655
1721
  if (!segments.length) throw new Error("\uC7AC\uC0DD\uD560 \uB9C8\uB514\uAC00 \uC5C6\uB2E4 \u2014 \uAD6C\uC870\uB97C \uD558\uB098\uB3C4 \uC8FC\uC9C0 \uC54A\uC558\uB2E4");
1656
1722
  const proj = new ObservedReducer(segments[0].model);
@@ -2888,7 +2954,16 @@ function mulberry32(seed) {
2888
2954
  } });
2889
2955
  return fn;
2890
2956
  }
2891
- function deriveAttentions(view, acked, nowIso) {
2957
+ var ATTENTION_PROPERTY = {
2958
+ /** 자리의 혼잡 판정 기준(%) — 점유/용량이 이 값을 넘으면 병목으로 본다. */
2959
+ congestionRatio: "attention.congestionRatio",
2960
+ /** 설비의 불량률 판정 기준(%) — 이 값을 넘으면 불량 과다로 본다. */
2961
+ scrapRate: "attention.scrapRate",
2962
+ /** 불량률을 재기 시작할 최소 표본 수 — 적은 표본의 비율은 판정 근거가 못 된다. */
2963
+ scrapMinSamples: "attention.scrapMinSamples"
2964
+ };
2965
+ var ATTENTION_DEFAULTS = { congestionPct: 90, scrapPct: 15, scrapMinSamples: 10 };
2966
+ function deriveAttentions(view, acked, nowIso, thresholds) {
2892
2967
  const out = [];
2893
2968
  for (const m of view.equipment) {
2894
2969
  if (m.status === "down") {
@@ -2909,14 +2984,24 @@ function deriveAttentions(view, acked, nowIso) {
2909
2984
  for (const n of view.locations) {
2910
2985
  if ((n.capacity ?? 0) > 0) {
2911
2986
  const r = (n.occupancy ?? 0) / n.capacity;
2912
- if (r >= 0.9) {
2987
+ const declaredPct = thresholds?.congestionPctOf?.(n.id);
2988
+ const thresholdPct = declaredPct ?? ATTENTION_DEFAULTS.congestionPct;
2989
+ if (r * 100 >= thresholdPct) {
2913
2990
  const saturated = r >= 1;
2914
2991
  out.push({
2915
2992
  id: `bottleneck:${n.id}`,
2916
2993
  kind: "bottleneck",
2917
2994
  severity: saturated ? "high" : "medium",
2918
2995
  anchor: { locationId: n.id },
2919
- params: { locationId: n.id, occupancy: n.occupancy ?? 0, capacity: n.capacity ?? 0, ratioPct: Math.round(r * 100), saturated: saturated ? 1 : 0 },
2996
+ params: {
2997
+ locationId: n.id,
2998
+ occupancy: n.occupancy ?? 0,
2999
+ capacity: n.capacity ?? 0,
3000
+ ratioPct: Math.round(r * 100),
3001
+ saturated: saturated ? 1 : 0,
3002
+ thresholdPct,
3003
+ basis: declaredPct === void 0 ? "default" : "declared"
3004
+ },
2920
3005
  recommendedActions: [{ code: "advice.add-resource" }, { code: "advice.downstream-priority" }]
2921
3006
  // 권고만(command 없음) — 표현계층이 advice.* 코드로 렌더
2922
3007
  });
@@ -2949,14 +3034,25 @@ function deriveAttentions(view, acked, nowIso) {
2949
3034
  }
2950
3035
  for (const m of view.equipment) {
2951
3036
  const total = (m.goodCount ?? 0) + (m.scrapCount ?? 0);
2952
- if (total >= 10) {
3037
+ const declaredMin = thresholds?.scrapMinSamplesOf?.(m.id);
3038
+ const minSamples = declaredMin ?? ATTENTION_DEFAULTS.scrapMinSamples;
3039
+ if (total >= minSamples) {
2953
3040
  const rate = (m.scrapCount ?? 0) / total;
2954
- if (rate >= 0.15) out.push({
3041
+ const declaredPct = thresholds?.scrapPctOf?.(m.id);
3042
+ const thresholdPct = declaredPct ?? ATTENTION_DEFAULTS.scrapPct;
3043
+ if (rate * 100 >= thresholdPct) out.push({
2955
3044
  id: `scrap:${m.id}`,
2956
3045
  kind: "scrap-high",
2957
- severity: rate >= 0.3 ? "high" : "medium",
3046
+ severity: rate * 100 >= thresholdPct * 2 ? "high" : "medium",
2958
3047
  anchor: { moverId: m.id, locationId: m.location },
2959
- params: { moverId: m.id, goodCount: m.goodCount ?? 0, scrapCount: m.scrapCount ?? 0, ratePct: Math.round(rate * 100) },
3048
+ params: {
3049
+ moverId: m.id,
3050
+ goodCount: m.goodCount ?? 0,
3051
+ scrapCount: m.scrapCount ?? 0,
3052
+ ratePct: Math.round(rate * 100),
3053
+ thresholdPct,
3054
+ basis: declaredPct === void 0 ? "default" : "declared"
3055
+ },
2960
3056
  recommendedActions: [
2961
3057
  { code: "act.hold-for-inspection", command: CMD.resourceHold, args: { resourceId: m.id } },
2962
3058
  { code: "act.reset-metrics", command: CMD.resourceResetMetrics, args: { resourceId: m.id } }
@@ -3821,11 +3917,44 @@ var FlowEngine = class {
3821
3917
  tasks: [...this.tasks.values()]
3822
3918
  },
3823
3919
  this._acked,
3824
- now
3920
+ now,
3825
3921
  // 지연 판정의 "지금" — 관측 중이면 마지막으로 들은 시각이다(§nowMs)
3922
+ this.attentionThresholds()
3826
3923
  );
3827
3924
  return out;
3828
3925
  }
3926
+ /**
3927
+ * 현장이 선언한 판정 기준을 모델에서 뽑는다 — **규칙은 한 곳**(순수 함수는 모델을 읽지 않는다).
3928
+ *
3929
+ * 자리·설비의 속성에서 읽고, 없으면 `undefined` 를 돌려 기본값이 쓰이게 한다(그 사실은 판정 층이
3930
+ * `basis` 로 밝힌다). 값이 수가 아니거나 범위를 벗어나면 **없는 것으로 본다** — 사람이 손으로 넣는
3931
+ * 값이라 잘못 들어올 수 있고, 잘못된 기준으로 판정하는 것은 기본값보다 나쁘다.
3932
+ */
3933
+ attentionThresholds() {
3934
+ const pct = (props, id) => {
3935
+ const raw = props?.find((p) => p.id === id)?.value;
3936
+ if (raw === void 0) return void 0;
3937
+ const n = Number(raw);
3938
+ return Number.isFinite(n) && n > 0 && n <= 100 ? n : void 0;
3939
+ };
3940
+ const count = (props, id) => {
3941
+ const raw = props?.find((p) => p.id === id)?.value;
3942
+ if (raw === void 0) return void 0;
3943
+ const n = Number(raw);
3944
+ return Number.isFinite(n) && n >= 1 ? Math.floor(n) : void 0;
3945
+ };
3946
+ const locProps = new Map(
3947
+ (this.boardDef?.locations ?? []).map((l) => [l.id, l.properties])
3948
+ );
3949
+ const eqProps = new Map(
3950
+ readBoardEquipment(this.boardDef ?? {}).map((e) => [e.id, e.properties])
3951
+ );
3952
+ return {
3953
+ congestionPctOf: (id) => pct(locProps.get(id), ATTENTION_PROPERTY.congestionRatio),
3954
+ scrapPctOf: (id) => pct(eqProps.get(id), ATTENTION_PROPERTY.scrapRate),
3955
+ scrapMinSamplesOf: (id) => count(eqProps.get(id), ATTENTION_PROPERTY.scrapMinSamples)
3956
+ };
3957
+ }
3829
3958
  /**
3830
3959
  * fork — 현재 상태를 정확히 복제한 새 엔진 (디지털트윈 본연: "현재로부터 예측").
3831
3960
  * 원본(live/sim)은 계속 진행, fork 는 what-if 를 앞으로 시뮬레이션해 forecast·발산(predicted vs actual) 검사에 쓴다.
@@ -7111,6 +7240,8 @@ function retiredVocabularyIn(line) {
7111
7240
  }
7112
7241
  // Annotate the CommonJS export names for ESM import in node:
7113
7242
  0 && (module.exports = {
7243
+ ATTENTION_DEFAULTS,
7244
+ ATTENTION_PROPERTY,
7114
7245
  BIZSTEP,
7115
7246
  BTT,
7116
7247
  BTT_DELIVERY,
@@ -7236,7 +7367,9 @@ function retiredVocabularyIn(line) {
7236
7367
  relationsFrom,
7237
7368
  relationsTo,
7238
7369
  replay,
7370
+ replayFrom,
7239
7371
  replaySegments,
7372
+ replayWithCheckpoint,
7240
7373
  requiredTestsFor,
7241
7374
  retiredVocabularyIn,
7242
7375
  sgtinClass,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.26",
3
+ "version": "0.7.28",
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": {