@things-factory/headless-twin 10.0.13 → 10.0.14
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.
- package/dist-server/engine/ingest-health.d.ts +41 -0
- package/dist-server/engine/ingest-health.js +48 -0
- package/dist-server/engine/ingest-health.js.map +1 -1
- package/dist-server/engine/twin-engine.d.ts +34 -0
- package/dist-server/engine/twin-engine.js +180 -6
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/service/reference/reference-adapter.d.ts +26 -0
- package/dist-server/service/reference/reference-adapter.js.map +1 -1
- package/dist-server/service/reference/reference-assessment.d.ts +11 -1
- package/dist-server/service/reference/reference-assessment.js +22 -2
- package/dist-server/service/reference/reference-assessment.js.map +1 -1
- package/dist-server/service/reference/reference-live.js +18 -0
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +12 -2
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js +24 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
- package/dist-server/service/twin-model/epcis-coverage.js +27 -0
- package/dist-server/service/twin-model/epcis-coverage.js.map +1 -1
- package/dist-server/service/twin-model/isa95-coverage.js +85 -5
- package/dist-server/service/twin-model/isa95-coverage.js.map +1 -1
- package/dist-server/service/twin-model/item-ref.d.ts +3 -3
- package/dist-server/service/twin-model/item-ref.js +4 -4
- package/dist-server/service/twin-model/item-ref.js.map +1 -1
- package/package.json +3 -3
- package/server/engine/ingest-health.ts +78 -0
- package/server/engine/twin-engine.ts +199 -6
- package/server/service/reference/reference-adapter.ts +23 -0
- package/server/service/reference/reference-assessment.ts +39 -4
- package/server/service/reference/reference-live.ts +20 -0
- package/server/service/reference/reference-master.ts +12 -2
- package/server/service/twin-forecast/twin-forecast-query.ts +27 -1
- package/server/service/twin-model/epcis-coverage.ts +27 -0
- package/server/service/twin-model/isa95-coverage.ts +85 -5
- package/server/service/twin-model/item-ref.ts +4 -4
- package/test/checkpoint-refuses-empty.test.ts +103 -0
- package/test/cursor-stall-not-read-failure.test.ts +126 -0
- package/test/item-ref.test.ts +3 -3
- package/test/mirror-resumes-from-checkpoint.test.ts +192 -0
- package/test/revision-axis.test.ts +13 -2
- package/test/status-tally.test.ts +3 -3
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -58,7 +58,9 @@ import {
|
|
|
58
58
|
newIngestLedger,
|
|
59
59
|
recordIngest,
|
|
60
60
|
recordReadFailure,
|
|
61
|
+
clearCursorStall,
|
|
61
62
|
clearReadFailure,
|
|
63
|
+
recordCursorStall,
|
|
62
64
|
rollIngestWindow,
|
|
63
65
|
/* 별칭 — 같은 이름의 정적 메서드와 헷갈리지 않게(그 메서드가 이것을 부른다). */
|
|
64
66
|
recordJournalWrite as recordLedgerWrite,
|
|
@@ -396,10 +398,68 @@ export class TwinEngine {
|
|
|
396
398
|
*/
|
|
397
399
|
const state = unwrapState(this.snapshot(domainId, instanceId))
|
|
398
400
|
if (!state) return
|
|
401
|
+
/*
|
|
402
|
+
* ── **아무것도 듣지 못한 것을 「비었다」로 적지 않는다** (2026-08-24 실측) ────
|
|
403
|
+
*
|
|
404
|
+
* 미러가 재기동하면 관측 축(재고·오더·작업)을 들고 오지 않는다(`startLive` 가 되찾은 상태를
|
|
405
|
+
* 버린다 — 「다음 계측이 정정한다」는 전제). 그런데 원본이 **커서 증분**으로 말하는 현장에서는 그
|
|
406
|
+
* 정정이 오지 않는다: 커서가 따라잡힌 뒤 원본이 변하지 않으면 미러는 영구히 빈 채다.
|
|
407
|
+
*
|
|
408
|
+
* 그 상태에서 이 함수가 돌면 **빈 상태를 좋은 스냅샷 위에 덮는다.** 그래서 손실이 영구화됐다:
|
|
409
|
+
*
|
|
410
|
+
* 저장돼 있던 것 rev 221,884 · nowTime 2026-04-15 · items 741 · orders 2,780 · tasks 6,353
|
|
411
|
+
* 덮으려던 것 같은 키 · nowTime 2026-01-01 · 전부 0
|
|
412
|
+
*
|
|
413
|
+
* 리비전으로는 막을 수 없다 — 저널 줄 번호는 관측이 없어도 계속 자란다. 막는 기준은 **「들은 것이
|
|
414
|
+
* 있나」**다. 유입이 한 건도 없었다면 이 빈 상태는 **원본이 「비었다」고 말한 것이 아니라 우리가
|
|
415
|
+
* 아무것도 못 들은 것**이고, 그 둘을 같은 값으로 적으면 「모름」이 「없음」이 된다.
|
|
416
|
+
*
|
|
417
|
+
* 원본이 실제로 「다 비었다」고 말한 경우는 막지 않는다 — 그때는 유입이 있었으므로 이 문을 지난다.
|
|
418
|
+
*
|
|
419
|
+
* 그리고 **거절을 말한다**: 조용히 거절하면 왜 체크포인트가 낡아 가는지 아무도 모른다.
|
|
420
|
+
*/
|
|
421
|
+
if (!(inst.metrics?.ingestedTotal > 0)) {
|
|
422
|
+
const observed = (s: any) => (s?.items?.length ?? 0) + (s?.orders?.length ?? 0) + (s?.tasks?.length ?? 0)
|
|
423
|
+
if (observed(state) === 0) {
|
|
424
|
+
const prev = await this.loadSnapshot(domainId, instanceId).catch(() => null)
|
|
425
|
+
const had = observed(prev?.state)
|
|
426
|
+
if (had > 0) {
|
|
427
|
+
twinWarn(
|
|
428
|
+
`[twin-engine] "${instanceId}": checkpoint refused — this twin has heard nothing since start and its live ` +
|
|
429
|
+
`state is empty, while the stored snapshot holds ${had} observed fact(s) (revision ${prev?.revision}). ` +
|
|
430
|
+
'Writing the empty state would destroy the only recoverable copy. The journal still holds the truth.'
|
|
431
|
+
)
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
399
436
|
const revision = inst.revision ?? state.revision ?? 0
|
|
400
437
|
/* 구조 리비전도 함께 — 읽는 쪽이 "이 상태가 지금의 공장인가" 를 가릴 수 있어야 한다. */
|
|
401
438
|
const { structureRev } = await this.tipOf(domainId, instanceId).catch(() => ({ structureRev: null }) as any)
|
|
402
|
-
|
|
439
|
+
/*
|
|
440
|
+
* ── **이어 접을 씨앗을 함께 적는다** (2026-08-24) ──────────────────────────
|
|
441
|
+
*
|
|
442
|
+
* 이 함수는 오랫동안 `{revision, state, structureRev}` 만 적었다. 그래서 **재개점을 읽는 쪽은 다
|
|
443
|
+
* 있는데 쓰는 쪽이 없었다**: 조회 경로(`recover`)는 `fold` 가 있으면 꼬리만 접고, 없으면 저널을
|
|
444
|
+
* 0부터 접는다. 실측으로 저장된 스냅샷 34건 전부 `fold` 가 비어 있었고, 저널은 2,960만 줄이었다 —
|
|
445
|
+
* 그래서 재기동·조회마다 처음부터 다시 접었다. 규모 기준(엔티티 10만·품목 100만)에서 이것은
|
|
446
|
+
* 느린 것이 아니라 **못 하는 것**이다.
|
|
447
|
+
*
|
|
448
|
+
* 씨앗은 상태가 대신할 수 없다: 리듀서는 소비처가 보는 값 말고도 든다(부모를 기다리는 담김·집계
|
|
449
|
+
* 중인 수량·담을 줄 몰라 세어 둔 사건). 상태만 되돌리고 뒤를 접으면 0부터 접은 결과와 **조용히**
|
|
450
|
+
* 달라진다. 그 동치는 커널 시험이 증명한다(`observed-checkpoint.test.ts`).
|
|
451
|
+
*
|
|
452
|
+
* 관측 구동이 아니면 씨앗이 없다 — 시뮬은 리듀서를 갖지 않고, 그 상태의 권위는 커널 자신이다.
|
|
453
|
+
* 그때는 `fold` 를 **넣지 않는다**(빈 씨앗을 넣으면 읽는 쪽이 「이어 접을 수 있다」고 잘못 본다).
|
|
454
|
+
*/
|
|
455
|
+
const reducer: ReducerCheckpoint | undefined = inst.kernel?.observedCheckpoint?.()
|
|
456
|
+
const fold = reducer && inst.oee ? { reducer, oee: inst.oee.serialize() } : undefined
|
|
457
|
+
await cacheService.setInCache(
|
|
458
|
+
this.SNAPSHOT_CACHE_ID,
|
|
459
|
+
{ domainId, instanceId },
|
|
460
|
+
{ revision, state, structureRev, ...(fold ? { fold } : {}) },
|
|
461
|
+
this.SNAPSHOT_TTL_S
|
|
462
|
+
)
|
|
403
463
|
}
|
|
404
464
|
|
|
405
465
|
/**
|
|
@@ -410,6 +470,64 @@ export class TwinEngine {
|
|
|
410
470
|
*/
|
|
411
471
|
private static readonly FOLD_NOTE = 'reducer + oee checkpoint — the seed for folding only the tail'
|
|
412
472
|
|
|
473
|
+
/**
|
|
474
|
+
* 재기동에 쓸 **웜스타트 씨앗**을 만든다 — 상태 + 이어 접을 재개점.
|
|
475
|
+
*
|
|
476
|
+
* ── 왜 이 자리가 생겼나 (2026-08-24) ──────────────────────────────────────
|
|
477
|
+
* 두 호출부가 같은 일을 조금씩 다르게 하고 있었고(겹포장을 한쪽만 벗겼다), 둘 다 **재개점을 버리고**
|
|
478
|
+
* 상태만 들고 갔다. 그래서 저장된 재개점을 읽는 쪽이 다 있는데도 재기동은 매번 저널을 처음부터
|
|
479
|
+
* 접었다(실측: 저널 2,960만 줄).
|
|
480
|
+
*
|
|
481
|
+
* 여기서 하는 일 셋:
|
|
482
|
+
* ① 겹포장을 벗긴다 — 옛 형식으로 저장된 값이 한 번은 반드시 나온다
|
|
483
|
+
* ② **그 공장이 아직 그 공장인지** 심판한다 — 아니면 씨앗을 버린다(아래)
|
|
484
|
+
* ③ 씨앗이 저널 끝보다 앞서 있으면 **그 꼬리만 접어** 끝까지 밀어 둔다
|
|
485
|
+
*
|
|
486
|
+
* ②가 필요한 이유: 재개점은 그때의 보드 위에서 만들어진 것이다. 그 뒤 구조가 바뀌었다면(자리가
|
|
487
|
+
* 빠졌다·설비가 옮겨졌다) 되세운 리듀서는 **지금 없는 자리와 설비를 든다** — 없는 냉장실이 화면에
|
|
488
|
+
* 나오고 그 자리의 판정이 계속 돌아간다. 오류 없이 틀리므로 눈에 띄지 않는다.
|
|
489
|
+
*
|
|
490
|
+
* ③이 필요한 이유: 스냅샷은 체크포인트 주기로 쓰이므로 마지막 주기 이후의 사실은 저널에만 있다.
|
|
491
|
+
* 그것을 빼고 되세우면 그만큼이 조용히 사라진다 — 「모름」을 「없음」으로 적는 것과 같은 부류다.
|
|
492
|
+
* 접는 구간은 **그 틈뿐**이고(저널 전체가 아니다), `recover` 가 그 자리에서 새 재개점을 남겨 준다.
|
|
493
|
+
*/
|
|
494
|
+
private static async warmSeedFor(
|
|
495
|
+
domainId: string,
|
|
496
|
+
instanceId: string
|
|
497
|
+
): Promise<{ revision: number; state: any; fold?: { reducer: ReducerCheckpoint; oee: OeeCheckpoint } } | null> {
|
|
498
|
+
let cached = await this.loadSnapshot(domainId, instanceId).catch(() => null)
|
|
499
|
+
if (!cached?.state) return null
|
|
500
|
+
|
|
501
|
+
const seedOf = (c: typeof cached) => (c?.fold?.reducer ? { fold: c.fold } : {})
|
|
502
|
+
if (!cached.fold?.reducer) return { revision: cached.revision, state: unwrapState(cached.state) }
|
|
503
|
+
|
|
504
|
+
const tip = await this.tipOf(domainId, instanceId).catch(() => null)
|
|
505
|
+
if (tip && (cached.structureRev ?? null) !== tip.structureRev) {
|
|
506
|
+
twinLog(
|
|
507
|
+
`[twin-engine] "${instanceId}": the stored fold seed is from structure ${cached.structureRev} but the factory is now ` +
|
|
508
|
+
`at ${tip.structureRev} — starting without it (the journal is folded from the beginning instead).`
|
|
509
|
+
)
|
|
510
|
+
return { revision: cached.revision, state: unwrapState(cached.state) }
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (tip && (cached.revision ?? 0) < tip.revision) {
|
|
514
|
+
/*
|
|
515
|
+
* 틈을 접는다 — `recover` 가 이 씨앗으로 **꼬리만** 접고, 끝 지점의 새 재개점을 남긴다.
|
|
516
|
+
* 아직 이 트윈의 런타임이 없으므로 `recover` 는 메모리 대신 저널 경로를 탄다(그것이 여기의 전제다).
|
|
517
|
+
*/
|
|
518
|
+
const gap = tip.revision - (cached.revision ?? 0)
|
|
519
|
+
await this.recover(domainId, instanceId).catch(err =>
|
|
520
|
+
twinWarn(`[twin-engine] "${instanceId}": could not fold the ${gap} event(s) after the checkpoint — ${err?.message ?? err}`)
|
|
521
|
+
)
|
|
522
|
+
const advanced = await this.loadSnapshot(domainId, instanceId).catch(() => null)
|
|
523
|
+
if (advanced?.state && (advanced.revision ?? 0) > (cached.revision ?? 0)) {
|
|
524
|
+
twinLog(`[twin-engine] "${instanceId}": folded ${gap} event(s) after the checkpoint → revision ${advanced.revision}.`)
|
|
525
|
+
cached = advanced
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return { revision: cached.revision, state: unwrapState(cached.state), ...seedOf(cached) }
|
|
529
|
+
}
|
|
530
|
+
|
|
413
531
|
/** 체크포인트된 최신 스냅샷 로드(없으면 null). getFromCache 는 CacheStore 엔티티를 반환 → 페이로드는 .value. */
|
|
414
532
|
static async loadSnapshot(
|
|
415
533
|
domainId: string,
|
|
@@ -649,10 +767,9 @@ export class TwinEngine {
|
|
|
649
767
|
if (!row.domainId || !row.instanceId) continue
|
|
650
768
|
// 웜스타트: 캐시된 최신 스냅샷 우선(O(1) + attentions/OEE 등 라이브 파생상태 보존).
|
|
651
769
|
// 없으면 저널 fold-from-0 replay(진실 폴백 — replay 는 라이브 파생상태를 못 담으므로 캐시가 더 충실).
|
|
652
|
-
const cached = await this.
|
|
770
|
+
const cached = await this.warmSeedFor(row.domainId, row.instanceId).catch(() => null)
|
|
653
771
|
if (cached?.state) {
|
|
654
|
-
|
|
655
|
-
this.recovered[runtimeKey(row.domainId, row.instanceId)] = { revision: cached.revision, state: unwrapState(cached.state) }
|
|
772
|
+
this.recovered[runtimeKey(row.domainId, row.instanceId)] = cached
|
|
656
773
|
/*
|
|
657
774
|
* **「웜스타트했다」고 말하지 않는다** — 여기서는 상태를 **찾아 둔 것**뿐이다.
|
|
658
775
|
*
|
|
@@ -1470,6 +1587,44 @@ export class TwinEngine {
|
|
|
1470
1587
|
* 관측 축(재고·위치·설비)은 **여전히 심지 않는다** — 다음 계측이 정정하고, 심으면 떠난 물건이
|
|
1471
1588
|
* 되살아난다. 무엇을 넘길지는 `planLiveContinuity` 가 고르고, 어떻게 흡수할지는 커널이 정한다.
|
|
1472
1589
|
*/
|
|
1590
|
+
/*
|
|
1591
|
+
* ── **재개점에서 미러를 되세운다** (2026-08-24) ────────────────────────────
|
|
1592
|
+
*
|
|
1593
|
+
* 이 자리에서 미러는 오랫동안 되찾은 상태를 **버렸다**. 전제는 「진실은 원천에 있으니 다음 계측이
|
|
1594
|
+
* 정정한다」였고 라이브 피드에서는 옳았다. 그런데 원본이 **커서 증분**으로 말하는 현장에서는 그
|
|
1595
|
+
* 정정이 오지 않는다: 커서가 따라잡힌 뒤 원본이 변하지 않으면 미러는 영구히 빈 채로 남는다.
|
|
1596
|
+
* 그리고 그 빈 채로 화면이 「이상 없음」을 보였다 — 사실이 사라지는 동안 화면이 안심시킨 것이다.
|
|
1597
|
+
*
|
|
1598
|
+
* 되돌리는 것은 **상태가 아니라 재개점**이다. 상태만 심으면 그 뒤를 이어 접은 결과가 0부터 접은
|
|
1599
|
+
* 결과와 조용히 달라진다(리듀서는 보류된 담김·집계 중인 수량도 든다). 그 동치는 커널 시험이
|
|
1600
|
+
* 증명한다(`observed-checkpoint.test.ts` — 재개점 + 꼬리 == 0부터 접기).
|
|
1601
|
+
*
|
|
1602
|
+
* 씨앗은 **그 공장이 아직 그 공장일 때만** 오고, 마지막 체크포인트 이후의 사실은 이미 접혀 들어
|
|
1603
|
+
* 있다(`warmSeedFor`). 씨앗이 없으면 전과 같이 빈 채로 시작한다 — 지어내지 않는다.
|
|
1604
|
+
*/
|
|
1605
|
+
const seed = this.recovered[key]?.fold?.reducer
|
|
1606
|
+
if (seed) {
|
|
1607
|
+
if (typeof kernel.restoreObserved !== 'function') {
|
|
1608
|
+
twinWarn(
|
|
1609
|
+
`[twin-engine] "${id}": a fold seed is stored but this kernel cannot take it (no restoreObserved) — ` +
|
|
1610
|
+
'the mirror starts empty and waits for the source to re-tell everything. Upgrade the kernel.'
|
|
1611
|
+
)
|
|
1612
|
+
} else {
|
|
1613
|
+
try {
|
|
1614
|
+
kernel.restoreObserved(seed)
|
|
1615
|
+
const st = kernel.getSnapshot?.()
|
|
1616
|
+
twinLog(
|
|
1617
|
+
`[twin-engine] mirror "${id}" resumed from the stored fold seed at revision ${this.recovered[key]?.revision} — ` +
|
|
1618
|
+
`items ${st?.items?.length ?? 0} · orders ${st?.orders?.length ?? 0} · tasks ${st?.tasks?.length ?? 0} ` +
|
|
1619
|
+
'(the journal is not folded from the beginning).'
|
|
1620
|
+
)
|
|
1621
|
+
if (this.recovered[key]?.fold?.oee) inst.oee?.restore(this.recovered[key].fold.oee)
|
|
1622
|
+
} catch (err: any) {
|
|
1623
|
+
/* 되세우기가 실패해도 미러는 돌아야 한다 — 다만 무엇을 잃었는지 말한다. */
|
|
1624
|
+
twinWarn(`[twin-engine] "${id}": could not resume from the stored fold seed — starting empty: ${err?.message ?? err}`)
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1473
1628
|
this.seedLiveContinuity(domainId, id, kernel)
|
|
1474
1629
|
delete this.recovered[key]
|
|
1475
1630
|
/* 라이브 바인딩(data 채널) subdomain 필터용 Domain 1회 해석(sim 과 동일). */
|
|
@@ -2281,9 +2436,9 @@ export class TwinEngine {
|
|
|
2281
2436
|
* 체크포인트 캐시 우선(O(1) + 라이브 파생상태 보존), 없으면 저널 replay 폴백(부팅과 같은 순서).
|
|
2282
2437
|
*/
|
|
2283
2438
|
if (!this.recovered[key] && reg.purpose !== 'bench') {
|
|
2284
|
-
const cached = await this.
|
|
2439
|
+
const cached = await this.warmSeedFor(domainId, instanceId).catch(() => null)
|
|
2285
2440
|
if (cached?.state) {
|
|
2286
|
-
this.recovered[key] =
|
|
2441
|
+
this.recovered[key] = cached
|
|
2287
2442
|
} else {
|
|
2288
2443
|
const state = await this.recover(domainId, instanceId).catch(() => null)
|
|
2289
2444
|
if (state) this.recovered[key] = { revision: state.revision, state }
|
|
@@ -2332,6 +2487,23 @@ export class TwinEngine {
|
|
|
2332
2487
|
if (mode === 'resync') {
|
|
2333
2488
|
const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
2334
2489
|
if (!reg?.model) throw new Error(`instance "${instanceId}" not provisioned (no model)`)
|
|
2490
|
+
/*
|
|
2491
|
+
* ── **미러도 씨앗을 여기서 확보한다** (2026-08-24) ────────────────────────
|
|
2492
|
+
*
|
|
2493
|
+
* 이 줄이 없어서 미러는 저장된 체크포인트를 **한 번도 읽지 않았다.** `startLive` 는 동기라 스스로
|
|
2494
|
+
* 캐시를 읽을 수 없고 `recovered` 에 담겨 있기를 기대하는데, 그것을 담는 곳은 `bootstrap` 과
|
|
2495
|
+
* `start` 뿐이었다 — 그리고 미러의 실제 기동 경로는 **여기**다. 그래서 「읽는 쪽·쓰는 쪽이 다
|
|
2496
|
+
* 있는데 아무 일도 일어나지 않는」 상태가 됐다.
|
|
2497
|
+
*
|
|
2498
|
+
* 이것은 `start` 가 이미 배운 교훈과 같은 자리다: 부팅 순서에 기대면 어떤 날은 상태가 살아나고
|
|
2499
|
+
* 어떤 날은 조용히 빈 채로 뜬다 — **재현되지 않는 결함이 가장 나쁘다.** 그래서 순서에 기대지 않고
|
|
2500
|
+
* 이 자리에서 확보한다(이미 담겨 있으면 그것을 쓴다).
|
|
2501
|
+
*/
|
|
2502
|
+
const key = runtimeKey(domainId, instanceId)
|
|
2503
|
+
if (!this.recovered[key]) {
|
|
2504
|
+
const seed = await this.warmSeedFor(domainId, instanceId).catch(() => null)
|
|
2505
|
+
if (seed?.state) this.recovered[key] = seed
|
|
2506
|
+
}
|
|
2335
2507
|
return this.startLive(instanceId, domainId, reg.kind, reg.model as TwinModelDef)
|
|
2336
2508
|
}
|
|
2337
2509
|
if (mode === 'resume') {
|
|
@@ -3732,6 +3904,27 @@ export class TwinEngine {
|
|
|
3732
3904
|
if (ledger) clearReadFailure(ledger)
|
|
3733
3905
|
}
|
|
3734
3906
|
|
|
3907
|
+
/**
|
|
3908
|
+
* **읽었는데 창을 넘길 수 없다**를 적는다 — 위와 **조치가 반대인** 사실이다(§`recordCursorStall`).
|
|
3909
|
+
*
|
|
3910
|
+
* 이 문이 없던 동안 이 사실이 `recordIngestReadFailure` 로 나갔다. 그래서 화면이 「원본에 닿지
|
|
3911
|
+
* 못한다」고 말했는데 원본은 **답한** 상태였고, 그 답의 모양이 커서를 이긴 것이었다(한 시각에 한
|
|
3912
|
+
* 페이지보다 많은 행). 사람은 원본을 의심하고 기다리는데 **기다림으로는 영원히 풀리지 않는다.**
|
|
3913
|
+
*
|
|
3914
|
+
* 조치가 반대인 두 사실을 한 이름으로 부르면 그 이름은 정보가 아니라 오해다.
|
|
3915
|
+
*/
|
|
3916
|
+
static recordIngestCursorStall(domainId: string, instanceId: string, reason: string, nowMs = Date.now(), stream?: string): void {
|
|
3917
|
+
const key = runtimeKey(domainId, instanceId)
|
|
3918
|
+
const ledger = this.ingestLedgers[key] ?? (this.ingestLedgers[key] = newIngestLedger())
|
|
3919
|
+
recordCursorStall(ledger, reason, nowMs, stream)
|
|
3920
|
+
}
|
|
3921
|
+
|
|
3922
|
+
/** 창을 넘겼다 — 정체 기록을 지운다(풀린 정체가 화면에 남아 있으면 그것도 거짓이다). */
|
|
3923
|
+
static clearIngestCursorStall(domainId: string, instanceId: string): void {
|
|
3924
|
+
const ledger = this.ingestLedgers[runtimeKey(domainId, instanceId)]
|
|
3925
|
+
if (ledger) clearCursorStall(ledger)
|
|
3926
|
+
}
|
|
3927
|
+
|
|
3735
3928
|
/**
|
|
3736
3929
|
* 저널에 **적은 것**을 같은 장부에 남긴다 — 유입과 같은 10분 창에.
|
|
3737
3930
|
*
|
|
@@ -154,6 +154,29 @@ export interface LiveFeedContinuity {
|
|
|
154
154
|
* 로 알린다. 그 둘을 섞으면 조용한 원본이 끊긴 원본으로 보인다(고치려던 것의 반대 방향으로 틀린다).
|
|
155
155
|
*/
|
|
156
156
|
onReadFailure?: (info: { reason: string; stream?: string }) => void
|
|
157
|
+
/**
|
|
158
|
+
* **읽었는데 창을 넘길 수 없다** — 어댑터가 커서 정체를 알린다 (2026-08-24).
|
|
159
|
+
*
|
|
160
|
+
* ── 왜 `onReadFailure` 와 갈라야 하나 ───────────────────────────────────────
|
|
161
|
+
* 이 문을 만들기 전에는 두 사실이 **같은 이름으로** 나갔다.
|
|
162
|
+
*
|
|
163
|
+
* 원본에 닿지 못했다 접속 실패·시간 초과·형식 오류 → 기다리면 풀린다
|
|
164
|
+
* 읽었는데 커서가 못 넘어간다 한 시각에 한 페이지보다 많은 행이 몰려 있다 → **기다려도 안 풀린다**
|
|
165
|
+
*
|
|
166
|
+
* 둘째는 **읽기가 성공한 실패**다. 원본은 답했고, 그 답의 모양이 커서를 이긴 것이다. 그런데 화면이
|
|
167
|
+
* 「원본에 닿지 못한다」고 말하면 사람을 반대 방향으로 보낸다 — 원본을 의심하고 기다린다. 실제로
|
|
168
|
+
* 필요한 조치는 **페이지를 키우거나 같은 시각 안에서 순서를 정하는 것**이고, 기다림으로는 영원히
|
|
169
|
+
* 풀리지 않는다.
|
|
170
|
+
*
|
|
171
|
+
* 조치가 반대인 두 사실을 한 이름으로 부르면, 그 이름은 정보가 아니라 오해다.
|
|
172
|
+
*
|
|
173
|
+
* ── 무엇을 알리나 ───────────────────────────────────────────────────────────
|
|
174
|
+
* 그 주기를 포기했을 때 부른다(`onReadFailure` 와 같은 규율). 어느 흐름인지 알면 함께 준다 —
|
|
175
|
+
* 밀도가 높은 표는 원본마다 다르므로 그 이름이 조치의 절반이다.
|
|
176
|
+
*
|
|
177
|
+
* **닿지 못한 것과 섞어 부르지 않는다.** 하나의 주기가 두 이유로 실패할 수는 없다(먼저 닿아야 읽는다).
|
|
178
|
+
*/
|
|
179
|
+
onCursorStall?: (info: { reason: string; stream?: string }) => void
|
|
157
180
|
}
|
|
158
181
|
|
|
159
182
|
export interface ReferenceAdapter {
|
|
@@ -18,7 +18,7 @@ import type { IngestWarning, ReferenceMaster } from './reference-master.js'
|
|
|
18
18
|
* 그래서 사실 층이 스스로 완결이다.
|
|
19
19
|
*
|
|
20
20
|
* ── 무엇을 지어내지 않는가 ──────────────────────────────────────────────────
|
|
21
|
-
* 「없음」의 뜻을 셋으로
|
|
21
|
+
* 「없음」의 뜻을 셋으로 구분해 둔다. 뭉치면 조치가 달라지는 것들이 한 칸에 섞인다.
|
|
22
22
|
* · `gaps` — 원본에 있는데 우리가 옮기지 못했다. 고칠 대상이 있다(원본의 빈 칸이거나 우리 매핑).
|
|
23
23
|
* · `absent` — 이 원본에 그 **개념이 없다.** 빠진 것이 아니므로 고칠 것이 없다.
|
|
24
24
|
* · `grounding` — 값이 들어왔지만 **근거가 저장된 값이 아니다**(원본의 계산 규칙에서 왔다).
|
|
@@ -40,6 +40,16 @@ export interface AssessmentCounts {
|
|
|
40
40
|
/** 공정을 말하는 투입 / 전체 투입 — 공정별 소요를 아는 정도. */
|
|
41
41
|
taggedInputs?: number
|
|
42
42
|
totalInputs?: number
|
|
43
|
+
/** 시험 명세(점검표)의 수 — 선언되지 않았으면 없다(0 과 구별한다). */
|
|
44
|
+
testSpecifications?: number
|
|
45
|
+
/** 그 명세들이 선언한 판정 기준의 수. */
|
|
46
|
+
criteria?: number
|
|
47
|
+
/**
|
|
48
|
+
* 그중 **아무 한계도 말하지 않는** 기준의 수 — 커널의 `criterionSaysNothing` 과 같은 판정이다.
|
|
49
|
+
*
|
|
50
|
+
* 이 수가 0 이 아니면 「관리점이 있다」가 「판정된다」를 뜻하지 않는다. 세지 않으면 그 구별이 사라진다.
|
|
51
|
+
*/
|
|
52
|
+
criteriaWithoutLimit?: number
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
/** 옮기지 못한 것 한 갈래 — 건수 큰 것부터. */
|
|
@@ -65,7 +75,7 @@ export interface ImportAssessment {
|
|
|
65
75
|
* 이름을 `origin` 이라 하지 않는다 — 화면에서 그 낱말이 이미 **트윈의 출처**(`summary.origin`:
|
|
66
76
|
* 템플릿인가 원본인가)를 뜻한다. 같은 이름을 두 뜻으로 쓰면 읽는 사람이 헷갈린다.
|
|
67
77
|
*
|
|
68
|
-
* `built` 와
|
|
78
|
+
* `built` 와 비교해 「전부 옮겼는가」를 사람이 직접 셀 수 있게 한다. 커넥터가 말하지 않으면 이 자리는
|
|
69
79
|
* 비어 있고, 화면은 분모 없이 옮긴 수만 보인다 — **분모를 지어내지 않는다.**
|
|
70
80
|
*/
|
|
71
81
|
originCounts?: Record<string, number>
|
|
@@ -84,7 +94,7 @@ export interface ImportAssessment {
|
|
|
84
94
|
notes: IngestWarning[]
|
|
85
95
|
}
|
|
86
96
|
|
|
87
|
-
/** 이 마스터가 개념을 선언했나 — 「없다」와 「비어 있다」를
|
|
97
|
+
/** 이 마스터가 개념을 선언했나 — 「없다」와 「비어 있다」를 구분한다. */
|
|
88
98
|
const declared = (v: unknown): boolean => Array.isArray(v) ? v.length > 0 : v !== undefined && v !== null
|
|
89
99
|
|
|
90
100
|
/**
|
|
@@ -105,6 +115,21 @@ export function assessMaster(master: ReferenceMaster, siteId: string): ImportAss
|
|
|
105
115
|
equipment: (m.equipment ?? []).length,
|
|
106
116
|
materialDefinitions: (m.materialDefinitions ?? []).length,
|
|
107
117
|
operations: (m.operations ?? []).length,
|
|
118
|
+
...(declared(m.testSpecifications)
|
|
119
|
+
? {
|
|
120
|
+
testSpecifications: m.testSpecifications!.length,
|
|
121
|
+
criteria: m.testSpecifications!.reduce((n, t) => n + (t.criteria?.length ?? 0), 0),
|
|
122
|
+
/*
|
|
123
|
+
* **한계를 말하지 않는 기준** — 커널의 `criterionSaysNothing` 과 같은 판정이다. 기준이
|
|
124
|
+
* 선언됐다는 사실만 있고 판정할 재료가 없는 자리이고, 그것을 세지 않으면 「관리점이 있다」가
|
|
125
|
+
* 「판정된다」로 읽힌다.
|
|
126
|
+
*/
|
|
127
|
+
criteriaWithoutLimit: m.testSpecifications!.reduce(
|
|
128
|
+
(n, t) => n + (t.criteria ?? []).filter(c => !c.expression && c.limit?.minimum === undefined && c.limit?.maximum === undefined).length,
|
|
129
|
+
0
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
: {}),
|
|
108
133
|
...(d
|
|
109
134
|
? {
|
|
110
135
|
materials: (d.materials ?? []).length,
|
|
@@ -126,7 +151,7 @@ export function assessMaster(master: ReferenceMaster, siteId: string): ImportAss
|
|
|
126
151
|
const notes: IngestWarning[] = []
|
|
127
152
|
/*
|
|
128
153
|
* 커넥터가 「대체」 수집기로 남기는 것 중 일부는 **빈 자리가 아니라 파생된 값**이다(산출량이 원본의
|
|
129
|
-
* 계산 규칙에서 온 경우처럼). 같은 통로로 오지만 뜻이 달라
|
|
154
|
+
* 계산 규칙에서 온 경우처럼). 같은 통로로 오지만 뜻이 달라 구분해 담는다 — 이름에 그 사실이 적혀 있다.
|
|
130
155
|
*/
|
|
131
156
|
const isDerived = (field: string) => /derived|not a stored field/i.test(field)
|
|
132
157
|
for (const w of m.warnings ?? []) {
|
|
@@ -209,6 +234,16 @@ export function formatAssessment(a: ImportAssessment): string {
|
|
|
209
234
|
out.push(' 파생된 값(저장된 값이 아니다):')
|
|
210
235
|
for (const g of a.derived) out.push(` ${g.what}${g.sample ? ` — ${g.sample}` : ''}`)
|
|
211
236
|
}
|
|
237
|
+
if (c.testSpecifications) {
|
|
238
|
+
/*
|
|
239
|
+
* 관리 기준은 계측이 오는지와 무관하게 값이 있다 — 「관리점이 셋인데 둘은 아무 한계도 말하지
|
|
240
|
+
* 않는다」를 여기서 볼 수 있어야 판정에 근거가 있는지 사람이 안다.
|
|
241
|
+
*/
|
|
242
|
+
out.push(
|
|
243
|
+
` 관리 기준: 점검표 ${n('testSpecifications', c.testSpecifications)} · 기준 ${c.criteria ?? 0}` +
|
|
244
|
+
(c.criteriaWithoutLimit ? ` (한계를 말하지 않는 것 ${c.criteriaWithoutLimit})` : '')
|
|
245
|
+
)
|
|
246
|
+
}
|
|
212
247
|
if (a.absent.length) out.push(` 이 원본에 개념이 없는 자리: ${a.absent.join(', ')}`)
|
|
213
248
|
for (const n of a.notes) out.push(` 참고 ${n.code}: ${n.message}`)
|
|
214
249
|
return out.join('\n')
|
|
@@ -164,6 +164,26 @@ async function loadLiveCursor(
|
|
|
164
164
|
.update({ id: row.id }, { lastError: `live: ${reason}` } as any)
|
|
165
165
|
.catch(err => twinWarn(`[twin-live] "${source}": read failure not recorded — ${err?.message ?? err}`))
|
|
166
166
|
},
|
|
167
|
+
/**
|
|
168
|
+
* **읽었는데 창을 넘길 수 없다** — 위와 조치가 반대인 사실이다 (2026-08-24).
|
|
169
|
+
*
|
|
170
|
+
* 이 문을 만들기 전에는 이 사실이 `onReadFailure` 로 나갔다. 그래서 화면이 「원본에 닿지 못한다」고
|
|
171
|
+
* 말했는데 실제로는 원본이 **답한** 상태였고, 그 답의 모양이 커서를 이긴 것이었다. 사람은 원본을
|
|
172
|
+
* 의심하고 기다리는데 기다림으로는 영원히 풀리지 않는다 — **원인을 반대 방향으로 가리켰다.**
|
|
173
|
+
*
|
|
174
|
+
* `status` 를 건드리지 않는 규율은 위와 같다(재부착의 자격이다). `lastError` 에는 적는다 — 목록에서
|
|
175
|
+
* 이유를 볼 수 있어야 하고, 「닿지 못한다」와 다른 문장이어야 한다.
|
|
176
|
+
*/
|
|
177
|
+
onCursorStall: ({ reason, stream }) => {
|
|
178
|
+
TwinEngine.recordIngestCursorStall(domainId, instanceId, reason, Date.now(), stream)
|
|
179
|
+
twinWarn(
|
|
180
|
+
`[twin-live] "${source}": read the source but the cursor cannot advance` +
|
|
181
|
+
`${stream ? ` (${stream})` : ''} — ${reason}. Waiting will not clear this.`
|
|
182
|
+
)
|
|
183
|
+
repo
|
|
184
|
+
.update({ id: row.id }, { lastError: `live cursor stalled: ${reason}` } as any)
|
|
185
|
+
.catch(err => twinWarn(`[twin-live] "${source}": cursor stall not recorded — ${err?.message ?? err}`))
|
|
186
|
+
},
|
|
167
187
|
onCursor: next => {
|
|
168
188
|
/* 어댑터가 준 모양을 그대로 적는다 — 이 층은 흐름의 뜻을 모른다(열쇠는 어댑터가 정한다). */
|
|
169
189
|
repo
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 좌표·representations 는 트윈 공간층(보완/추가). 실 시스템이 좌표를 안 주면 layout 없이 위상만 인제스트.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type { DomainSystem, EquipmentLevel, WorkCalendarEntry } from '@operato/twin-kernel'
|
|
10
|
+
import type { DomainSystem, EquipmentLevel, TestSpecificationCriterion, WorkCalendarEntry } from '@operato/twin-kernel'
|
|
11
11
|
|
|
12
12
|
export interface RefLocation {
|
|
13
13
|
id: string
|
|
@@ -127,6 +127,16 @@ export interface RefTestSpecification {
|
|
|
127
127
|
/** 표시명 — i18n 키일 수 있다. 읽는 어휘를 하나로 맞추려고 인제스트가 `name` 으로 옮긴다. */
|
|
128
128
|
description?: string
|
|
129
129
|
version?: string
|
|
130
|
+
/**
|
|
131
|
+
* 판정 기준들 — 커널 계약의 `TestSpecification.criteria` 를 그대로 나른다.
|
|
132
|
+
*
|
|
133
|
+
* **경계에서 어휘를 갈지 않는다.** 커널 타입을 그대로 쓰므로 `limit.{minimum,maximum,uom}` ·
|
|
134
|
+
* `expression` · `evaluatedPropertyId` 가 원본에서 커널까지 한 이름으로 간다.
|
|
135
|
+
*
|
|
136
|
+
* 선언하지 않아도 명세는 성립한다(이름만으로 「무엇으로 검증했나」에 답한다). 선언하면 **판정에
|
|
137
|
+
* 근거가 있는지**를 물을 수 있게 된다 — 커널의 `criterionSaysNothing` 이 그것을 센다.
|
|
138
|
+
*/
|
|
139
|
+
criteria?: TestSpecificationCriterion[]
|
|
130
140
|
}
|
|
131
141
|
|
|
132
142
|
export interface RefEquipment {
|
|
@@ -350,7 +360,7 @@ export type MasterOrigin =
|
|
|
350
360
|
* `source` 는 **레퍼런스 등록 이름**이고 그 참조가 보고하는 사이트 전부에 같다. 그래서 어느 사이트에서
|
|
351
361
|
* 온 트윈인지는 `siteId` 가 있어야 안다 — 없으면 다시 읽기가 사이트를 찾지 못한다(2026-08-18 실측:
|
|
352
362
|
* 사이트가 여러 개인 참조에서 온 트윈은 재동기가 전부 거절됐다. 참조 이름을 사이트별 마스터의
|
|
353
|
-
* `source`(=인스턴스 id)와
|
|
363
|
+
* `source`(=인스턴스 id)와 비교하고 있었다 — 서로 다른 것을 비교한 것이다).
|
|
354
364
|
*/
|
|
355
365
|
| { kind: 'reference'; source: string; siteId?: string; spaceId?: string }
|
|
356
366
|
/**
|
|
@@ -331,7 +331,33 @@ export class TwinForecastQuery {
|
|
|
331
331
|
calibrated: !!cal,
|
|
332
332
|
/* 학습해 뒀지만 모델이 바뀌어 적용하지 않았다 — 사용자가 다시 배울 수 있게 알린다. */
|
|
333
333
|
...(stale ? { calibrationStale: true } : {}),
|
|
334
|
-
...(specCoverage ? { specCoverage } : {})
|
|
334
|
+
...(specCoverage ? { specCoverage } : {}),
|
|
335
|
+
/*
|
|
336
|
+
* **계보에 구멍이 몇 칸 생겼나** — 개체를 잇지 못한 채 넘어간 공정 수.
|
|
337
|
+
*
|
|
338
|
+
* ── 왜 이 값을 예측 응답에 싣나 (2026-08-24) ──────────────────────────────
|
|
339
|
+
* 미러의 오더는 확보분을 갖지 않는다(원본이 「어느 개체가 잡혀 있나」를 말하지 않는 것이 그
|
|
340
|
+
* 시스템의 정상이다). 커널은 그 자리에서 **던지지 않고 넘긴다** — 단계를 넘는 것은 공정의
|
|
341
|
+
* 진행이고 개체 번호가 필요 없다. 대신 넘긴 횟수를 센다.
|
|
342
|
+
*
|
|
343
|
+
* 그런데 그 카운터는 **fork 안에** 있고 fork 는 버려진다. 여기서 싣지 않으면 아무에게도 닿지
|
|
344
|
+
* 않는다 — 리듀서가 오래 세어 온 값을 스냅샷이 떨어뜨려 그것을 읽도록 쓰인 검사가 영원히
|
|
345
|
+
* 조용했던 것과 **같은 실수**다. 세어 놓고 내보내지 않으면 안 센 것과 같다.
|
|
346
|
+
*
|
|
347
|
+
* 왜 화면이 이것을 알아야 하나: 계보는 **회수 범위**를 정하는 사슬이다. 구멍이 있는데 온전한
|
|
348
|
+
* 것처럼 보이면 회수가 조용히 좁아진다. 식품에서 그것은 되돌릴 수 없다.
|
|
349
|
+
*
|
|
350
|
+
* 두 곳에서 읽어 **큰 값**을 낸다 — 분포(회차 R개의 최대)와 궤적 fork 는 **둘 다 실제로 실행됐다**.
|
|
351
|
+
* 한쪽만 보면 다른 쪽이 겪은 구멍을 잃는다.
|
|
352
|
+
*/
|
|
353
|
+
...(() => {
|
|
354
|
+
const gaps = Math.max(
|
|
355
|
+
(distribution as any)?.stepsWithoutMaterial ?? 0,
|
|
356
|
+
(fc as any)?.getSnapshot?.()?.stepsWithoutMaterial ?? 0
|
|
357
|
+
)
|
|
358
|
+
/* 0 은 싣지 않는다 — 「구멍이 없다」와 「이 축을 모른다」를 화면이 구별할 수 있게. */
|
|
359
|
+
return gaps > 0 ? { stepsWithoutMaterial: gaps } : {}
|
|
360
|
+
})()
|
|
335
361
|
}
|
|
336
362
|
}
|
|
337
363
|
|
|
@@ -86,6 +86,20 @@ const FIELDS: Isa95Concept[] = [
|
|
|
86
86
|
surface: 'full',
|
|
87
87
|
note: 'twin.epcis.note.identity'
|
|
88
88
|
},
|
|
89
|
+
/*
|
|
90
|
+
* ── 2026-08-24 주장 근거가 달라졌다 ────────────────────────────────────────
|
|
91
|
+
* 이 줄은 오래 `full` 이었지만 근거가 **우리 선언**이었다. 이제 **정본 원문 대조**다:
|
|
92
|
+
* CBV Standard Release 2.0(Ratified Jun 2022) §7.2.3 의 처분 38개를 전수 확인했다.
|
|
93
|
+
*
|
|
94
|
+
* 그 대조가 실제로 셋을 바꿨다.
|
|
95
|
+
* · `expired` — 있는 낱말인데 우리가 쓰지 않고 있었다(기한 경과를 처분으로 말할 수 있게 됐다)
|
|
96
|
+
* · `conformant` / `non_conformant` — 검사 판정을 처분으로 남기는 낱말. 함께 `inspecting` bizStep
|
|
97
|
+
* · `non_sellable_expired` 는 표준이 **폐기**하고 `expired` 로 대체한 것을 확인
|
|
98
|
+
*
|
|
99
|
+
* ★ 그리고 **검증 방법에 함정이 있다**: `ref.gs1.org/cbv/…` 로 URN 을 조회하면 **지어낸 값에도
|
|
100
|
+
* 똑같은 응답**이 온다. 그것으로 확인했다고 여기면 없는 낱말을 발행한다(한 번 그렇게 했다).
|
|
101
|
+
* 확인은 정본 문서로만 한다.
|
|
102
|
+
*/
|
|
89
103
|
{
|
|
90
104
|
std: 'bizStep / disposition (CBV)',
|
|
91
105
|
part: 'fields',
|
|
@@ -160,6 +174,19 @@ const FIELDS: Isa95Concept[] = [
|
|
|
160
174
|
surface: 'none',
|
|
161
175
|
note: 'twin.epcis.note.persistentDisposition'
|
|
162
176
|
},
|
|
177
|
+
/*
|
|
178
|
+
* ── 2026-08-24 계측이 어디로 들어오는지 확정됐다 ──────────────────────────
|
|
179
|
+
* 「계측은 EPCIS 센서 필드로 오지 않는다」는 사실이 이제 **자리를 갖는다**: 자리의 상시 관측은
|
|
180
|
+
* ISA-95 `OperationsEvent` 로 들어오고(커널 `location.measured` 채널), 에너지는 그 위의 누적기가
|
|
181
|
+
* 받는다. ISA-95 표의 `OperationsEvent` 줄이 그 판정을 든다.
|
|
182
|
+
*
|
|
183
|
+
* 왜 EPCIS 가 아닌가: `SensorElement` 는 **개체에 붙는** 관측이고, 냉장실의 온도는 그 안의 물건
|
|
184
|
+
* 수백 개와 관계되며 그 수백 개는 시간에 따라 바뀐다. 개체마다 붙이면 같은 사실이 수백 벌이 되고,
|
|
185
|
+
* 물건이 떠나면 그 방의 온도 이력이 함께 사라진다. 주인은 **자리**다.
|
|
186
|
+
*
|
|
187
|
+
* 그래서 이 줄은 `behavior: 'none'` 으로 남는다 — 결손이 아니라 **다른 층으로 들어온다는 사실**이다.
|
|
188
|
+
* 그 구별을 표가 말하지 않으면 「센서를 못 받는 트윈」으로 읽힌다.
|
|
189
|
+
*/
|
|
163
190
|
{
|
|
164
191
|
std: 'sensorElementList',
|
|
165
192
|
part: 'fields',
|
|
@@ -102,9 +102,39 @@ const PART2: Isa95Concept[] = [
|
|
|
102
102
|
{ std: 'PhysicalAsset', part: '2', label: 'twin.isa95.PhysicalAsset', axis: 'assets', structure: 'full', behavior: 'full', surface: 'full' },
|
|
103
103
|
{ std: 'MaterialClass', part: '2', label: 'twin.isa95.MaterialClass', axis: 'materialClasses', structure: 'full', behavior: 'partial', surface: 'full' },
|
|
104
104
|
{ std: 'MaterialDefinition', part: '2', label: 'twin.isa95.MaterialDefinition', axis: 'materialDefinitions', structure: 'full', behavior: 'full', surface: 'full' },
|
|
105
|
-
/*
|
|
105
|
+
/*
|
|
106
|
+
* 로트·서브로트는 개체 축이 아니라 **관측**으로 존재한다(EPCIS 개체) — 구조는 저널이 갖는다.
|
|
107
|
+
*
|
|
108
|
+
* ── 2026-08-24 로트가 시험 결과를 들게 됐다 (커널 0.7.57) ─────────────────
|
|
109
|
+
* `ItemState.testResults` 로 「이 로트를 쓸 수 있나」의 **근거**가 로트에 실린다. 그전에는 판정
|
|
110
|
+
* (`disposition`)만 있었고 「왜 그렇게 판정했나」를 되짚을 수 없었다.
|
|
111
|
+
*
|
|
112
|
+
* **구조를 `full` 로 올리지 않는다.** `MaterialLotType` 원문(B2MML-Material.xsd)이 드는 것 중
|
|
113
|
+
* 아직 없는 것이 있다: `MaterialLotProperty`(시험이 아닌 로트 속성 — 원산지·포장 형태) ·
|
|
114
|
+
* `TestSpecificationID`(로트별 기준 참조 — 품목 정의가 대신 답하므로 두지 않았다) · `Status` ·
|
|
115
|
+
* `HierarchyScope` · `Version`. 채운 것만 세는 것이 이 표의 규율이다.
|
|
116
|
+
*/
|
|
106
117
|
{ std: 'MaterialLot', part: '2', label: 'twin.isa95.MaterialLot', axis: null, structure: 'partial', behavior: 'full', surface: 'partial', note: 'twin.isa95.note.lot' },
|
|
107
|
-
|
|
118
|
+
/*
|
|
119
|
+
* ── 2026-08-24 판정 정정: `none` 이 아니었다 ──────────────────────────────
|
|
120
|
+
* 이 줄은 셋 다 `none` 이었는데 **표가 코드보다 뒤처져 있었다.** 커널은 서브로트를 든다:
|
|
121
|
+
*
|
|
122
|
+
* `ItemState.subLotId` 표준 `MaterialSubLot.ID` (계약이 그렇게 적고 있다)
|
|
123
|
+
* `subLotIdOf(class, location)` 비직렬 로트가 자리마다 나뉠 때 그 부분의 이름을 만든다
|
|
124
|
+
* `itemKeyOf(item)` **개체의 정체성이 이 값이다** — `subLotId ?? epc`
|
|
125
|
+
*
|
|
126
|
+
* 기능이 `full` 인 근거: 이 축이 없으면 같은 로트를 rack-1 에 100개·rack-2 에 60개 관측했을 때
|
|
127
|
+
* **뒤에 온 관측이 앞을 덮어 100개가 조용히 사라진다**(합계 160 → 60). 실제로 그 결함을 이 축으로
|
|
128
|
+
* 고쳤다. 즉 장식이 아니라 정체성을 지탱한다.
|
|
129
|
+
*
|
|
130
|
+
* 구조가 `partial` 인 이유: 서브로트는 로트와 같은 모양이고(위 참조) 그 로트가 `partial` 이다.
|
|
131
|
+
* 화면이 `partial` 인 이유: 값이 물품의 **이름으로** 나온다(`twin-item` 의 line) — 「이 로트가 자리마다
|
|
132
|
+
* 나뉘어 있다」를 말하는 자리는 아직 없다.
|
|
133
|
+
*
|
|
134
|
+
* 이 부류(코드에 있는데 표가 `none`)는 자동으로 잡히지 않는다 — 표의 가드는 반대 방향만 본다
|
|
135
|
+
* (축이 사라지면 구조를 내린다). 그래서 축을 늘릴 때 이 표를 함께 보는 것이 규율이다.
|
|
136
|
+
*/
|
|
137
|
+
{ std: 'MaterialSublot', part: '2', label: 'twin.isa95.MaterialSublot', axis: null, structure: 'partial', behavior: 'full', surface: 'partial' },
|
|
108
138
|
{ std: 'ProcessSegment', part: '2', label: 'twin.isa95.ProcessSegment', axis: 'operations', structure: 'full', behavior: 'full', surface: 'full' },
|
|
109
139
|
/*
|
|
110
140
|
* 속성은 자원마다 붙는 확장이라 **자기 축이 아니다** — 그래서 `axis` 는 없지만 구조는 완전하다:
|
|
@@ -114,7 +144,30 @@ const PART2: Isa95Concept[] = [
|
|
|
114
144
|
* 기능은 **부분**이다: 호스트의 주행 추정기가 `speed` 를 읽어 이동 시간을 만드는 것이 유일한 소비처다
|
|
115
145
|
* (다른 속성은 아직 아무도 읽지 않는다). 그 사실을 `full` 로 올리면 표가 거짓이 된다.
|
|
116
146
|
*/
|
|
117
|
-
{ std: 'Property', part: '2', label: 'twin.isa95.Property', axis: null, structure: 'full', behavior: 'partial', surface: 'full', note: 'twin.isa95.note.property' }
|
|
147
|
+
{ std: 'Property', part: '2', label: 'twin.isa95.Property', axis: null, structure: 'full', behavior: 'partial', surface: 'full', note: 'twin.isa95.note.property' },
|
|
148
|
+
/*
|
|
149
|
+
* **자리의 상시 관측** — 2026-08-24 신설(커널 0.7.56). 표에 이 개념이 아예 없었다.
|
|
150
|
+
*
|
|
151
|
+
* 왜 이 축이 필요한가: 트윈의 판정 대상은 물건의 상태이고, 물건의 상태는 **조건 없이 정해지지
|
|
152
|
+
* 않는다.** 「이 로트가 냉장실에 있었다」까지만 아는 트윈은 그 로트가 괜찮았는지 말할 수 없다. 그리고
|
|
153
|
+
* **이 조인은 트윈만 할 수 있다** — 계측 시스템은 물건의 자리 이력을 모르고, 물류 시스템은 조건
|
|
154
|
+
* 이력을 모른다.
|
|
155
|
+
*
|
|
156
|
+
* 표준 앵커: `OperationsEventType`(B2MML-OperationsEvent.xsd, ISA-95.00.02-2018) +
|
|
157
|
+
* `OperationsRecordTemplateType`. 우리가 든 것 — `EffectiveTimestamp` · `EffectiveEndDate` ·
|
|
158
|
+
* `RecordTimestamp` · `HierarchyScope`(자리) · `Source` · 값은 `ValueType` 으로 **좁혔다**
|
|
159
|
+
* (단위 없는 물리량은 판정의 재료가 못 된다).
|
|
160
|
+
*
|
|
161
|
+
* 붙는 자리가 `HierarchyScope.EquipmentID` 인 것이 중요하다 — ISA-95 에서 장소 계층이 곧 설비
|
|
162
|
+
* 계층이고 냉장실은 `StorageZone` 수준의 설비다. `OperationalLocationType` 에는 이 축이 없다.
|
|
163
|
+
*
|
|
164
|
+
* 구조가 `partial`: 봉투 전체(`ID`·`Description`·사건 분류·`OperationsRecord` 구조)가 아니라 관측
|
|
165
|
+
* 하나를 담는 데 필요한 칸만 든다.
|
|
166
|
+
* 기능이 `full`: 사건 채널(`location.measured`)이 상태를 만들고, 선언된 기준으로 판정해
|
|
167
|
+
* `observation-out-of-limit` 신호를 세운다.
|
|
168
|
+
* 화면이 `none`: 클라이언트에 이 축을 그리는 곳이 없다(실측 0곳).
|
|
169
|
+
*/
|
|
170
|
+
{ std: 'OperationsEvent', part: '2', label: 'twin.isa95.OperationsEvent', axis: null, shownOn: ['locations'], structure: 'partial', behavior: 'full', surface: 'none' }
|
|
118
171
|
]
|
|
119
172
|
|
|
120
173
|
/*
|
|
@@ -149,10 +202,37 @@ const PART4: Isa95Concept[] = [
|
|
|
149
202
|
* 그것을 본다 — 만료·불합격이면 그 자격은 성립하지 않는다(실측: 만료된 용접사는 용접 작업 5건 중 한
|
|
150
203
|
* 번도 배정되지 않았다).
|
|
151
204
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
205
|
+
* ── 2026-08-24 위 문장 하나가 낡았다 ─────────────────────────────────────
|
|
206
|
+
* 「**사람만 판정한다**」고 적혀 있었다. 지금은 아니다 — `capabilityOfResource` 가 설비·사람·자산
|
|
207
|
+
* **셋 모두**에 `requiredTests` 를 걸고, 그 답이 배정의 가용성 필터로 들어간다. 그리고 판정 대상이
|
|
208
|
+
* 자원 밖으로 넓어졌다: **자리의 상시 관측**(`OperationsEvent` 줄)과 **로트의 시험 결과**
|
|
209
|
+
* (`TestResult` 줄)도 같은 기준으로 판정한다.
|
|
210
|
+
*
|
|
211
|
+
* 남은 규율은 하나다: **결과가 없으면 판정하지 않는다**(선언한 것만 제약이 된다 — 결손이 아니라 규율).
|
|
212
|
+
*
|
|
213
|
+
* 그리고 기준의 **숫자 한계는 우리가 더한 것**이다. 표준 `TestSpecificationCriteria.Expression` 은
|
|
214
|
+
* 자유 문장(`TextType`)이고, B2MML 일곱 파일에 `Minimum`·`Maximum`·`Tolerance` 가 하나도 없다
|
|
215
|
+
* (원문 대조). 그것 없이는 판정할 수 없으므로 `limit: {minimum, maximum, uom}` 을 더했고, 발명한
|
|
216
|
+
* 자리임을 계약에 적었다. 커널은 `Expression` 을 **읽지 않는다** — 문법이 정의되지 않았으므로
|
|
217
|
+
* 파싱하면 그 순간 방언이다.
|
|
154
218
|
*/
|
|
155
219
|
{ std: 'TestSpecification', part: '4', label: 'twin.isa95.TestSpecification', axis: 'testSpecifications', structure: 'full', behavior: 'full', surface: 'full', note: 'twin.isa95.note.test' },
|
|
220
|
+
/*
|
|
221
|
+
* **시험 결과** — 2026-08-24 신설(커널 0.7.57). 기준만 있고 결과의 자리가 표에 없었다.
|
|
222
|
+
*
|
|
223
|
+
* 표준 `TestResultType`(B2MML-OperationsTest.xsd)에서 우리가 든 것:
|
|
224
|
+
* `EvaluationDate`(at) · `Expiration`(expiresAt) · `TestableObjectID` · `PropertyMeasurement`
|
|
225
|
+
* · `EvaluatedCriterionResult` → **`result: 'pass'|'fail'` 로 좁혔다**(표준은 열거하지 않는다)
|
|
226
|
+
* 아직 없는 것: `ID` · `Description` · `HierarchyScope` · `OperationsTestRequirementID` ·
|
|
227
|
+
* `TestResultChild`. 그래서 구조는 `partial` 이다.
|
|
228
|
+
*
|
|
229
|
+
* 기능이 `partial` 인 이유를 정확히 적는다 — **자원에서는 결정을 바꾸고 로트에서는 아직 바꾸지
|
|
230
|
+
* 않는다.** 만료·불합격 자원에는 작업이 배정되지 않지만, 불합격 로트가 할당에서 빠지거나 주의 신호를
|
|
231
|
+
* 세우지는 않는다(들고·판정하고·되짚을 수는 있다). 그 자리를 `full` 로 적으면 표가 거짓이 된다.
|
|
232
|
+
*
|
|
233
|
+
* 화면은 `none` 이다 — 클라이언트에 이 축을 그리는 곳이 없다(실측: `operato-twin/client` 에 0곳).
|
|
234
|
+
*/
|
|
235
|
+
{ std: 'TestResult', part: '4', label: 'twin.isa95.TestResult', axis: null, shownOn: ['items'], structure: 'partial', behavior: 'partial', surface: 'none' },
|
|
156
236
|
{ std: 'WorkMaster', part: '4', label: 'twin.isa95.WorkMaster', axis: 'recipes', structure: 'full', behavior: 'full', surface: 'full' },
|
|
157
237
|
{ std: 'WorkDirective', part: '4', label: 'twin.isa95.WorkDirective', axis: null, structure: 'none', behavior: 'none', surface: 'none' }
|
|
158
238
|
]
|