@operato/twin-kernel 0.7.53 → 0.7.54

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.
@@ -204,17 +204,34 @@ levelOfType?: (type: string) => EquipmentLevel | undefined): Hierarchy;
204
204
  * 템플릿이다. 즉 「모른다」가 아니라 「원천 쪽」이다. 표식은 그 기본값과 다를 때 달린다.
205
205
  */
206
206
  export type PropertySource = 'reference' | 'local';
207
- export interface ResourceProperty {
208
- /** 속성 식별자 — 표준 `ID`. 어휘는 표준이 정하지 않으므로 우리가 한 곳에서 정한다 — 그 자리는 읽는 쪽에 있다
209
- * (headless-twin `EQUIPMENT_PROPERTY`·`PROPERTY_EFFECTS`). 커널은 속성을 **나르기만** 한다. */
210
- id: string;
211
- description?: string;
207
+ /**
208
+ * **ISA-95 `ValueType`** — 값·데이터형·단위 한 묶음. 1차 출처(B2MML v0700+): `ValueType`
209
+ * (`B2MML-Common.xsd`)의 `ValueString`·`DataType`·`UnitOfMeasure`.
210
+ *
211
+ * ── 왜 따로 뽑나 (2026-08-24) ────────────────────────────────────────────────
212
+ * 표준은 이 묶음을 **여러 곳에서 재사용한다** — 자원 속성(`<X>PropertyType.Value`) · 시험 측정값
213
+ * (`PropertyMeasurementType.Value`) · 공정 모수 실측(`OpSegmentDataType.Value`) · 자리 속성
214
+ * (`OperationalLocationPropertyType.Value`). 처음에는 자원 속성에만 있었고 세 필드가 그 안에 인라인
215
+ * 이었다. 두 번째 자리가 생기는 순간 **같은 매핑을 두 번 적게 되고**, 그때부터 한쪽만 고쳐지는 길이
216
+ * 열린다(단위를 한쪽에서만 필수로 만들거나, `dataType` 주석이 갈리는 식으로).
217
+ *
218
+ * 표준이 한 타입으로 둔 것을 우리도 한 타입으로 둔다. **평평하게 펴는 것**은 우리 선택이다 —
219
+ * 표준은 `Value` 를 자식 요소로 두지만, 소비처가 `p.value` 로 읽는 편이 `p.value.valueString` 보다
220
+ * 낫고 이 저장소가 이미 그렇게 하고 있었다. 그 선택을 여기 적어 둔다(되돌리려면 소비처 전부가 움직인다).
221
+ */
222
+ export interface StandardValue {
212
223
  /** 표준 `Value.ValueString`. 값의 표기는 문자열이고, 뜻은 `dataType`·`uom` 이 정한다. */
213
224
  value?: string;
214
225
  /** 표준 `Value.DataType` — 예: `xs:double`. 미지정이면 소비처가 형을 짐작하지 않는다. */
215
226
  dataType?: string;
216
- /** 표준 `Value.UnitOfMeasure` — UN/CEFACT 공통코드(예: `MTS`·`KMH`). **단위 없는 물리량은 쓰지 않는다.** */
227
+ /** 표준 `Value.UnitOfMeasure` — UN/CEFACT 공통코드(예: `MTS`·`KMH`·`CEL`). **단위 없는 물리량은 쓰지 않는다.** */
217
228
  uom?: string;
229
+ }
230
+ export interface ResourceProperty extends StandardValue {
231
+ /** 속성 식별자 — 표준 `ID`. 어휘는 표준이 정하지 않으므로 우리가 한 곳에서 정한다 — 그 자리는 읽는 쪽에 있다
232
+ * (headless-twin `EQUIPMENT_PROPERTY`·`PROPERTY_EFFECTS`). 커널은 속성을 **나르기만** 한다. */
233
+ id: string;
234
+ description?: string;
218
235
  /** 하위 속성 — 표준 `<X>PropertyChild`(재귀). 구조화된 속성(예: 정격/실측 묶음)을 잃지 않기 위해. */
219
236
  children?: ResourceProperty[];
220
237
  /** 이 개체 속성이 구체화하는 **등급 속성** — 표준 `<X>ClassPropertyID`. */
@@ -278,14 +295,29 @@ export type TestSpecificationRefs = string[];
278
295
  * **결과가 선언돼 있으면 그때부터 제약이 된다**: 불합격이거나 유효기간이 지났으면 그 등급으로 자격이
279
296
  * 성립하지 않는다. 그것이 이 값을 싣는 이유다(싣지 않으면 아무 제약도 없다).
280
297
  *
281
- * 필드 이름은 최소로 둔다 — 표준 결과 타입(`B2MML-OperationsTest.xsd`)과 아직 대조하지 않았다.
298
+ * ── 1차 출처와 대조했다 (2026-08-24) ─────────────────────────────────────────
299
+ * 예전에는 「필드 이름을 아직 대조하지 않았다」고 유보해 두었다. `B2MML-OperationsTest.xsd` 의
300
+ * `TestResultType` 을 읽었고, 그 결과 **둘이 갈렸다.**
301
+ *
302
+ * 표준이 준 것 `EvaluationDate` · `Expiration` · `TestableObjectID`
303
+ * · `EvaluatedCriterionResult` · `PropertyMeasurement`(unbounded)
304
+ * 우리가 좁힌 것 `result: 'pass' | 'fail'` — **표준은 판정을 열거하지 않는다**(`TextType`)
305
+ *
306
+ * 좁힘을 적어 두는 이유: HACCP 같은 규제 판정에는 「적합·부적합」 밖의 값이 온다(재검·조건부). 그때
307
+ * 이 유니온을 넓혀야 하는데, **좁힘이라고 적혀 있지 않으면 다음 사람이 「표준이 둘만 정했다」고 읽는다.**
308
+ *
309
+ * `at`·`expiresAt` 은 표준 `EvaluationDate`·`Expiration` 이다 — 이름을 바꾸지 않는다(이미 나간 계약이고,
310
+ * 개명이 사는 값은 이 주석이 대신 낸다).
282
311
  */
283
312
  export interface TestResult {
284
313
  /** 어느 시험인가 — `TestSpecification.id` 를 가리킨다. */
285
314
  specId: string;
286
- /** 합격 여부. 표준의 판정 어휘를 대조하기 전이므로 둘만 둔다(모르는 값을 만들지 않는다). */
315
+ /**
316
+ * 합격 여부 — **우리가 좁힌 것이다.** 표준 `EvaluatedCriterionResult` 는 `TextType` 이고
317
+ * 판정 어휘를 열거하지 않는다(원문 대조 2026-08-24). 넓힐 때는 이 주석과 함께 움직인다.
318
+ */
287
319
  result: 'pass' | 'fail';
288
- /** 언제 통과·불합격했나(ISO). */
320
+ /** 언제 통과·불합격했나(ISO) — 표준 `EvaluationDate`. */
289
321
  at?: ISOTime;
290
322
  /**
291
323
  * 언제까지 유효한가(ISO) — 자격에는 대개 유효기간이 있다.
@@ -294,6 +326,18 @@ export interface TestResult {
294
326
  * (유효기간을 모르는 것과 지난 것은 다르다).
295
327
  */
296
328
  expiresAt?: ISOTime;
329
+ /**
330
+ * 이 판정의 **근거가 된 측정값들** — 표준 `TestResult.PropertyMeasurement`(`maxOccurs="unbounded"`).
331
+ *
332
+ * ── 왜 판정에 값을 붙이나 (당위) ──────────────────────────────────────────
333
+ * 「부적합」이라고만 적힌 기록과 「4.2°C 를 재어 한계를 넘었다」는 기록은 **다른 물건**이다. 앞의
334
+ * 것으로는 되짚을 수 없다 — 얼마나 벗어났나, 언제였나, 다시 재면 같은 답이 나오나에 답할 수 없다.
335
+ * 규제 기록은 성질상 **사고 뒤에** 읽히므로, 근거 없는 판정은 그때 아무 일도 하지 못한다.
336
+ *
337
+ * **없어도 판정은 성립한다** — 원본이 결론만 주는 현장이 있고, 그때 값을 지어내지 않는다. 빈 것은
338
+ * `testEvidenceGaps` 가 세어 낸다(조용히 넘기지 않는다).
339
+ */
340
+ propertyMeasurements?: PropertyMeasurement[];
297
341
  }
298
342
  /**
299
343
  * 이 시험 결과가 **이 시각에 유효한 합격인가.**
@@ -348,6 +392,40 @@ export interface Capability {
348
392
  available: boolean;
349
393
  reason: CapabilityReason;
350
394
  }
395
+ /**
396
+ * **판정 기준 하나** — 표준 `TestSpecificationCriteriaType`(`B2MML-OperationsTest.xsd`).
397
+ *
398
+ * ── 왜 이 축이 필요한가 (당위) ────────────────────────────────────────────────
399
+ * 측정값만 있으면 트윈은 「4.2」를 들고 있을 뿐이고 **그것이 괜찮은지 모른다.** 판정의 재료는
400
+ * 「값」과 「기준」 둘이다. 기준이 없으면 트윈은 조건을 알면서도 아무 말도 할 수 없다 — 계기판이 된다.
401
+ *
402
+ * ── 커널은 기준을 **정하지 않고, 재해석하지도 않는다** ──────────────────────
403
+ * 기준은 그 현장의 규정이다(식품안전·약전·사내 규격). 트윈이 정할 것이 아니고, **표현식을 커널이
404
+ * 평가하지도 않는다** — 표준의 `Expression` 이 `TextType` 이라 문법이 정의돼 있지 않다. 문법을 우리가
405
+ * 만들면 그 순간 방언이 되고, 현장의 규정과 우리 해석이 갈리는 날 어느 쪽이 옳은지 아무도 모른다.
406
+ *
407
+ * 그러면 이 값이 무슨 일을 하나: **판정에 근거가 있는지를 물을 수 있게 한다**(§`testEvidenceGaps`).
408
+ * 「부적합」이라고만 적힌 기록과 「4.2°C 를 재어 한계 4.0 을 넘었다」는 기록은 다른 물건이다.
409
+ */
410
+ export interface TestSpecificationCriterion {
411
+ /** 표준 `ID`. */
412
+ id: string;
413
+ /** 표준 `Description`. */
414
+ description?: string;
415
+ /** 표준 `Sequence` — 기준이 여럿일 때의 순서. */
416
+ sequence?: number;
417
+ /**
418
+ * 표준 `Expression`(`TextType`) — **그 현장의 표기 그대로** 나른다. 커널은 평가하지 않는다.
419
+ *
420
+ * 표준에서는 선택이지만 **우리는 요구한다**(좁힘): 표현식이 없는 기준은 아무 한계도 말하지 않으므로
421
+ * 「기준이 선언됐다」는 착각만 만든다. 선언만 있고 내용이 없는 것을 이 저장소는 거절한다.
422
+ */
423
+ expression: string;
424
+ /** 표준 `Result` — 이 기준이 만족될 때 기대하는 값(현장 표기). */
425
+ result?: string;
426
+ /** 표준 `EvaluatedPropertyID` — 이 기준이 **무엇을 재어** 판정하나. 측정값과 짝을 맞추는 키다. */
427
+ evaluatedPropertyId?: string;
428
+ }
351
429
  export interface TestSpecification {
352
430
  /** 표준 `ID` — 자원의 `testSpecificationIds` 가 이 값을 가리킨다. */
353
431
  id: string;
@@ -355,7 +433,56 @@ export interface TestSpecification {
355
433
  description?: string;
356
434
  /** 표준 `Version` — 같은 시험의 판이 바뀌면 자격의 뜻도 바뀐다(어느 판으로 검증했나). */
357
435
  version?: string;
436
+ /**
437
+ * 판정 기준들 — 표준 `TestSpecificationCriteria`(`maxOccurs="unbounded"`).
438
+ *
439
+ * 선언하지 않아도 명세는 성립한다(「무엇으로 검증했다고 하는지」를 이름으로 답하는 것이 그 최소
440
+ * 역할이었다). 선언하면 **판정에 근거가 있는지**를 물을 수 있게 된다.
441
+ */
442
+ criteria?: TestSpecificationCriterion[];
443
+ }
444
+ /**
445
+ * **측정값 하나** — 표준 `PropertyMeasurementType`(`B2MML-OperationsTest.xsd`).
446
+ *
447
+ * 표준에서 이것은 `TestResult` 의 자식이다: 판정 하나가 **자기 근거가 된 측정들을 든다.** 값의 모양은
448
+ * `ValueType` 이라 `StandardValue` 를 그대로 쓴다 — 단위 없는 물리량은 쓰지 않는다는 규율이 여기에도 산다.
449
+ */
450
+ export interface PropertyMeasurement extends StandardValue {
451
+ /** 표준 `ID`. */
452
+ id?: string;
453
+ /** 표준 `Description`. */
454
+ description?: string;
455
+ /** 표준 `TestableObjectPropertyID` — **무엇을 쟀나**(그 개체의 어느 속성인가). 기준과 짝을 맞추는 키다. */
456
+ testableObjectPropertyId?: string;
457
+ /** 표준 `MeasurementDate` — 언제 쟀나. */
458
+ measurementDate?: ISOTime;
459
+ /**
460
+ * 이 값이 **잰 것이 아니라 접은 것**임을 밝힌다 — §`DemandWindowState.derived` 와 같은 규율.
461
+ *
462
+ * 트윈은 원본이 주지 않는 값을 계산할 수 있다(그것이 트윈의 존재 이유다). 그런데 그 수를 실측과
463
+ * **같은 자리에 같은 모양으로** 두면 화면·보고서가 그것을 잰 값으로 읽는다. 규제 기록에서 그
464
+ * 구별이 사라지는 것은 결함이 아니라 사고다. 그래서 값 옆에 종류를 함께 싣는다.
465
+ */
466
+ derived?: boolean;
358
467
  }
468
+ /**
469
+ * **이 판정에 근거가 있나** — 기준과 측정값의 짝을 맞춰 빈 곳을 돌려준다.
470
+ *
471
+ * ── 왜 커널이 이것을 답하나 ─────────────────────────────────────────────────
472
+ * 커널은 기준을 **평가하지 않는다**(`Expression` 은 문법이 정의되지 않은 자유 문자열). 그러나
473
+ * **짝이 맞는지는 문법을 몰라도 알 수 있다** — 그리고 그것이 이 축을 실은 이유다: 「부적합」이라고만
474
+ * 적힌 기록과 근거를 든 기록을 화면이 구별할 수 있어야 한다.
475
+ *
476
+ * unmeasured 기준은 있는데 그것을 잰 값이 없다 — **판정의 근거가 비었다**
477
+ * unmatched 측정값은 있는데 그것을 요구한 기준이 없다 — 어느 규정으로 잰 것인지 모른다
478
+ *
479
+ * 판정하지 않고 **세어서 낸다**: 이 저장소는 빈 것을 조용히 넘기지 않는다(§`ObservedReducer.unhandled`).
480
+ * 그리고 짝의 키는 표준이 준 것 그대로다 — `EvaluatedPropertyID` ↔ `TestableObjectPropertyID`.
481
+ */
482
+ export declare function testEvidenceGaps(spec: Pick<TestSpecification, 'criteria'> | undefined, result: Pick<TestResult, 'propertyMeasurements'> | undefined): {
483
+ unmeasured: string[];
484
+ unmatched: string[];
485
+ };
359
486
  /**
360
487
  * 유효 기간 — **ISA-95 `EffectiveStartDate` / `EffectiveEndDate`.**
361
488
  *
@@ -1557,6 +1684,28 @@ export interface StateSnapshot {
1557
1684
  firstAtMs?: number;
1558
1685
  lastAtMs?: number;
1559
1686
  }[];
1687
+ /**
1688
+ * **개체를 잇지 못한 채 다음 공정으로 넘어간 횟수** — 계보에 구멍이 남았다.
1689
+ *
1690
+ * ── 왜 이 값이 필요했나 (2026-08-24 실측) ────────────────────────────────────
1691
+ * 미러의 오더는 **확보분을 가진 적이 없다.** 원본이 「이 오더에 어느 개체가 잡혀 있나」를 말하지
1692
+ * 않는 것이 정상이다(첫 실 연동: 오더 사실이 `status`·`requested`·`fulfilled` 만 든다). 그런데
1693
+ * 커널이 중간 공정을 넘을 때 **들고 갈 개체를 요구하고 던졌다** — 그래서 미러에서 예측이
1694
+ * 구조적으로 실패했다(`twinForecast` 가 오더 하나 때문에 전부 실패).
1695
+ *
1696
+ * 진행을 막은 것이 과했다. **단계를 넘는 것은 공정의 진행이고 개체를 잇는 것은 계보**다. 계보를
1697
+ * 못 이으면 계보를 비우는 것이 맞고, 그렇다고 진행까지 멈추면 있는 답(언제 끝나나·처리량)까지 잃는다.
1698
+ *
1699
+ * 그래서 넘기되 **세어 낸다.** 세지 않으면 계보에 구멍이 있는데 화면이 정상으로 보인다 —
1700
+ * `unhandled` 를 스냅샷이 떨어뜨려 정합성 검사가 영원히 조용했던 것과 같은 부류의 실수다.
1701
+ *
1702
+ * **목록이 아니라 수다**: 오더 수가 규모에 비례해 자라므로 목록은 잘라야 하고, 자르면 「조용히
1703
+ * 자르지 않는다」를 지킬 수 없다. 어느 오더인지는 그 오더의 계보가 빈 것으로 답한다.
1704
+ *
1705
+ * 자체 구동(시뮬)에서는 **이 값이 오르지 않는다** — 시뮬은 개체를 스스로 만들므로 확보분이 비는
1706
+ * 것이 곧 결함이고, 거기서는 여전히 던진다(진짜 결함을 조용하게 만들지 않는다).
1707
+ */
1708
+ stepsWithoutMaterial?: number;
1560
1709
  /**
1561
1710
  * 확인(ack)해 둔 주목 신호 id — **상태에서 파생되지 않는 유일한 축.**
1562
1711
  *
package/dist/contract.js CHANGED
@@ -245,6 +245,35 @@ export function meetsTests(required, results, at) {
245
245
  }
246
246
  return true;
247
247
  }
248
+ /**
249
+ * **이 판정에 근거가 있나** — 기준과 측정값의 짝을 맞춰 빈 곳을 돌려준다.
250
+ *
251
+ * ── 왜 커널이 이것을 답하나 ─────────────────────────────────────────────────
252
+ * 커널은 기준을 **평가하지 않는다**(`Expression` 은 문법이 정의되지 않은 자유 문자열). 그러나
253
+ * **짝이 맞는지는 문법을 몰라도 알 수 있다** — 그리고 그것이 이 축을 실은 이유다: 「부적합」이라고만
254
+ * 적힌 기록과 근거를 든 기록을 화면이 구별할 수 있어야 한다.
255
+ *
256
+ * unmeasured 기준은 있는데 그것을 잰 값이 없다 — **판정의 근거가 비었다**
257
+ * unmatched 측정값은 있는데 그것을 요구한 기준이 없다 — 어느 규정으로 잰 것인지 모른다
258
+ *
259
+ * 판정하지 않고 **세어서 낸다**: 이 저장소는 빈 것을 조용히 넘기지 않는다(§`ObservedReducer.unhandled`).
260
+ * 그리고 짝의 키는 표준이 준 것 그대로다 — `EvaluatedPropertyID` ↔ `TestableObjectPropertyID`.
261
+ */
262
+ export function testEvidenceGaps(spec, result) {
263
+ const criteria = spec?.criteria ?? [];
264
+ const measurements = result?.propertyMeasurements ?? [];
265
+ /* 기준이 재는 속성들 / 실제로 잰 속성들 — 속성 id 를 말하지 않은 것은 짝을 맞출 수 없으므로 뺀다. */
266
+ const wanted = new Set(criteria.map(c => c.evaluatedPropertyId).filter((k) => !!k));
267
+ const got = new Set(measurements.map(m => m.testableObjectPropertyId).filter((k) => !!k));
268
+ return {
269
+ unmeasured: criteria
270
+ .filter(c => c.evaluatedPropertyId && !got.has(c.evaluatedPropertyId))
271
+ .map(c => c.id),
272
+ unmatched: measurements
273
+ .map(m => m.testableObjectPropertyId)
274
+ .filter((k) => !!k && !wanted.has(k))
275
+ };
276
+ }
248
277
  /**
249
278
  * 이 시각에 유효 기간 밖인가 — **한 규칙**으로 개체·등급·설비↔자산 매핑을 모두 판정한다.
250
279
  *
@@ -560,6 +560,19 @@ export declare abstract class FlowEngine implements TwinKernel {
560
560
  protected localParams: Map<string, Map<string, string>>;
561
561
  /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
562
562
  private observer?;
563
+ /**
564
+ * 이 커널의 상태가 **관측에서 왔나** — 미러인가.
565
+ *
566
+ * 자체 구동(시뮬)과 가르는 축이다. 두 구동은 **같은 빈 값에 다른 뜻**을 갖는다: 시뮬에서 확보분이
567
+ * 비는 것은 결함이고, 미러에서는 원본이 말하지 않는 정상이다. 그 구분 없이 한쪽 규칙을 쓰면
568
+ * 시뮬의 진짜 결함이 조용해지거나 미러가 정상 상태에서 죽는다.
569
+ *
570
+ * fork 도 이 표식을 물려받는다(§`fork` — skip 목록에 없다). 예측은 미러에서 갈라져 나오므로
571
+ * 그것이 맞다: 예측이 든 상태는 여전히 **관측에서 온 상태**다.
572
+ */
573
+ protected get observationDriven(): boolean;
574
+ /** 계보를 잇지 못한 채 넘어간 횟수 — §`StateSnapshot.stepsWithoutMaterial`. */
575
+ protected stepsWithoutMaterial: number;
563
576
  /** 관측분이 아직 커널 상태로 옮겨지지 않았다 — 스냅샷·fork 직전에 한 번만 옮긴다. */
564
577
  /**
565
578
  * **관측 모드에서 원본과 어긋난 횟수** — 받아들였지만 사실이 맞지 않았다.
@@ -573,6 +573,21 @@ export class FlowEngine {
573
573
  localParams = new Map();
574
574
  /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
575
575
  observer;
576
+ /**
577
+ * 이 커널의 상태가 **관측에서 왔나** — 미러인가.
578
+ *
579
+ * 자체 구동(시뮬)과 가르는 축이다. 두 구동은 **같은 빈 값에 다른 뜻**을 갖는다: 시뮬에서 확보분이
580
+ * 비는 것은 결함이고, 미러에서는 원본이 말하지 않는 정상이다. 그 구분 없이 한쪽 규칙을 쓰면
581
+ * 시뮬의 진짜 결함이 조용해지거나 미러가 정상 상태에서 죽는다.
582
+ *
583
+ * fork 도 이 표식을 물려받는다(§`fork` — skip 목록에 없다). 예측은 미러에서 갈라져 나오므로
584
+ * 그것이 맞다: 예측이 든 상태는 여전히 **관측에서 온 상태**다.
585
+ */
586
+ get observationDriven() {
587
+ return !!this.observer;
588
+ }
589
+ /** 계보를 잇지 못한 채 넘어간 횟수 — §`StateSnapshot.stepsWithoutMaterial`. */
590
+ stepsWithoutMaterial = 0;
576
591
  /** 관측분이 아직 커널 상태로 옮겨지지 않았다 — 스냅샷·fork 직전에 한 번만 옮긴다. */
577
592
  /**
578
593
  * **관측 모드에서 원본과 어긋난 횟수** — 받아들였지만 사실이 맞지 않았다.
@@ -1528,6 +1543,8 @@ export class FlowEngine {
1528
1543
  const u = this.observer?.snapshot?.().unhandled;
1529
1544
  return u?.length ? { unhandled: u } : {};
1530
1545
  })(),
1546
+ /* 계보를 잇지 못한 채 넘어간 횟수 — 같은 규율(세기만 하고 내보내지 않으면 없는 것과 같다). */
1547
+ ...(this.stepsWithoutMaterial > 0 ? { stepsWithoutMaterial: this.stepsWithoutMaterial } : {}),
1531
1548
  tasks: [...this.tasks.values()].map(t => ({
1532
1549
  id: t.id, kind: t.kind, status: t.status, itemRefs: [t.itemEpc],
1533
1550
  fromNode: t.fromNode, toNode: t.toNode, resourceRef: t.resource ?? undefined, orderId: t.orderId,
@@ -275,6 +275,13 @@ export declare class MesKernel extends FlowEngine {
275
275
  private onOrderDef;
276
276
  /** 정의 모드 할당 — 레시피 입력 BOM 전량 확보 후 첫 라우트 스텝 태스크. */
277
277
  private allocateDef;
278
+ /**
279
+ * 공정 하나의 작업을 낸다.
280
+ *
281
+ * `itemEpc` 가 **비어 있을 수 있다**(빈 문자열) — 원본이 개체를 말하지 않는 미러에서 그렇다. 그때
282
+ * 이 작업은 「무엇을」 없이 「어디서 무엇을 한다」만 안다. 빈 값으로 두는 것이 규약이다(§1453 의
283
+ * `t.itemRefs?.[0] ?? ''` 와 같은 자리) — 없는 개체를 지어내지 않는다.
284
+ */
278
285
  private emitStationDef;
279
286
  /** 정의 모드 완료 — 라우트 인덱스: 중간=WIP 변환+다음 스텝, 마지막=완제품(수율). */
280
287
  private onTaskCompleteDef;
@@ -811,9 +811,16 @@ export class MesKernel extends FlowEngine {
811
811
  o.status = 'op-' + ops[0].key;
812
812
  this.emitOrder(o);
813
813
  }
814
+ /**
815
+ * 공정 하나의 작업을 낸다.
816
+ *
817
+ * `itemEpc` 가 **비어 있을 수 있다**(빈 문자열) — 원본이 개체를 말하지 않는 미러에서 그렇다. 그때
818
+ * 이 작업은 「무엇을」 없이 「어디서 무엇을 한다」만 안다. 빈 값으로 두는 것이 규약이다(§1453 의
819
+ * `t.itemRefs?.[0] ?? ''` 와 같은 자리) — 없는 개체를 지어내지 않는다.
820
+ */
814
821
  emitStationDef(o, op, itemEpc) {
815
822
  const loc = this.locationByType(op.locationType);
816
- const task = { id: `task-${++this.taskSeq}`, kind: op.key, status: 'created', itemEpc, fromNode: loc.id, toNode: loc.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: op.key, fromNode: loc.id, toNode: loc.id, resourceKind: op.resourceType }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: op.resourceType, changeoverKey: o.gtin, setupMs: this.paramDuration(op.key, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: op.intent };
823
+ const task = { id: `task-${++this.taskSeq}`, kind: op.key, status: 'created', itemEpc: itemEpc ?? '', fromNode: loc.id, toNode: loc.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: op.key, fromNode: loc.id, toNode: loc.id, resourceKind: op.resourceType }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: op.resourceType, changeoverKey: o.gtin, setupMs: this.paramDuration(op.key, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: op.intent };
817
824
  this.tasks.set(task.id, task);
818
825
  this.emitTask(task);
819
826
  }
@@ -849,10 +856,34 @@ export class MesKernel extends FlowEngine {
849
856
  }
850
857
  /* 다음 자리에 무엇을 들고 가는가 — 만든 것이 있으면 그것, 없으면 들고 있던 것. */
851
858
  const carried = order.allocated[0];
852
- if (!carried) {
859
+ /*
860
+ * ── 개체가 없어도 **진행은 막지 않는다** (2026-08-24) ───────────────────────
861
+ * 예전에는 여기서 무조건 던졌다. 그래서 **미러에서 예측이 구조적으로 실패했다**: 미러의 오더는
862
+ * 확보분을 가진 적이 없고(원본이 「어느 개체가 잡혀 있나」를 말하지 않는 것이 정상이다 — 첫 실
863
+ * 연동의 오더 사실은 `status`·`requested`·`fulfilled` 뿐이다), 그 오더가 중간 공정을 넘는
864
+ * 순간마다 터졌다. 오더 하나 때문에 `twinForecast` 가 통째로 실패했다.
865
+ *
866
+ * 두 가지를 섞은 것이 잘못이었다.
867
+ *
868
+ * 단계를 넘는 것 공정의 **진행**이다 — 라우트·소요·용량이 답한다. 개체 번호가 필요 없다
869
+ * 개체를 잇는 것 **계보**다 — 개체가 없으면 이을 수 없다
870
+ *
871
+ * 계보를 못 이으면 **계보를 비우는 것**이 맞다(지어내지 않는다 — 예전에 `'WIP'` 를 지어내던
872
+ * 그 자리다). 그런데 진행까지 멈추면 있는 답(언제 끝나나·처리량)까지 잃는다. **없어도 되는
873
+ * 것을 요구해서 있는 답을 잃고 있었다.**
874
+ *
875
+ * ── 그러나 시뮬에서는 여전히 결함이다 ────────────────────────────────────
876
+ * 자체 구동은 개체를 스스로 만든다. 거기서 확보분이 비면 `allocateDef` 가 확보하지 못했거나
877
+ * 선언이 어긋난 것이고, 조용히 넘기면 **계보가 통째로 빈 시뮬**이 정상처럼 보인다. 그래서
878
+ * 관측 구동일 때만 넘긴다(§`observationDriven`).
879
+ */
880
+ if (!carried && !this.observationDriven) {
853
881
  throw new Error(`order ${order.id} at step '${t.kind}': nothing to carry to '${next.key}' — the order holds no allocated material ` +
854
882
  `and this step declares no produced material. Declare the step's input or its output in the model.`);
855
883
  }
884
+ /* 넘기되 **세어 낸다** — 세지 않으면 계보에 구멍이 있는데 화면이 정상으로 보인다. */
885
+ if (!carried)
886
+ this.stepsWithoutMaterial++;
856
887
  this.emitStationDef(order, next, carried);
857
888
  order.status = 'op-' + next.key;
858
889
  this.emitOrder(order);
@@ -898,6 +929,24 @@ export class MesKernel extends FlowEngine {
898
929
  this.emitOrder(order);
899
930
  return;
900
931
  }
932
+ /*
933
+ * ── 원본이 개체를 말하지 않는 현장 (2026-08-24) ────────────────────────────
934
+ * 위 `seedIncomplete` 는 **우리가 못 심은 것**이다(원본이 말한 물품이 스냅샷에 없었다). 이것은
935
+ * 다른 사실이다: **원본이 애초에 말하지 않는다.** 첫 실 연동의 오더 사실은 `status`·`requested`
936
+ * ·`fulfilled` 뿐이고, 「어느 개체가 잡혀 있나」를 주지 않는 것이 그 시스템의 정상이다.
937
+ *
938
+ * 두 사실을 한 값으로 뭉개지 않는다 — 앞의 것은 고칠 수 있는 결함이고 뒤의 것은 원본의 성질이다.
939
+ *
940
+ * **여기서는 진행도 넘기지 않는다.** 중간 단계는 넘긴다(§`onTaskCompleteDef` — 단계를 넘는 것은
941
+ * 공정의 진행이고 개체가 필요 없다). 그러나 마지막 단계는 **산출을 만든다**: 소비할 것 없이
942
+ * 제품을 내면 「일어나지 않은 생산」을 기록하는 것이고, 그것은 계보가 비는 것보다 나쁘다.
943
+ * 그래서 막혔다고 말하고 멈춘다 — 이유가 상태에 남으므로 화면이 원인을 지어내지 않는다.
944
+ */
945
+ if (this.observationDriven) {
946
+ order.status = 'blocked-source-omits-material';
947
+ this.emitOrder(order);
948
+ return;
949
+ }
901
950
  throw new Error(`order ${order.id} at final step '${t.kind}': nothing allocated to consume — the kernel does not make a ` +
902
951
  `product out of nothing. Declare the recipe inputs, or the step's produced material.`);
903
952
  }
@@ -168,6 +168,7 @@ __export(index_exports, {
168
168
  ssccUri: () => ssccUri,
169
169
  stateFieldsOf: () => stateFieldsOf,
170
170
  subLotIdOf: () => subLotIdOf,
171
+ testEvidenceGaps: () => testEvidenceGaps,
171
172
  testPassedAt: () => testPassedAt,
172
173
  transactionEvent: () => transactionEvent,
173
174
  transformationEvent: () => transformationEvent,
@@ -289,6 +290,16 @@ function meetsTests(required, results, at) {
289
290
  }
290
291
  return true;
291
292
  }
293
+ function testEvidenceGaps(spec, result) {
294
+ const criteria = spec?.criteria ?? [];
295
+ const measurements = result?.propertyMeasurements ?? [];
296
+ const wanted = new Set(criteria.map((c) => c.evaluatedPropertyId).filter((k) => !!k));
297
+ const got = new Set(measurements.map((m) => m.testableObjectPropertyId).filter((k) => !!k));
298
+ return {
299
+ unmeasured: criteria.filter((c) => c.evaluatedPropertyId && !got.has(c.evaluatedPropertyId)).map((c) => c.id),
300
+ unmatched: measurements.map((m) => m.testableObjectPropertyId).filter((k) => !!k && !wanted.has(k))
301
+ };
302
+ }
292
303
  function effectivityAt(p, at) {
293
304
  if (!p || !at) return void 0;
294
305
  const atMs = parsedMs(at);
@@ -3729,6 +3740,21 @@ var FlowEngine = class {
3729
3740
  localParams = /* @__PURE__ */ new Map();
3730
3741
  /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
3731
3742
  observer;
3743
+ /**
3744
+ * 이 커널의 상태가 **관측에서 왔나** — 미러인가.
3745
+ *
3746
+ * 자체 구동(시뮬)과 가르는 축이다. 두 구동은 **같은 빈 값에 다른 뜻**을 갖는다: 시뮬에서 확보분이
3747
+ * 비는 것은 결함이고, 미러에서는 원본이 말하지 않는 정상이다. 그 구분 없이 한쪽 규칙을 쓰면
3748
+ * 시뮬의 진짜 결함이 조용해지거나 미러가 정상 상태에서 죽는다.
3749
+ *
3750
+ * fork 도 이 표식을 물려받는다(§`fork` — skip 목록에 없다). 예측은 미러에서 갈라져 나오므로
3751
+ * 그것이 맞다: 예측이 든 상태는 여전히 **관측에서 온 상태**다.
3752
+ */
3753
+ get observationDriven() {
3754
+ return !!this.observer;
3755
+ }
3756
+ /** 계보를 잇지 못한 채 넘어간 횟수 — §`StateSnapshot.stepsWithoutMaterial`. */
3757
+ stepsWithoutMaterial = 0;
3732
3758
  /** 관측분이 아직 커널 상태로 옮겨지지 않았다 — 스냅샷·fork 직전에 한 번만 옮긴다. */
3733
3759
  /**
3734
3760
  * **관측 모드에서 원본과 어긋난 횟수** — 받아들였지만 사실이 맞지 않았다.
@@ -4501,6 +4527,8 @@ var FlowEngine = class {
4501
4527
  const u = this.observer?.snapshot?.().unhandled;
4502
4528
  return u?.length ? { unhandled: u } : {};
4503
4529
  })(),
4530
+ /* 계보를 잇지 못한 채 넘어간 횟수 — 같은 규율(세기만 하고 내보내지 않으면 없는 것과 같다). */
4531
+ ...this.stepsWithoutMaterial > 0 ? { stepsWithoutMaterial: this.stepsWithoutMaterial } : {},
4504
4532
  tasks: [...this.tasks.values()].map((t) => ({
4505
4533
  id: t.id,
4506
4534
  kind: t.kind,
@@ -7598,9 +7626,16 @@ var MesKernel = class extends FlowEngine {
7598
7626
  o.status = "op-" + ops[0].key;
7599
7627
  this.emitOrder(o);
7600
7628
  }
7629
+ /**
7630
+ * 공정 하나의 작업을 낸다.
7631
+ *
7632
+ * `itemEpc` 가 **비어 있을 수 있다**(빈 문자열) — 원본이 개체를 말하지 않는 미러에서 그렇다. 그때
7633
+ * 이 작업은 「무엇을」 없이 「어디서 무엇을 한다」만 안다. 빈 값으로 두는 것이 규약이다(§1453 의
7634
+ * `t.itemRefs?.[0] ?? ''` 와 같은 자리) — 없는 개체를 지어내지 않는다.
7635
+ */
7601
7636
  emitStationDef(o, op, itemEpc) {
7602
7637
  const loc = this.locationByType(op.locationType);
7603
- const task = { id: `task-${++this.taskSeq}`, kind: op.key, status: "created", itemEpc, fromNode: loc.id, toNode: loc.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: op.key, fromNode: loc.id, toNode: loc.id, resourceKind: op.resourceType }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: op.resourceType, changeoverKey: o.gtin, setupMs: this.paramDuration(op.key, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: op.intent };
7638
+ const task = { id: `task-${++this.taskSeq}`, kind: op.key, status: "created", itemEpc: itemEpc ?? "", fromNode: loc.id, toNode: loc.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: op.key, fromNode: loc.id, toNode: loc.id, resourceKind: op.resourceType }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: op.resourceType, changeoverKey: o.gtin, setupMs: this.paramDuration(op.key, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: op.intent };
7604
7639
  this.tasks.set(task.id, task);
7605
7640
  this.emitTask(task);
7606
7641
  }
@@ -7624,11 +7659,12 @@ var MesKernel = class extends FlowEngine {
7624
7659
  order.allocated = [made];
7625
7660
  }
7626
7661
  const carried = order.allocated[0];
7627
- if (!carried) {
7662
+ if (!carried && !this.observationDriven) {
7628
7663
  throw new Error(
7629
7664
  `order ${order.id} at step '${t.kind}': nothing to carry to '${next.key}' \u2014 the order holds no allocated material and this step declares no produced material. Declare the step's input or its output in the model.`
7630
7665
  );
7631
7666
  }
7667
+ if (!carried) this.stepsWithoutMaterial++;
7632
7668
  this.emitStationDef(order, next, carried);
7633
7669
  order.status = "op-" + next.key;
7634
7670
  this.emitOrder(order);
@@ -7643,6 +7679,11 @@ var MesKernel = class extends FlowEngine {
7643
7679
  this.emitOrder(order);
7644
7680
  return;
7645
7681
  }
7682
+ if (this.observationDriven) {
7683
+ order.status = "blocked-source-omits-material";
7684
+ this.emitOrder(order);
7685
+ return;
7686
+ }
7646
7687
  throw new Error(
7647
7688
  `order ${order.id} at final step '${t.kind}': nothing allocated to consume \u2014 the kernel does not make a product out of nothing. Declare the recipe inputs, or the step's produced material.`
7648
7689
  );
@@ -9157,6 +9198,7 @@ function retiredVocabularyIn(line) {
9157
9198
  ssccUri,
9158
9199
  stateFieldsOf,
9159
9200
  subLotIdOf,
9201
+ testEvidenceGaps,
9160
9202
  testPassedAt,
9161
9203
  transactionEvent,
9162
9204
  transformationEvent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.53",
3
+ "version": "0.7.54",
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": {