@operato/twin-kernel 0.7.27 → 0.7.29

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.
@@ -2,7 +2,7 @@ import type { TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, Effectiv
2
2
  import type { EpcisEvent, BizTransactionElement } from './epcis.ts';
3
3
  import type { AllocationPolicy, SlotView } from './allocation-policy.ts';
4
4
  import type { DurationEstimator, DurationContext } from './duration-estimator.ts';
5
- import type { OperationDef } from './domain-definition.ts';
5
+ import type { OperationDef, IsoDuration } from './domain-definition.ts';
6
6
  import { type CapacityAnalysis } from './capacity.ts';
7
7
  import { type CommittedDemand, type OperationsCapabilityReport } from './operations-capability.ts';
8
8
  export interface FlowLocation {
@@ -210,6 +210,51 @@ 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
+ * 공정에 선언할 수 있는 것의 **어휘** — 지금은 소요시간 하나다(`declareDurations` 가 읽는다).
242
+ *
243
+ * 자리·설비의 속성처럼 「속성 id」로 부른다: 선언을 받는 문·화면·AI 가 같은 이름을 써야 하고, 그 이름이
244
+ * 두 곳에 적히면 한쪽만 바뀌는 순간 값이 조용히 버려진다.
245
+ *
246
+ * 이 값이 얹히는 대상은 **자리·설비가 아니라 공정 종류**다(`FlowTask.kind` = `OperationDef.key`).
247
+ */
248
+ export declare const OPERATION_PROPERTY: {
249
+ /** 그 현장에서 이 공정이 걸리는 시간 — 실측이 없을 때 상수를 대신한다. */
250
+ readonly duration: "operation.duration";
251
+ };
252
+ /** 판정에 쓸 기준 — 대상별 선언이 있으면 그것, 없으면 기본값(그 사실을 함께 든다). */
253
+ export interface AttentionThresholds {
254
+ congestionPctOf?: (locationId: string) => number | undefined;
255
+ scrapPctOf?: (equipmentId: string) => number | undefined;
256
+ scrapMinSamplesOf?: (equipmentId: string) => number | undefined;
257
+ }
213
258
  export declare function deriveAttentions(view: {
214
259
  equipment: {
215
260
  id: string;
@@ -243,7 +288,13 @@ export declare function deriveAttentions(view: {
243
288
  }[];
244
289
  }, acked?: ReadonlySet<string>,
245
290
  /** 지금(ISO) — 납기 판정에 필요하다. **주지 않으면 지연을 판정하지 않는다**(모르면 판단하지 않는다). */
246
- nowIso?: ISOTime): Attention[];
291
+ nowIso?: ISOTime,
292
+ /**
293
+ * 현장이 선언한 판정 기준 — 주지 않으면 기본값으로 판정하고 그 사실을 `params.basis` 로 밝힌다.
294
+ *
295
+ * 부르는 쪽이 모델에서 뽑아 넘긴다(이 함수는 순수하게 남는다 — 모델을 읽는 규칙이 두 곳이 되지 않게).
296
+ */
297
+ thresholds?: AttentionThresholds): Attention[];
247
298
  /** OEE 계측 카운터 — sim 은 tick 으로 누적, live 는 실 텔레메트리 또는 이벤트 누적기가 채운다(face2-inbound-live §1.1). */
248
299
  export interface OeeCounters {
249
300
  runMs: number;
@@ -308,6 +359,13 @@ export declare abstract class FlowEngine implements TwinKernel {
308
359
  * 도메인 정의에서 실어 온다(`loadOperations`). 없으면 커널 기본값을 쓰고 그 사실을 `specCoverage()` 가 밝힌다.
309
360
  */
310
361
  protected operationSpecs: Map<string, OperationDef>;
362
+ /**
363
+ * 현장이 선언한 소요시간(작업 종류 → ms) — `declareDurations` 로 들어온다.
364
+ *
365
+ * 명세 행과 **따로** 두는 이유: 행이 없는 종류에도 시간을 줄 수 있어야 하고(창고·야드 트윈에는 행이
366
+ * 없다), 행을 지어 만들면 지어낸 `intent` 가 능력 계산까지 오염시킨다.
367
+ */
368
+ protected localDurations: Map<string, number>;
311
369
  /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
312
370
  private observer?;
313
371
  /** 관측분이 아직 커널 상태로 옮겨지지 않았다 — 스냅샷·fork 직전에 한 번만 옮긴다. */
@@ -491,6 +549,14 @@ export declare abstract class FlowEngine implements TwinKernel {
491
549
  getSnapshot(): StateSnapshot;
492
550
  protected computeAttentions(): Attention[];
493
551
  protected collectAttentions(): Attention[];
552
+ /**
553
+ * 현장이 선언한 판정 기준을 모델에서 뽑는다 — **규칙은 한 곳**(순수 함수는 모델을 읽지 않는다).
554
+ *
555
+ * 자리·설비의 속성에서 읽고, 없으면 `undefined` 를 돌려 기본값이 쓰이게 한다(그 사실은 판정 층이
556
+ * `basis` 로 밝힌다). 값이 수가 아니거나 범위를 벗어나면 **없는 것으로 본다** — 사람이 손으로 넣는
557
+ * 값이라 잘못 들어올 수 있고, 잘못된 기준으로 판정하는 것은 기본값보다 나쁘다.
558
+ */
559
+ protected attentionThresholds(): AttentionThresholds;
494
560
  /**
495
561
  * fork — 현재 상태를 정확히 복제한 새 엔진 (디지털트윈 본연: "현재로부터 예측").
496
562
  * 원본(live/sim)은 계속 진행, fork 는 what-if 를 앞으로 시뮬레이션해 forecast·발산(predicted vs actual) 검사에 쓴다.
@@ -626,6 +692,27 @@ export declare abstract class FlowEngine implements TwinKernel {
626
692
  */
627
693
  protected declaredOperations(): readonly OperationDef[];
628
694
  loadOperations(ops?: OperationDef[]): void;
695
+ /**
696
+ * **현장이 정한 소요시간** — 종류별로 시간만 받는다(2026-08-18).
697
+ *
698
+ * ── 왜 명세 행과 따로 받나 ──────────────────────────────────────────────────
699
+ * 창고·야드 트윈에는 오퍼레이션 명세 행이 **아예 없다**(실측: 35개 트윈 중 WMS·YMS 전부 0개). 그런데
700
+ * 그 트윈들도 `putaway`·`pick`·`spot`·`dwell` 을 돌리고, 시간은 커널 상수에서 온다 — 그 현장이 실제로
701
+ * 몇 분 걸리는지 말할 문이 없었다.
702
+ *
703
+ * 명세 행을 지어 만들 수는 없다: `OperationDef` 는 `label`·`intent` 를 요구하고(ISA-95
704
+ * `OperationsSegment`), 그 둘은 **지어내면 다른 답까지 오염시킨다**(`capacity()`·능력 보고가 의도별로
705
+ * 자원을 센다). 「몇 분 걸리나」를 말하려고 「무슨 종류의 작업인가」를 발명하지 않는다.
706
+ *
707
+ * ── 순서: 실측 > 현장 선언 > 원천 명세 > 상수 ──────────────────────────────
708
+ * 이력에서 배운 값이 가장 강하고(그 현장이 실제로 그랬다), 그다음이 **이 선언**이다 — 원천 명세보다
709
+ * 이긴다(원천 사본의 값이 낡았을 때 고칠 자리가 여기다). 무엇을 썼는지는 `specCoverage()` 가 밝힌다.
710
+ *
711
+ * ── 조용히 버리지 않는다 ────────────────────────────────────────────────────
712
+ * 읽을 수 없는 값은 던진다. 받아 두고 무시하면 화면은 「넣었습니다」라고 말하고 시뮬은 상수로 도는데,
713
+ * 그 어긋남을 아무도 볼 수 없다(이 시스템에서 가장 비싼 종류의 침묵이다).
714
+ */
715
+ declareDurations(durations: Record<string, IsoDuration> | undefined | null): void;
629
716
  /**
630
717
  * 라우트(공정 순서) — 수율을 거슬러 올릴 때 필요하다. 기본은 모른다(선언 순서를 쓴다).
631
718
  * 생산 정의를 가진 커널이 override 해서 자기 라우트를 답한다.
@@ -680,10 +767,12 @@ export declare abstract class FlowEngine implements TwinKernel {
680
767
  */
681
768
  protected committedDemands(): CommittedDemand[];
682
769
  /**
683
- * task 소요 산출 — 우선순위: **① 추정기(이력 보정) → ② 명세(ISA-95 Duration + 변동) → ③ 도메인 상수.**
770
+ * task 소요 산출 — 우선순위: **① 추정기(이력 보정) → ② 현장 선언(`declareDurations`) →
771
+ * ③ 명세(ISA-95 Duration + 변동) → ④ 도메인 상수.**
684
772
  *
685
773
  * 이 순서인 이유: 실측에서 배운 값이 선언값을 이기고, 선언값이 우리가 코드에 고정한 상수를 이긴다.
686
- * 무엇을 썼는지는 `specCoverage()` 드러낸다 상수를 것이 조용히 넘어가지 않게.
774
+ * 선언이 둘로 갈리는 이유는 권위다 **현장이 정한 값**이 원천이 그려 명세를 이긴다(ADR-0034).
775
+ * 무엇을 썼는지는 `specCoverage()` 로 드러낸다 — 상수를 쓴 것이 조용히 넘어가지 않게.
687
776
  * "얼마"만 소비하고 "경로"는 씬이 소유한다(좌표-free 유지).
688
777
  */
689
778
  protected durationOf(ctx: DurationContext, fallbackMs: number): number;
@@ -44,9 +44,50 @@ 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 };
70
+ /**
71
+ * 공정에 선언할 수 있는 것의 **어휘** — 지금은 소요시간 하나다(`declareDurations` 가 읽는다).
72
+ *
73
+ * 자리·설비의 속성처럼 「속성 id」로 부른다: 선언을 받는 문·화면·AI 가 같은 이름을 써야 하고, 그 이름이
74
+ * 두 곳에 적히면 한쪽만 바뀌는 순간 값이 조용히 버려진다.
75
+ *
76
+ * 이 값이 얹히는 대상은 **자리·설비가 아니라 공정 종류**다(`FlowTask.kind` = `OperationDef.key`).
77
+ */
78
+ export const OPERATION_PROPERTY = {
79
+ /** 그 현장에서 이 공정이 걸리는 시간 — 실측이 없을 때 상수를 대신한다. */
80
+ duration: 'operation.duration'
81
+ };
47
82
  export function deriveAttentions(view, acked,
48
83
  /** 지금(ISO) — 납기 판정에 필요하다. **주지 않으면 지연을 판정하지 않는다**(모르면 판단하지 않는다). */
49
- nowIso) {
84
+ nowIso,
85
+ /**
86
+ * 현장이 선언한 판정 기준 — 주지 않으면 기본값으로 판정하고 그 사실을 `params.basis` 로 밝힌다.
87
+ *
88
+ * 부르는 쪽이 모델에서 뽑아 넘긴다(이 함수는 순수하게 남는다 — 모델을 읽는 규칙이 두 곳이 되지 않게).
89
+ */
90
+ thresholds) {
50
91
  // 언어 중립: kind + params(원시값) + 조치 code 만 방출. 사람이 읽는 title/detail/rationale/라벨은
51
92
  // 표현계층(클라 i18next)이 kind/code 로 렌더한다(무방언·다국어 — design/plans/i18n.md L3).
52
93
  const out = [];
@@ -67,12 +108,19 @@ nowIso) {
67
108
  for (const n of view.locations) {
68
109
  if ((n.capacity ?? 0) > 0) {
69
110
  const r = (n.occupancy ?? 0) / n.capacity;
70
- if (r >= 0.9) {
111
+ /* 기준은 현장이 정한다 — 없으면 기본값이고, 어느 쪽인지 함께 낸다(조용히 기본값을 진실로 두지 않는다). */
112
+ const declaredPct = thresholds?.congestionPctOf?.(n.id);
113
+ const thresholdPct = declaredPct ?? ATTENTION_DEFAULTS.congestionPct;
114
+ if (r * 100 >= thresholdPct) {
71
115
  const saturated = r >= 1;
72
116
  out.push({
73
117
  id: `bottleneck:${n.id}`, kind: 'bottleneck', severity: saturated ? 'high' : 'medium',
74
118
  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 },
119
+ params: {
120
+ locationId: n.id, occupancy: n.occupancy ?? 0, capacity: n.capacity ?? 0, ratioPct: Math.round(r * 100),
121
+ saturated: saturated ? 1 : 0,
122
+ thresholdPct, basis: declaredPct === undefined ? 'default' : 'declared'
123
+ },
76
124
  recommendedActions: [{ code: 'advice.add-resource' }, { code: 'advice.downstream-priority' }]
77
125
  // 권고만(command 없음) — 표현계층이 advice.* 코드로 렌더
78
126
  });
@@ -130,13 +178,21 @@ nowIso) {
130
178
  }
131
179
  for (const m of view.equipment) {
132
180
  const total = (m.goodCount ?? 0) + (m.scrapCount ?? 0);
133
- if (total >= 10) {
181
+ const declaredMin = thresholds?.scrapMinSamplesOf?.(m.id);
182
+ const minSamples = declaredMin ?? ATTENTION_DEFAULTS.scrapMinSamples;
183
+ if (total >= minSamples) {
134
184
  const rate = (m.scrapCount ?? 0) / total;
135
- if (rate >= 0.15)
185
+ const declaredPct = thresholds?.scrapPctOf?.(m.id);
186
+ const thresholdPct = declaredPct ?? ATTENTION_DEFAULTS.scrapPct;
187
+ /* 「높음」은 기준의 두 배에서 — 기준이 현장의 것이면 그 두 배도 현장의 것이다(상수 두 벌을 두지 않는다). */
188
+ if (rate * 100 >= thresholdPct)
136
189
  out.push({
137
- id: `scrap:${m.id}`, kind: 'scrap-high', severity: rate >= 0.3 ? 'high' : 'medium',
190
+ id: `scrap:${m.id}`, kind: 'scrap-high', severity: rate * 100 >= thresholdPct * 2 ? 'high' : 'medium',
138
191
  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) },
192
+ params: {
193
+ moverId: m.id, goodCount: m.goodCount ?? 0, scrapCount: m.scrapCount ?? 0, ratePct: Math.round(rate * 100),
194
+ thresholdPct, basis: declaredPct === undefined ? 'default' : 'declared'
195
+ },
140
196
  recommendedActions: [
141
197
  { code: 'act.hold-for-inspection', command: CMD.resourceHold, args: { resourceId: m.id } },
142
198
  { code: 'act.reset-metrics', command: CMD.resourceResetMetrics, args: { resourceId: m.id } }
@@ -267,6 +323,13 @@ export class FlowEngine {
267
323
  * 도메인 정의에서 실어 온다(`loadOperations`). 없으면 커널 기본값을 쓰고 그 사실을 `specCoverage()` 가 밝힌다.
268
324
  */
269
325
  operationSpecs = new Map();
326
+ /**
327
+ * 현장이 선언한 소요시간(작업 종류 → ms) — `declareDurations` 로 들어온다.
328
+ *
329
+ * 명세 행과 **따로** 두는 이유: 행이 없는 종류에도 시간을 줄 수 있어야 하고(창고·야드 트윈에는 행이
330
+ * 없다), 행을 지어 만들면 지어낸 `intent` 가 능력 계산까지 오염시킨다.
331
+ */
332
+ localDurations = new Map();
270
333
  /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
271
334
  observer;
272
335
  /** 관측분이 아직 커널 상태로 옮겨지지 않았다 — 스냅샷·fork 직전에 한 번만 옮긴다. */
@@ -1105,10 +1168,40 @@ export class FlowEngine {
1105
1168
  locations: [...this.locations.values()],
1106
1169
  orders: [...this.orders.values()],
1107
1170
  tasks: [...this.tasks.values()]
1108
- }, this._acked, now // 지연 판정의 "지금" — 관측 중이면 마지막으로 들은 시각이다(§nowMs)
1109
- );
1171
+ }, this._acked, now, // 지연 판정의 "지금" — 관측 중이면 마지막으로 들은 시각이다(§nowMs)
1172
+ this.attentionThresholds());
1110
1173
  return out;
1111
1174
  }
1175
+ /**
1176
+ * 현장이 선언한 판정 기준을 모델에서 뽑는다 — **규칙은 한 곳**(순수 함수는 모델을 읽지 않는다).
1177
+ *
1178
+ * 자리·설비의 속성에서 읽고, 없으면 `undefined` 를 돌려 기본값이 쓰이게 한다(그 사실은 판정 층이
1179
+ * `basis` 로 밝힌다). 값이 수가 아니거나 범위를 벗어나면 **없는 것으로 본다** — 사람이 손으로 넣는
1180
+ * 값이라 잘못 들어올 수 있고, 잘못된 기준으로 판정하는 것은 기본값보다 나쁘다.
1181
+ */
1182
+ attentionThresholds() {
1183
+ const pct = (props, id) => {
1184
+ const raw = props?.find(p => p.id === id)?.value;
1185
+ if (raw === undefined)
1186
+ return undefined;
1187
+ const n = Number(raw);
1188
+ return Number.isFinite(n) && n > 0 && n <= 100 ? n : undefined;
1189
+ };
1190
+ const count = (props, id) => {
1191
+ const raw = props?.find(p => p.id === id)?.value;
1192
+ if (raw === undefined)
1193
+ return undefined;
1194
+ const n = Number(raw);
1195
+ return Number.isFinite(n) && n >= 1 ? Math.floor(n) : undefined;
1196
+ };
1197
+ const locProps = new Map((this.boardDef?.locations ?? []).map(l => [l.id, l.properties]));
1198
+ const eqProps = new Map(readBoardEquipment(this.boardDef ?? {}).map(e => [e.id, e.properties]));
1199
+ return {
1200
+ congestionPctOf: id => pct(locProps.get(id), ATTENTION_PROPERTY.congestionRatio),
1201
+ scrapPctOf: id => pct(eqProps.get(id), ATTENTION_PROPERTY.scrapRate),
1202
+ scrapMinSamplesOf: id => count(eqProps.get(id), ATTENTION_PROPERTY.scrapMinSamples)
1203
+ };
1204
+ }
1112
1205
  /**
1113
1206
  * fork — 현재 상태를 정확히 복제한 새 엔진 (디지털트윈 본연: "현재로부터 예측").
1114
1207
  * 원본(live/sim)은 계속 진행, fork 는 what-if 를 앞으로 시뮬레이션해 forecast·발산(predicted vs actual) 검사에 쓴다.
@@ -1351,6 +1444,39 @@ export class FlowEngine {
1351
1444
  if (o?.key)
1352
1445
  this.operationSpecs.set(o.key, o);
1353
1446
  }
1447
+ /**
1448
+ * **현장이 정한 소요시간** — 종류별로 시간만 받는다(2026-08-18).
1449
+ *
1450
+ * ── 왜 명세 행과 따로 받나 ──────────────────────────────────────────────────
1451
+ * 창고·야드 트윈에는 오퍼레이션 명세 행이 **아예 없다**(실측: 35개 트윈 중 WMS·YMS 전부 0개). 그런데
1452
+ * 그 트윈들도 `putaway`·`pick`·`spot`·`dwell` 을 돌리고, 시간은 커널 상수에서 온다 — 그 현장이 실제로
1453
+ * 몇 분 걸리는지 말할 문이 없었다.
1454
+ *
1455
+ * 명세 행을 지어 만들 수는 없다: `OperationDef` 는 `label`·`intent` 를 요구하고(ISA-95
1456
+ * `OperationsSegment`), 그 둘은 **지어내면 다른 답까지 오염시킨다**(`capacity()`·능력 보고가 의도별로
1457
+ * 자원을 센다). 「몇 분 걸리나」를 말하려고 「무슨 종류의 작업인가」를 발명하지 않는다.
1458
+ *
1459
+ * ── 순서: 실측 > 현장 선언 > 원천 명세 > 상수 ──────────────────────────────
1460
+ * 이력에서 배운 값이 가장 강하고(그 현장이 실제로 그랬다), 그다음이 **이 선언**이다 — 원천 명세보다
1461
+ * 이긴다(원천 사본의 값이 낡았을 때 고칠 자리가 여기다). 무엇을 썼는지는 `specCoverage()` 가 밝힌다.
1462
+ *
1463
+ * ── 조용히 버리지 않는다 ────────────────────────────────────────────────────
1464
+ * 읽을 수 없는 값은 던진다. 받아 두고 무시하면 화면은 「넣었습니다」라고 말하고 시뮬은 상수로 도는데,
1465
+ * 그 어긋남을 아무도 볼 수 없다(이 시스템에서 가장 비싼 종류의 침묵이다).
1466
+ */
1467
+ declareDurations(durations) {
1468
+ for (const [kind, text] of Object.entries(durations ?? {})) {
1469
+ const key = String(kind ?? '').trim();
1470
+ if (!key)
1471
+ throw new Error('declareDurations: an operation kind is empty — say which operation the duration belongs to');
1472
+ const ms = parseIsoDuration(text);
1473
+ if (ms === undefined)
1474
+ throw new Error(`declareDurations: "${text}" is not an ISO 8601 duration (operation "${key}") — the value would be dropped without a trace`);
1475
+ if (ms <= 0)
1476
+ throw new Error(`declareDurations: operation "${key}" cannot take ${ms}ms — a task with no duration never finishes`);
1477
+ this.localDurations.set(key, ms);
1478
+ }
1479
+ }
1354
1480
  /**
1355
1481
  * 라우트(공정 순서) — 수율을 거슬러 올릴 때 필요하다. 기본은 모른다(선언 순서를 쓴다).
1356
1482
  * 생산 정의를 가진 커널이 override 해서 자기 라우트를 답한다.
@@ -1454,10 +1580,12 @@ export class FlowEngine {
1454
1580
  return out;
1455
1581
  }
1456
1582
  /**
1457
- * task 소요 산출 — 우선순위: **① 추정기(이력 보정) → ② 명세(ISA-95 Duration + 변동) → ③ 도메인 상수.**
1583
+ * task 소요 산출 — 우선순위: **① 추정기(이력 보정) → ② 현장 선언(`declareDurations`) →
1584
+ * ③ 명세(ISA-95 Duration + 변동) → ④ 도메인 상수.**
1458
1585
  *
1459
1586
  * 이 순서인 이유: 실측에서 배운 값이 선언값을 이기고, 선언값이 우리가 코드에 고정한 상수를 이긴다.
1460
- * 무엇을 썼는지는 `specCoverage()` 드러낸다 상수를 것이 조용히 넘어가지 않게.
1587
+ * 선언이 둘로 갈리는 이유는 권위다 **현장이 정한 값**이 원천이 그려 명세를 이긴다(ADR-0034).
1588
+ * 무엇을 썼는지는 `specCoverage()` 로 드러낸다 — 상수를 쓴 것이 조용히 넘어가지 않게.
1461
1589
  * "얼마"만 소비하고 "경로"는 씬이 소유한다(좌표-free 유지).
1462
1590
  */
1463
1591
  durationOf(ctx, fallbackMs) {
@@ -1474,6 +1602,16 @@ export class FlowEngine {
1474
1602
  return this.sampleSpread(estimated.meanMs, estimated.spread);
1475
1603
  }
1476
1604
  const spec = this.operationSpecs.get(ctx.kind);
1605
+ /*
1606
+ * **현장이 정한 시간이 원천 명세를 이긴다** — 원천 사본의 값이 낡았을 때 고칠 자리가 그것뿐이다
1607
+ * (ADR-0034: 우리가 정한 값이 원천이 그려 준 값을 이긴다). 변동은 명세 행이 말한 것을 그대로 쓴다 —
1608
+ * 평균만 우리가 고쳤다고 퍼짐을 0 으로 만들면 줄이 사라진다.
1609
+ */
1610
+ const local = this.localDurations.get(ctx.kind);
1611
+ if (local !== undefined) {
1612
+ this.noteSpecUse(ctx.kind, 'declared');
1613
+ return this.applyVariability(local, spec?.variability);
1614
+ }
1477
1615
  const declared = parseIsoDuration(spec?.duration);
1478
1616
  if (declared === undefined) {
1479
1617
  this.noteSpecUse(ctx.kind, 'default');
@@ -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,
@@ -53,6 +55,7 @@ __export(index_exports, {
53
55
  MES_PRODUCT_GTINS: () => MES_PRODUCT_GTINS,
54
56
  MES_TYPES: () => MES_TYPES,
55
57
  MesKernel: () => MesKernel,
58
+ OPERATION_PROPERTY: () => OPERATION_PROPERTY,
56
59
  OP_EVENT: () => OP_EVENT,
57
60
  OP_PARAM: () => OP_PARAM,
58
61
  ObservedReducer: () => ObservedReducer,
@@ -2952,7 +2955,20 @@ function mulberry32(seed) {
2952
2955
  } });
2953
2956
  return fn;
2954
2957
  }
2955
- function deriveAttentions(view, acked, nowIso) {
2958
+ var ATTENTION_PROPERTY = {
2959
+ /** 자리의 혼잡 판정 기준(%) — 점유/용량이 이 값을 넘으면 병목으로 본다. */
2960
+ congestionRatio: "attention.congestionRatio",
2961
+ /** 설비의 불량률 판정 기준(%) — 이 값을 넘으면 불량 과다로 본다. */
2962
+ scrapRate: "attention.scrapRate",
2963
+ /** 불량률을 재기 시작할 최소 표본 수 — 적은 표본의 비율은 판정 근거가 못 된다. */
2964
+ scrapMinSamples: "attention.scrapMinSamples"
2965
+ };
2966
+ var ATTENTION_DEFAULTS = { congestionPct: 90, scrapPct: 15, scrapMinSamples: 10 };
2967
+ var OPERATION_PROPERTY = {
2968
+ /** 그 현장에서 이 공정이 걸리는 시간 — 실측이 없을 때 상수를 대신한다. */
2969
+ duration: "operation.duration"
2970
+ };
2971
+ function deriveAttentions(view, acked, nowIso, thresholds) {
2956
2972
  const out = [];
2957
2973
  for (const m of view.equipment) {
2958
2974
  if (m.status === "down") {
@@ -2973,14 +2989,24 @@ function deriveAttentions(view, acked, nowIso) {
2973
2989
  for (const n of view.locations) {
2974
2990
  if ((n.capacity ?? 0) > 0) {
2975
2991
  const r = (n.occupancy ?? 0) / n.capacity;
2976
- if (r >= 0.9) {
2992
+ const declaredPct = thresholds?.congestionPctOf?.(n.id);
2993
+ const thresholdPct = declaredPct ?? ATTENTION_DEFAULTS.congestionPct;
2994
+ if (r * 100 >= thresholdPct) {
2977
2995
  const saturated = r >= 1;
2978
2996
  out.push({
2979
2997
  id: `bottleneck:${n.id}`,
2980
2998
  kind: "bottleneck",
2981
2999
  severity: saturated ? "high" : "medium",
2982
3000
  anchor: { locationId: n.id },
2983
- params: { locationId: n.id, occupancy: n.occupancy ?? 0, capacity: n.capacity ?? 0, ratioPct: Math.round(r * 100), saturated: saturated ? 1 : 0 },
3001
+ params: {
3002
+ locationId: n.id,
3003
+ occupancy: n.occupancy ?? 0,
3004
+ capacity: n.capacity ?? 0,
3005
+ ratioPct: Math.round(r * 100),
3006
+ saturated: saturated ? 1 : 0,
3007
+ thresholdPct,
3008
+ basis: declaredPct === void 0 ? "default" : "declared"
3009
+ },
2984
3010
  recommendedActions: [{ code: "advice.add-resource" }, { code: "advice.downstream-priority" }]
2985
3011
  // 권고만(command 없음) — 표현계층이 advice.* 코드로 렌더
2986
3012
  });
@@ -3013,14 +3039,25 @@ function deriveAttentions(view, acked, nowIso) {
3013
3039
  }
3014
3040
  for (const m of view.equipment) {
3015
3041
  const total = (m.goodCount ?? 0) + (m.scrapCount ?? 0);
3016
- if (total >= 10) {
3042
+ const declaredMin = thresholds?.scrapMinSamplesOf?.(m.id);
3043
+ const minSamples = declaredMin ?? ATTENTION_DEFAULTS.scrapMinSamples;
3044
+ if (total >= minSamples) {
3017
3045
  const rate = (m.scrapCount ?? 0) / total;
3018
- if (rate >= 0.15) out.push({
3046
+ const declaredPct = thresholds?.scrapPctOf?.(m.id);
3047
+ const thresholdPct = declaredPct ?? ATTENTION_DEFAULTS.scrapPct;
3048
+ if (rate * 100 >= thresholdPct) out.push({
3019
3049
  id: `scrap:${m.id}`,
3020
3050
  kind: "scrap-high",
3021
- severity: rate >= 0.3 ? "high" : "medium",
3051
+ severity: rate * 100 >= thresholdPct * 2 ? "high" : "medium",
3022
3052
  anchor: { moverId: m.id, locationId: m.location },
3023
- params: { moverId: m.id, goodCount: m.goodCount ?? 0, scrapCount: m.scrapCount ?? 0, ratePct: Math.round(rate * 100) },
3053
+ params: {
3054
+ moverId: m.id,
3055
+ goodCount: m.goodCount ?? 0,
3056
+ scrapCount: m.scrapCount ?? 0,
3057
+ ratePct: Math.round(rate * 100),
3058
+ thresholdPct,
3059
+ basis: declaredPct === void 0 ? "default" : "declared"
3060
+ },
3024
3061
  recommendedActions: [
3025
3062
  { code: "act.hold-for-inspection", command: CMD.resourceHold, args: { resourceId: m.id } },
3026
3063
  { code: "act.reset-metrics", command: CMD.resourceResetMetrics, args: { resourceId: m.id } }
@@ -3131,6 +3168,13 @@ var FlowEngine = class {
3131
3168
  * 도메인 정의에서 실어 온다(`loadOperations`). 없으면 커널 기본값을 쓰고 그 사실을 `specCoverage()` 가 밝힌다.
3132
3169
  */
3133
3170
  operationSpecs = /* @__PURE__ */ new Map();
3171
+ /**
3172
+ * 현장이 선언한 소요시간(작업 종류 → ms) — `declareDurations` 로 들어온다.
3173
+ *
3174
+ * 명세 행과 **따로** 두는 이유: 행이 없는 종류에도 시간을 줄 수 있어야 하고(창고·야드 트윈에는 행이
3175
+ * 없다), 행을 지어 만들면 지어낸 `intent` 가 능력 계산까지 오염시킨다.
3176
+ */
3177
+ localDurations = /* @__PURE__ */ new Map();
3134
3178
  /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
3135
3179
  observer;
3136
3180
  /** 관측분이 아직 커널 상태로 옮겨지지 않았다 — 스냅샷·fork 직전에 한 번만 옮긴다. */
@@ -3885,11 +3929,44 @@ var FlowEngine = class {
3885
3929
  tasks: [...this.tasks.values()]
3886
3930
  },
3887
3931
  this._acked,
3888
- now
3932
+ now,
3889
3933
  // 지연 판정의 "지금" — 관측 중이면 마지막으로 들은 시각이다(§nowMs)
3934
+ this.attentionThresholds()
3890
3935
  );
3891
3936
  return out;
3892
3937
  }
3938
+ /**
3939
+ * 현장이 선언한 판정 기준을 모델에서 뽑는다 — **규칙은 한 곳**(순수 함수는 모델을 읽지 않는다).
3940
+ *
3941
+ * 자리·설비의 속성에서 읽고, 없으면 `undefined` 를 돌려 기본값이 쓰이게 한다(그 사실은 판정 층이
3942
+ * `basis` 로 밝힌다). 값이 수가 아니거나 범위를 벗어나면 **없는 것으로 본다** — 사람이 손으로 넣는
3943
+ * 값이라 잘못 들어올 수 있고, 잘못된 기준으로 판정하는 것은 기본값보다 나쁘다.
3944
+ */
3945
+ attentionThresholds() {
3946
+ const pct = (props, id) => {
3947
+ const raw = props?.find((p) => p.id === id)?.value;
3948
+ if (raw === void 0) return void 0;
3949
+ const n = Number(raw);
3950
+ return Number.isFinite(n) && n > 0 && n <= 100 ? n : void 0;
3951
+ };
3952
+ const count = (props, id) => {
3953
+ const raw = props?.find((p) => p.id === id)?.value;
3954
+ if (raw === void 0) return void 0;
3955
+ const n = Number(raw);
3956
+ return Number.isFinite(n) && n >= 1 ? Math.floor(n) : void 0;
3957
+ };
3958
+ const locProps = new Map(
3959
+ (this.boardDef?.locations ?? []).map((l) => [l.id, l.properties])
3960
+ );
3961
+ const eqProps = new Map(
3962
+ readBoardEquipment(this.boardDef ?? {}).map((e) => [e.id, e.properties])
3963
+ );
3964
+ return {
3965
+ congestionPctOf: (id) => pct(locProps.get(id), ATTENTION_PROPERTY.congestionRatio),
3966
+ scrapPctOf: (id) => pct(eqProps.get(id), ATTENTION_PROPERTY.scrapRate),
3967
+ scrapMinSamplesOf: (id) => count(eqProps.get(id), ATTENTION_PROPERTY.scrapMinSamples)
3968
+ };
3969
+ }
3893
3970
  /**
3894
3971
  * fork — 현재 상태를 정확히 복제한 새 엔진 (디지털트윈 본연: "현재로부터 예측").
3895
3972
  * 원본(live/sim)은 계속 진행, fork 는 what-if 를 앞으로 시뮬레이션해 forecast·발산(predicted vs actual) 검사에 쓴다.
@@ -4105,6 +4182,37 @@ var FlowEngine = class {
4105
4182
  loadOperations(ops = []) {
4106
4183
  for (const o of ops) if (o?.key) this.operationSpecs.set(o.key, o);
4107
4184
  }
4185
+ /**
4186
+ * **현장이 정한 소요시간** — 종류별로 시간만 받는다(2026-08-18).
4187
+ *
4188
+ * ── 왜 명세 행과 따로 받나 ──────────────────────────────────────────────────
4189
+ * 창고·야드 트윈에는 오퍼레이션 명세 행이 **아예 없다**(실측: 35개 트윈 중 WMS·YMS 전부 0개). 그런데
4190
+ * 그 트윈들도 `putaway`·`pick`·`spot`·`dwell` 을 돌리고, 시간은 커널 상수에서 온다 — 그 현장이 실제로
4191
+ * 몇 분 걸리는지 말할 문이 없었다.
4192
+ *
4193
+ * 명세 행을 지어 만들 수는 없다: `OperationDef` 는 `label`·`intent` 를 요구하고(ISA-95
4194
+ * `OperationsSegment`), 그 둘은 **지어내면 다른 답까지 오염시킨다**(`capacity()`·능력 보고가 의도별로
4195
+ * 자원을 센다). 「몇 분 걸리나」를 말하려고 「무슨 종류의 작업인가」를 발명하지 않는다.
4196
+ *
4197
+ * ── 순서: 실측 > 현장 선언 > 원천 명세 > 상수 ──────────────────────────────
4198
+ * 이력에서 배운 값이 가장 강하고(그 현장이 실제로 그랬다), 그다음이 **이 선언**이다 — 원천 명세보다
4199
+ * 이긴다(원천 사본의 값이 낡았을 때 고칠 자리가 여기다). 무엇을 썼는지는 `specCoverage()` 가 밝힌다.
4200
+ *
4201
+ * ── 조용히 버리지 않는다 ────────────────────────────────────────────────────
4202
+ * 읽을 수 없는 값은 던진다. 받아 두고 무시하면 화면은 「넣었습니다」라고 말하고 시뮬은 상수로 도는데,
4203
+ * 그 어긋남을 아무도 볼 수 없다(이 시스템에서 가장 비싼 종류의 침묵이다).
4204
+ */
4205
+ declareDurations(durations) {
4206
+ for (const [kind, text] of Object.entries(durations ?? {})) {
4207
+ const key = String(kind ?? "").trim();
4208
+ if (!key) throw new Error("declareDurations: an operation kind is empty \u2014 say which operation the duration belongs to");
4209
+ const ms2 = parseIsoDuration(text);
4210
+ if (ms2 === void 0)
4211
+ throw new Error(`declareDurations: "${text}" is not an ISO 8601 duration (operation "${key}") \u2014 the value would be dropped without a trace`);
4212
+ if (ms2 <= 0) throw new Error(`declareDurations: operation "${key}" cannot take ${ms2}ms \u2014 a task with no duration never finishes`);
4213
+ this.localDurations.set(key, ms2);
4214
+ }
4215
+ }
4108
4216
  /**
4109
4217
  * 라우트(공정 순서) — 수율을 거슬러 올릴 때 필요하다. 기본은 모른다(선언 순서를 쓴다).
4110
4218
  * 생산 정의를 가진 커널이 override 해서 자기 라우트를 답한다.
@@ -4206,10 +4314,12 @@ var FlowEngine = class {
4206
4314
  return out;
4207
4315
  }
4208
4316
  /**
4209
- * task 소요 산출 — 우선순위: **① 추정기(이력 보정) → ② 명세(ISA-95 Duration + 변동) → ③ 도메인 상수.**
4317
+ * task 소요 산출 — 우선순위: **① 추정기(이력 보정) → ② 현장 선언(`declareDurations`) →
4318
+ * ③ 명세(ISA-95 Duration + 변동) → ④ 도메인 상수.**
4210
4319
  *
4211
4320
  * 이 순서인 이유: 실측에서 배운 값이 선언값을 이기고, 선언값이 우리가 코드에 고정한 상수를 이긴다.
4212
- * 무엇을 썼는지는 `specCoverage()` 드러낸다 상수를 것이 조용히 넘어가지 않게.
4321
+ * 선언이 둘로 갈리는 이유는 권위다 **현장이 정한 값**이 원천이 그려 명세를 이긴다(ADR-0034).
4322
+ * 무엇을 썼는지는 `specCoverage()` 로 드러낸다 — 상수를 쓴 것이 조용히 넘어가지 않게.
4213
4323
  * "얼마"만 소비하고 "경로"는 씬이 소유한다(좌표-free 유지).
4214
4324
  */
4215
4325
  durationOf(ctx, fallbackMs) {
@@ -4223,6 +4333,11 @@ var FlowEngine = class {
4223
4333
  return this.sampleSpread(estimated.meanMs, estimated.spread);
4224
4334
  }
4225
4335
  const spec = this.operationSpecs.get(ctx.kind);
4336
+ const local = this.localDurations.get(ctx.kind);
4337
+ if (local !== void 0) {
4338
+ this.noteSpecUse(ctx.kind, "declared");
4339
+ return this.applyVariability(local, spec?.variability);
4340
+ }
4226
4341
  const declared = parseIsoDuration(spec?.duration);
4227
4342
  if (declared === void 0) {
4228
4343
  this.noteSpecUse(ctx.kind, "default");
@@ -7175,6 +7290,8 @@ function retiredVocabularyIn(line) {
7175
7290
  }
7176
7291
  // Annotate the CommonJS export names for ESM import in node:
7177
7292
  0 && (module.exports = {
7293
+ ATTENTION_DEFAULTS,
7294
+ ATTENTION_PROPERTY,
7178
7295
  BIZSTEP,
7179
7296
  BTT,
7180
7297
  BTT_DELIVERY,
@@ -7209,6 +7326,7 @@ function retiredVocabularyIn(line) {
7209
7326
  MES_PRODUCT_GTINS,
7210
7327
  MES_TYPES,
7211
7328
  MesKernel,
7329
+ OPERATION_PROPERTY,
7212
7330
  OP_EVENT,
7213
7331
  OP_PARAM,
7214
7332
  ObservedReducer,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.27",
3
+ "version": "0.7.29",
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": {