@operato/twin-kernel 0.7.31 → 0.7.32

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.
@@ -1770,6 +1770,17 @@ export interface TaskStatusDelta {
1770
1770
  remainingMs?: number;
1771
1771
  /** 이 작업의 총 소요 예상(ms) — 진척의 분모. */
1772
1772
  durationMs?: number;
1773
+ /**
1774
+ * 품질 판정 결과 — **판정이 있었던 작업만** (2026-08-19).
1775
+ *
1776
+ * 왜 계약에 있나: 양품/불량은 설비 누적 카운터(종류를 모른다)와 EPCIS disposition(종류를 모른다)
1777
+ * 에만 남아서, **「이 공정의 양품률이 얼마였나」를 저널에서 되짚을 수 없었다** — 재기동하면 종류별
1778
+ * 품질 이력이 통째로 사라졌다. 판정이 일어나는 자리(작업 완료)에서 사실을 적는다.
1779
+ *
1780
+ * **없음은 「양품」이 아니라 「판정하지 않았다」**다(이동·체류에는 품질 판정이 없다). 그래서 소비처는
1781
+ * 없는 것을 세지 않는다 — 모름을 양품으로 세면 양품률이 조용히 100% 로 올라간다.
1782
+ */
1783
+ outcome?: 'good' | 'scrap';
1773
1784
  /** 우선순위 — 나가지 않으면 미러가 배정 순서의 근거를 모른다(상태⊆이벤트). */
1774
1785
  priority?: number;
1775
1786
  startTime?: ISOTime;
@@ -174,6 +174,20 @@ export interface FlowTask {
174
174
  * dwell: 자리에서 시간만 소비(무자원 체류·큐어링 — 즉시 진행, 자원 불요).
175
175
  */
176
176
  intent?: 'transport' | 'process' | 'dwell';
177
+ /**
178
+ * 이 작업의 **품질 판정 결과** — 판정이 있었을 때만 채운다 (2026-08-19).
179
+ *
180
+ * ── 왜 작업에 적나 ────────────────────────────────────────────────────────
181
+ * 양품/불량은 지금까지 두 곳에만 남았다: 설비 누적 카운터(`goodCount`·`scrapCount` — 종류를 모른다)와
182
+ * EPCIS disposition(`sellable`/`non_sellable` — 종류를 모른다). 그래서 **「이 공정의 양품률이 얼마였나」**
183
+ * 를 되짚을 방법이 저널에 없었다 — 재기동하면 종류별 품질 이력이 통째로 사라진다.
184
+ *
185
+ * 판정이 일어나는 자리는 작업 완료다(그때 종류·자원·결과를 함께 안다). 그러니 그 자리에서 적는다 —
186
+ * 나중에 자리·업무단계로 종류를 되짚는 추론을 만들지 않는다(설비 둘이 한 자리를 쓰면 조용히 틀린다).
187
+ *
188
+ * **없음은 「양품」이 아니라 「판정하지 않았다」**다(이동·체류에는 품질 판정이 없다).
189
+ */
190
+ outcome?: 'good' | 'scrap';
177
191
  }
178
192
  /** 오더 라인(멀티SKU) — 라인별 품목·잔량. 단일 SKU 오더 = 1 라인. */
179
193
  export interface OrderLine {
@@ -245,6 +259,17 @@ export declare const ATTENTION_DEFAULTS: {
245
259
  *
246
260
  * 이 값이 얹히는 대상은 **자리·설비가 아니라 공정 종류**다(`FlowTask.kind` = `OperationDef.key`).
247
261
  */
262
+ /**
263
+ * 이력에서 배운 양품률 — 종류별 비율(0..1). **모르면 `undefined`**(0 을 답하지 않는다: 0 은 「전부 불량」이다).
264
+ *
265
+ * 소요시간 추정기와 같은 모양이다(무상태 시임, fork 가 참조를 공유). 표본이 모자란 종류는 추정기가
266
+ * 스스로 빼야 한다 — 두 건으로 만든 비율은 근거가 아니다.
267
+ */
268
+ export interface YieldEstimator {
269
+ estimate(ctx: {
270
+ kind: string;
271
+ }): number | undefined;
272
+ }
248
273
  export declare const OPERATION_PROPERTY: {
249
274
  /** 그 현장에서 이 공정이 걸리는 시간 — 실측이 없을 때 상수를 대신한다. */
250
275
  readonly duration: "operation.duration";
@@ -363,6 +388,13 @@ export declare abstract class FlowEngine implements TwinKernel {
363
388
  protected policy: AllocationPolicy;
364
389
  /** duration 시임(선택) — 미주입 시 명세, 명세도 없으면 도메인 상수. 이력 보정 추정기가 여기 들어온다. */
365
390
  durationEstimator?: DurationEstimator;
391
+ /**
392
+ * 양품률 시임(선택) — **이력에서 배운 수율.** 소요시간과 같은 자리·같은 규율이다 (2026-08-19).
393
+ *
394
+ * 없으면 선언값(현장→원천), 그것도 없으면 도메인 상수. 무엇을 썼는지는 `specCoverage()` 가 밝힌다.
395
+ * 주입은 호스트가 한다(저널을 접는 쪽이 이력을 안다) — 커널은 시임만 든다.
396
+ */
397
+ yieldEstimator?: YieldEstimator;
366
398
  /**
367
399
  * 오퍼레이션 명세(선택) — 작업 종류(`FlowTask.kind` = `OperationDef.key`) → 소요·변동·모수.
368
400
  * 도메인 정의에서 실어 온다(`loadOperations`). 없으면 커널 기본값을 쓰고 그 사실을 `specCoverage()` 가 밝힌다.
@@ -824,6 +856,16 @@ export declare abstract class FlowEngine implements TwinKernel {
824
856
  protected paramNumber(opKey: string, id: string): number | undefined;
825
857
  /** 명세 모수(기간) — ISO 8601 문자열을 밀리초로. 선언 없으면 undefined. */
826
858
  protected paramDuration(opKey: string, id: string): number | undefined;
859
+ /**
860
+ * 이 공정의 양품률 — **실측 > 현장 선언 > 원천 명세 > 도메인 상수** (2026-08-19).
861
+ *
862
+ * 소요시간(`durationOf`)과 같은 사다리다. 도메인 커널은 이 창구만 부르고 상수는 스스로 든다
863
+ * (`?? DEFAULT_YIELD`) — 상수를 쓴 사실은 `specCoverage()` 의 `parameterBasis` 가 밝힌다.
864
+ *
865
+ * 범위를 벗어난 실측은 쓰지 않는다: 비율이 아닌 값(0 미만·1 초과)은 이력 계산이 틀린 것이므로,
866
+ * 그것으로 시뮬을 굴리면 조용히 이상한 공장이 된다.
867
+ */
868
+ protected yieldOf(kind: string): number | undefined;
827
869
  /**
828
870
  * 모수 값 한 개 — **현장이 정한 것이 원천 명세를 이긴다**(소요시간과 같은 순서: ADR-0034).
829
871
  *
@@ -832,6 +874,12 @@ export declare abstract class FlowEngine implements TwinKernel {
832
874
  */
833
875
  private paramValue;
834
876
  private noteSpecUse;
877
+ /**
878
+ * 모수를 **무엇으로** 읽었나 — id 와 근거를 함께 적는다 (2026-08-19).
879
+ *
880
+ * 예전에는 id 만 세었다. 그래서 「수율을 읽었다」는 알 수 있어도 그것이 이력에서 배운 값인지 선언값인지
881
+ * 코드 상수인지 구별할 수 없었다 — 예측의 자격을 말하려면 그 구별이 필요하다(소요시간은 이미 구별한다).
882
+ */
835
883
  private noteParamUse;
836
884
  /**
837
885
  * 시뮬 명세 자기보고 — **어디까지 데이터로 말했고 어디부터 우리가 코드에 고정한 상수인가.**
@@ -845,6 +893,8 @@ export declare abstract class FlowEngine implements TwinKernel {
845
893
  duration: 'measured' | 'declared' | 'default';
846
894
  variability?: string;
847
895
  parameters: string[];
896
+ /** 그 모수를 무엇으로 읽었나(`id → measured|declared`) — 없는 id 는 상수를 썼다는 뜻이다. */
897
+ parameterBasis?: Record<string, 'measured' | 'declared'>;
848
898
  }[];
849
899
  /** 이력·계산에서 온 소요(추정기) — 선언값보다 강한 근거. */
850
900
  measuredDurations: number;
@@ -12,6 +12,7 @@
12
12
  import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure, capabilityOf, requiredTestsFor, priorityRank, dueStatusOf, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf } from "./contract.js";
13
13
  import { ObservedReducer } from "./observed-reducer.js";
14
14
  import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR, CBV_BIZSTEP } from "./epcis.js";
15
+ import { OP_PARAM } from "./domain-definition.js";
15
16
  import { parseIsoDuration } from "./iso-duration.js";
16
17
  import { analyzeCapacity } from "./capacity.js";
17
18
  import { operationsCapabilityOf } from "./operations-capability.js";
@@ -67,14 +68,6 @@ export const ATTENTION_PROPERTY = {
67
68
  };
68
69
  /** 선언이 없을 때 쓰는 기본값 — **드러내 둔다**(코드 안에 숨은 상수가 아니라 계약의 일부다). */
69
70
  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
71
  export const OPERATION_PROPERTY = {
79
72
  /** 그 현장에서 이 공정이 걸리는 시간 — 실측이 없을 때 상수를 대신한다. */
80
73
  duration: 'operation.duration',
@@ -327,6 +320,13 @@ export class FlowEngine {
327
320
  policy;
328
321
  /** duration 시임(선택) — 미주입 시 명세, 명세도 없으면 도메인 상수. 이력 보정 추정기가 여기 들어온다. */
329
322
  durationEstimator;
323
+ /**
324
+ * 양품률 시임(선택) — **이력에서 배운 수율.** 소요시간과 같은 자리·같은 규율이다 (2026-08-19).
325
+ *
326
+ * 없으면 선언값(현장→원천), 그것도 없으면 도메인 상수. 무엇을 썼는지는 `specCoverage()` 가 밝힌다.
327
+ * 주입은 호스트가 한다(저널을 접는 쪽이 이력을 안다) — 커널은 시임만 든다.
328
+ */
329
+ yieldEstimator;
330
330
  /**
331
331
  * 오퍼레이션 명세(선택) — 작업 종류(`FlowTask.kind` = `OperationDef.key`) → 소요·변동·모수.
332
332
  * 도메인 정의에서 실어 온다(`loadOperations`). 없으면 커널 기본값을 쓰고 그 사실을 `specCoverage()` 가 밝힌다.
@@ -1245,7 +1245,10 @@ export class FlowEngine {
1245
1245
  clone.durationEstimator = this.durationEstimator; // 무상태 시임 — 참조 공유(policy 와 동형)
1246
1246
  /* 명세는 불변(정의) → 참조 공유. 사용 기록은 fork 자기 것(무엇을 기본값으로 굴렸는지 fork 별로 다르다). */
1247
1247
  clone.operationSpecs = this.operationSpecs;
1248
- clone.specUse = new Map([...this.specUse.entries()].map(([k, u]) => [k, { duration: u.duration, variability: u.variability, params: new Set(u.params) }]));
1248
+ clone.specUse = new Map([...this.specUse.entries()].map(([k, u]) => [
1249
+ k,
1250
+ { duration: u.duration, variability: u.variability, params: new Set(u.params), paramBasis: new Map(u.paramBasis) }
1251
+ ]));
1249
1252
  return clone;
1250
1253
  }
1251
1254
  /**
@@ -1725,6 +1728,23 @@ export class FlowEngine {
1725
1728
  this.noteParamUse(opKey, id);
1726
1729
  return ms;
1727
1730
  }
1731
+ /**
1732
+ * 이 공정의 양품률 — **실측 > 현장 선언 > 원천 명세 > 도메인 상수** (2026-08-19).
1733
+ *
1734
+ * 소요시간(`durationOf`)과 같은 사다리다. 도메인 커널은 이 창구만 부르고 상수는 스스로 든다
1735
+ * (`?? DEFAULT_YIELD`) — 상수를 쓴 사실은 `specCoverage()` 의 `parameterBasis` 가 밝힌다.
1736
+ *
1737
+ * 범위를 벗어난 실측은 쓰지 않는다: 비율이 아닌 값(0 미만·1 초과)은 이력 계산이 틀린 것이므로,
1738
+ * 그것으로 시뮬을 굴리면 조용히 이상한 공장이 된다.
1739
+ */
1740
+ yieldOf(kind) {
1741
+ const measured = this.yieldEstimator?.estimate({ kind });
1742
+ if (typeof measured === 'number' && measured >= 0 && measured <= 1) {
1743
+ this.noteParamUse(kind, OP_PARAM.yield, 'measured');
1744
+ return measured;
1745
+ }
1746
+ return this.paramNumber(kind, OP_PARAM.yield);
1747
+ }
1728
1748
  /**
1729
1749
  * 모수 값 한 개 — **현장이 정한 것이 원천 명세를 이긴다**(소요시간과 같은 순서: ADR-0034).
1730
1750
  *
@@ -1745,14 +1765,22 @@ export class FlowEngine {
1745
1765
  cur.variability = variability;
1746
1766
  return;
1747
1767
  }
1748
- this.specUse.set(kind, { duration, ...(variability ? { variability } : {}), params: new Set() });
1768
+ this.specUse.set(kind, { duration, ...(variability ? { variability } : {}), params: new Set(), paramBasis: new Map() });
1749
1769
  }
1750
- noteParamUse(kind, id) {
1770
+ /**
1771
+ * 모수를 **무엇으로** 읽었나 — id 와 근거를 함께 적는다 (2026-08-19).
1772
+ *
1773
+ * 예전에는 id 만 세었다. 그래서 「수율을 읽었다」는 알 수 있어도 그것이 이력에서 배운 값인지 선언값인지
1774
+ * 코드 상수인지 구별할 수 없었다 — 예측의 자격을 말하려면 그 구별이 필요하다(소요시간은 이미 구별한다).
1775
+ */
1776
+ noteParamUse(kind, id, basis = 'declared') {
1751
1777
  const cur = this.specUse.get(kind);
1752
- if (cur)
1778
+ if (cur) {
1753
1779
  cur.params.add(id);
1754
- else
1755
- this.specUse.set(kind, { duration: 'default', params: new Set([id]) });
1780
+ cur.paramBasis.set(id, basis);
1781
+ return;
1782
+ }
1783
+ this.specUse.set(kind, { duration: 'default', params: new Set([id]), paramBasis: new Map([[id, basis]]) });
1756
1784
  }
1757
1785
  /**
1758
1786
  * 시뮬 명세 자기보고 — **어디까지 데이터로 말했고 어디부터 우리가 코드에 고정한 상수인가.**
@@ -1769,7 +1797,9 @@ export class FlowEngine {
1769
1797
  kind,
1770
1798
  duration: u.duration,
1771
1799
  ...(variability ? { variability } : {}),
1772
- parameters: [...u.params].sort()
1800
+ parameters: [...u.params].sort(),
1801
+ /* 모수를 **무엇으로** 읽었나 — 읽은 것이 있을 때만 낸다(빈 표를 만들지 않는다). */
1802
+ ...(u.paramBasis.size ? { parameterBasis: Object.fromEntries([...u.paramBasis.entries()].sort()) } : {})
1773
1803
  };
1774
1804
  });
1775
1805
  return {
@@ -2008,6 +2038,8 @@ export class FlowEngine {
2008
2038
  /* 실제 자재 이동 — 인원·설비와 같은 채널(실적을 한 곳에서 읽는다). */
2009
2039
  ...(t.materialActual?.length ? { materialActual: t.materialActual.map(r => ({ ...r })) } : {}),
2010
2040
  ...(t.durationMs ? { durationMs: t.durationMs } : {}),
2041
+ /* 품질 판정이 있었으면 **그 자리에서** 실어 보낸다 — 없으면 필드를 만들지 않는다(모름≠양품). */
2042
+ ...(t.outcome ? { outcome: t.outcome } : {}),
2011
2043
  ...(t.priority !== undefined ? { priority: t.priority } : {}),
2012
2044
  ...(t.startTime ? { startTime: t.startTime } : {}),
2013
2045
  ...(t.endTime ? { endTime: t.endTime } : {}),
@@ -229,7 +229,9 @@ export class MesKernel extends FlowEngine {
229
229
  // 마지막 스테이션(조립) → 완성차 (transform 1→1, disposition 으로 수율 loss → OEE 품질)
230
230
  const fgStore = this.locationByType('fg-store');
231
231
  const wip = order.allocated[0];
232
- const good = this.rng() < (this.paramNumber(t.kind, OP_PARAM.yield) ?? DEFAULT_YIELD);
232
+ const good = this.rng() < (this.yieldOf(t.kind) ?? DEFAULT_YIELD);
233
+ /* 판정을 **작업에 적는다** — 그 자리에서 적어야 저널에 종류와 함께 남고, 이력에서 수율을 배울 수 있다. */
234
+ t.outcome = good ? 'good' : 'scrap';
233
235
  this.recordOutput(t.resource, good); // OEE 품질(마지막 자원별 양품/불량)
234
236
  const disp = good ? DISP.sellable : DISP.non_sellable;
235
237
  const outputEpc = sgtinUri(CP, product.ref, ++this.prodSeq);
@@ -352,7 +354,8 @@ export class MesKernel extends FlowEngine {
352
354
  }
353
355
  const fgStore = this.locationByType('fg-store');
354
356
  const wip = order.allocated[0];
355
- const good = this.rng() < (this.paramNumber(t.kind, OP_PARAM.yield) ?? DEFAULT_YIELD);
357
+ const good = this.rng() < (this.yieldOf(t.kind) ?? DEFAULT_YIELD);
358
+ t.outcome = good ? 'good' : 'scrap'; // 같은 규율 — 판정은 일어난 자리에서 적는다
356
359
  this.recordOutput(t.resource, good);
357
360
  const disp = good ? DISP.sellable : DISP.non_sellable;
358
361
  const outEpc = this.serialOf(rc.outputs[0].material, ++this.prodSeq);
@@ -23,6 +23,16 @@ export interface TaskFacets {
23
23
  order?: string;
24
24
  /** 가장 최근 도착 지점. 진행에 따라 바뀌므로 마지막 것이 사실이다. */
25
25
  location?: string;
26
+ /**
27
+ * 품질 판정 결과 — **판정이 있었을 때만** (2026-08-19).
28
+ *
29
+ * 이 축이 없으면 「이 공정의 양품률이 얼마였나」를 저널에서 되짚을 수 없다: 양품/불량은 설비 누적
30
+ * 카운터(종류 모름)와 EPCIS disposition(종류 모름)에만 남아 있었다. 판정은 작업 완료에서 일어나므로
31
+ * 그 자리에서 적고, 여기서 그대로 접는다.
32
+ *
33
+ * **없음은 「양품」이 아니라 「판정하지 않았다」**다(이동·체류에는 품질 판정이 없다).
34
+ */
35
+ outcome?: 'good' | 'scrap';
26
36
  }
27
37
  /** 작업 하나의 이정표 — 소비처가 이것으로 구간·지표를 만든다. */
28
38
  export interface TaskRecord {
package/dist/task-fold.js CHANGED
@@ -46,6 +46,9 @@ export function foldTaskRecords(rows) {
46
46
  f.kind = d.kind;
47
47
  if (f.order === undefined && d.orderId)
48
48
  f.order = d.orderId;
49
+ /* 품질 판정은 완료 전이에 실려 온다 — 한 번 실린 값은 바뀌지 않는다(정정이 오면 마지막 것이 사실). */
50
+ if (d.outcome === 'good' || d.outcome === 'scrap')
51
+ f.outcome = d.outcome;
49
52
  const loc = d.toNode ?? d.fromNode;
50
53
  if (loc)
51
54
  f.location = loc;
@@ -2630,6 +2630,7 @@ function foldTaskRecords(rows) {
2630
2630
  if (f.resource === void 0 && d.resourceRef) f.resource = d.resourceRef;
2631
2631
  if (f.kind === void 0 && d.kind) f.kind = d.kind;
2632
2632
  if (f.order === void 0 && d.orderId) f.order = d.orderId;
2633
+ if (d.outcome === "good" || d.outcome === "scrap") f.outcome = d.outcome;
2633
2634
  const loc = d.toNode ?? d.fromNode;
2634
2635
  if (loc) f.location = loc;
2635
2636
  if (d.status === "created") rec.createdMs = at;
@@ -3172,6 +3173,13 @@ var FlowEngine = class {
3172
3173
  policy;
3173
3174
  /** duration 시임(선택) — 미주입 시 명세, 명세도 없으면 도메인 상수. 이력 보정 추정기가 여기 들어온다. */
3174
3175
  durationEstimator;
3176
+ /**
3177
+ * 양품률 시임(선택) — **이력에서 배운 수율.** 소요시간과 같은 자리·같은 규율이다 (2026-08-19).
3178
+ *
3179
+ * 없으면 선언값(현장→원천), 그것도 없으면 도메인 상수. 무엇을 썼는지는 `specCoverage()` 가 밝힌다.
3180
+ * 주입은 호스트가 한다(저널을 접는 쪽이 이력을 안다) — 커널은 시임만 든다.
3181
+ */
3182
+ yieldEstimator;
3175
3183
  /**
3176
3184
  * 오퍼레이션 명세(선택) — 작업 종류(`FlowTask.kind` = `OperationDef.key`) → 소요·변동·모수.
3177
3185
  * 도메인 정의에서 실어 온다(`loadOperations`). 없으면 커널 기본값을 쓰고 그 사실을 `specCoverage()` 가 밝힌다.
@@ -4003,7 +4011,12 @@ var FlowEngine = class {
4003
4011
  clone.rng.state = this.rng.state;
4004
4012
  clone.durationEstimator = this.durationEstimator;
4005
4013
  clone.operationSpecs = this.operationSpecs;
4006
- clone.specUse = new Map([...this.specUse.entries()].map(([k, u]) => [k, { duration: u.duration, variability: u.variability, params: new Set(u.params) }]));
4014
+ clone.specUse = new Map(
4015
+ [...this.specUse.entries()].map(([k, u]) => [
4016
+ k,
4017
+ { duration: u.duration, variability: u.variability, params: new Set(u.params), paramBasis: new Map(u.paramBasis) }
4018
+ ])
4019
+ );
4007
4020
  return clone;
4008
4021
  }
4009
4022
  /**
@@ -4437,6 +4450,23 @@ var FlowEngine = class {
4437
4450
  if (ms2 !== void 0) this.noteParamUse(opKey, id);
4438
4451
  return ms2;
4439
4452
  }
4453
+ /**
4454
+ * 이 공정의 양품률 — **실측 > 현장 선언 > 원천 명세 > 도메인 상수** (2026-08-19).
4455
+ *
4456
+ * 소요시간(`durationOf`)과 같은 사다리다. 도메인 커널은 이 창구만 부르고 상수는 스스로 든다
4457
+ * (`?? DEFAULT_YIELD`) — 상수를 쓴 사실은 `specCoverage()` 의 `parameterBasis` 가 밝힌다.
4458
+ *
4459
+ * 범위를 벗어난 실측은 쓰지 않는다: 비율이 아닌 값(0 미만·1 초과)은 이력 계산이 틀린 것이므로,
4460
+ * 그것으로 시뮬을 굴리면 조용히 이상한 공장이 된다.
4461
+ */
4462
+ yieldOf(kind) {
4463
+ const measured = this.yieldEstimator?.estimate({ kind });
4464
+ if (typeof measured === "number" && measured >= 0 && measured <= 1) {
4465
+ this.noteParamUse(kind, OP_PARAM.yield, "measured");
4466
+ return measured;
4467
+ }
4468
+ return this.paramNumber(kind, OP_PARAM.yield);
4469
+ }
4440
4470
  /**
4441
4471
  * 모수 값 한 개 — **현장이 정한 것이 원천 명세를 이긴다**(소요시간과 같은 순서: ADR-0034).
4442
4472
  *
@@ -4455,12 +4485,22 @@ var FlowEngine = class {
4455
4485
  if (variability) cur.variability = variability;
4456
4486
  return;
4457
4487
  }
4458
- this.specUse.set(kind, { duration, ...variability ? { variability } : {}, params: /* @__PURE__ */ new Set() });
4488
+ this.specUse.set(kind, { duration, ...variability ? { variability } : {}, params: /* @__PURE__ */ new Set(), paramBasis: /* @__PURE__ */ new Map() });
4459
4489
  }
4460
- noteParamUse(kind, id) {
4490
+ /**
4491
+ * 모수를 **무엇으로** 읽었나 — id 와 근거를 함께 적는다 (2026-08-19).
4492
+ *
4493
+ * 예전에는 id 만 세었다. 그래서 「수율을 읽었다」는 알 수 있어도 그것이 이력에서 배운 값인지 선언값인지
4494
+ * 코드 상수인지 구별할 수 없었다 — 예측의 자격을 말하려면 그 구별이 필요하다(소요시간은 이미 구별한다).
4495
+ */
4496
+ noteParamUse(kind, id, basis = "declared") {
4461
4497
  const cur = this.specUse.get(kind);
4462
- if (cur) cur.params.add(id);
4463
- else this.specUse.set(kind, { duration: "default", params: /* @__PURE__ */ new Set([id]) });
4498
+ if (cur) {
4499
+ cur.params.add(id);
4500
+ cur.paramBasis.set(id, basis);
4501
+ return;
4502
+ }
4503
+ this.specUse.set(kind, { duration: "default", params: /* @__PURE__ */ new Set([id]), paramBasis: /* @__PURE__ */ new Map([[id, basis]]) });
4464
4504
  }
4465
4505
  /**
4466
4506
  * 시뮬 명세 자기보고 — **어디까지 데이터로 말했고 어디부터 우리가 코드에 고정한 상수인가.**
@@ -4476,7 +4516,9 @@ var FlowEngine = class {
4476
4516
  kind,
4477
4517
  duration: u.duration,
4478
4518
  ...variability ? { variability } : {},
4479
- parameters: [...u.params].sort()
4519
+ parameters: [...u.params].sort(),
4520
+ /* 모수를 **무엇으로** 읽었나 — 읽은 것이 있을 때만 낸다(빈 표를 만들지 않는다). */
4521
+ ...u.paramBasis.size ? { parameterBasis: Object.fromEntries([...u.paramBasis.entries()].sort()) } : {}
4480
4522
  };
4481
4523
  });
4482
4524
  return {
@@ -4701,6 +4743,8 @@ var FlowEngine = class {
4701
4743
  /* 실제 자재 이동 — 인원·설비와 같은 채널(실적을 한 곳에서 읽는다). */
4702
4744
  ...t.materialActual?.length ? { materialActual: t.materialActual.map((r) => ({ ...r })) } : {},
4703
4745
  ...t.durationMs ? { durationMs: t.durationMs } : {},
4746
+ /* 품질 판정이 있었으면 **그 자리에서** 실어 보낸다 — 없으면 필드를 만들지 않는다(모름≠양품). */
4747
+ ...t.outcome ? { outcome: t.outcome } : {},
4704
4748
  ...t.priority !== void 0 ? { priority: t.priority } : {},
4705
4749
  ...t.startTime ? { startTime: t.startTime } : {},
4706
4750
  ...t.endTime ? { endTime: t.endTime } : {},
@@ -6163,7 +6207,8 @@ var MesKernel = class extends FlowEngine {
6163
6207
  }
6164
6208
  const fgStore = this.locationByType("fg-store");
6165
6209
  const wip = order.allocated[0];
6166
- const good = this.rng() < (this.paramNumber(t.kind, OP_PARAM.yield) ?? DEFAULT_YIELD);
6210
+ const good = this.rng() < (this.yieldOf(t.kind) ?? DEFAULT_YIELD);
6211
+ t.outcome = good ? "good" : "scrap";
6167
6212
  this.recordOutput(t.resource, good);
6168
6213
  const disp = good ? DISP.sellable : DISP.non_sellable;
6169
6214
  const outputEpc = sgtinUri(CP2, product.ref, ++this.prodSeq);
@@ -6275,7 +6320,8 @@ var MesKernel = class extends FlowEngine {
6275
6320
  }
6276
6321
  const fgStore = this.locationByType("fg-store");
6277
6322
  const wip = order.allocated[0];
6278
- const good = this.rng() < (this.paramNumber(t.kind, OP_PARAM.yield) ?? DEFAULT_YIELD);
6323
+ const good = this.rng() < (this.yieldOf(t.kind) ?? DEFAULT_YIELD);
6324
+ t.outcome = good ? "good" : "scrap";
6279
6325
  this.recordOutput(t.resource, good);
6280
6326
  const disp = good ? DISP.sellable : DISP.non_sellable;
6281
6327
  const outEpc = this.serialOf(rc.outputs[0].material, ++this.prodSeq);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.31",
3
+ "version": "0.7.32",
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": {