@things-factory/headless-twin 10.0.17 → 10.0.18

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.
Files changed (122) hide show
  1. package/dist-server/engine/canonical-ingest.d.ts +12 -85
  2. package/dist-server/engine/canonical-ingest.js +92 -26
  3. package/dist-server/engine/canonical-ingest.js.map +1 -1
  4. package/dist-server/engine/energy-topology.js +3 -3
  5. package/dist-server/engine/energy-topology.js.map +1 -1
  6. package/dist-server/engine/kpi-fold.d.ts +91 -3
  7. package/dist-server/engine/kpi-fold.js +149 -25
  8. package/dist-server/engine/kpi-fold.js.map +1 -1
  9. package/dist-server/engine/kpi-query.js +99 -11
  10. package/dist-server/engine/kpi-query.js.map +1 -1
  11. package/dist-server/engine/local-declarations.js +6 -5
  12. package/dist-server/engine/local-declarations.js.map +1 -1
  13. package/dist-server/engine/model-vocabulary.js +2 -2
  14. package/dist-server/engine/model-vocabulary.js.map +1 -1
  15. package/dist-server/engine/oee-accumulator.d.ts +2 -1
  16. package/dist-server/engine/oee-accumulator.js +3 -2
  17. package/dist-server/engine/oee-accumulator.js.map +1 -1
  18. package/dist-server/engine/twin-engine.d.ts +72 -2
  19. package/dist-server/engine/twin-engine.js +150 -4
  20. package/dist-server/engine/twin-engine.js.map +1 -1
  21. package/dist-server/routes.js +1 -1
  22. package/dist-server/routes.js.map +1 -1
  23. package/dist-server/service/index.d.ts +1 -1
  24. package/dist-server/service/index.js +3 -0
  25. package/dist-server/service/index.js.map +1 -1
  26. package/dist-server/service/reference/reference-live.js +1 -1
  27. package/dist-server/service/reference/reference-live.js.map +1 -1
  28. package/dist-server/service/reference/reference-master.d.ts +11 -2
  29. package/dist-server/service/reference/reference-master.js +1 -1
  30. package/dist-server/service/reference/reference-master.js.map +1 -1
  31. package/dist-server/service/reference/reference-resolver.js +2 -2
  32. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  33. package/dist-server/service/reference/template-registry.d.ts +1 -1
  34. package/dist-server/service/reference/template-registry.js.map +1 -1
  35. package/dist-server/service/twin-backfill/backfill-period-facts.d.ts +24 -0
  36. package/dist-server/service/twin-backfill/backfill-period-facts.js +96 -0
  37. package/dist-server/service/twin-backfill/backfill-period-facts.js.map +1 -0
  38. package/dist-server/service/twin-backfill/backfill-shape.d.ts +34 -0
  39. package/dist-server/service/twin-backfill/backfill-shape.js +75 -0
  40. package/dist-server/service/twin-backfill/backfill-shape.js.map +1 -0
  41. package/dist-server/service/twin-backfill/index.d.ts +2 -0
  42. package/dist-server/service/twin-backfill/index.js +6 -0
  43. package/dist-server/service/twin-backfill/index.js.map +1 -0
  44. package/dist-server/service/twin-backfill/twin-backfill-resolver.d.ts +10 -0
  45. package/dist-server/service/twin-backfill/twin-backfill-resolver.js +44 -0
  46. package/dist-server/service/twin-backfill/twin-backfill-resolver.js.map +1 -0
  47. package/dist-server/service/twin-control/twin-control-mutation.js +2 -2
  48. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  49. package/dist-server/service/twin-lifecycle/domain-catalog.js +2 -1
  50. package/dist-server/service/twin-lifecycle/domain-catalog.js.map +1 -1
  51. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  52. package/dist-server/service/twin-model/name-index.js +1 -1
  53. package/dist-server/service/twin-model/name-index.js.map +1 -1
  54. package/dist-server/service/twin-model/project-structure.js +4 -3
  55. package/dist-server/service/twin-model/project-structure.js.map +1 -1
  56. package/dist-server/service/twin-model/twin-equipment.d.ts +1 -1
  57. package/dist-server/service/twin-model/twin-equipment.js +2 -2
  58. package/dist-server/service/twin-model/twin-equipment.js.map +1 -1
  59. package/dist-server/service/twin-model/twin-model-item-query.js +1 -1
  60. package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
  61. package/dist-server/service/twin-model/twin-model-query.js +4 -4
  62. package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
  63. package/dist-server/service/twin-model/twin-model-tree-query.js +1 -1
  64. package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
  65. package/dist-server/service/twin-space/twin-space-resolver.js +1 -1
  66. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  67. package/dist-shared/entity-delta.js +1 -1
  68. package/dist-shared/entity-delta.js.map +1 -1
  69. package/package.json +7 -6
  70. package/server/engine/canonical-ingest.ts +72 -84
  71. package/server/engine/energy-topology.ts +1 -1
  72. package/server/engine/kpi-fold.ts +219 -36
  73. package/server/engine/kpi-query.ts +97 -4
  74. package/server/engine/local-declarations.ts +2 -1
  75. package/server/engine/model-vocabulary.ts +1 -1
  76. package/server/engine/oee-accumulator.ts +4 -2
  77. package/server/engine/twin-engine.ts +163 -5
  78. package/server/routes.ts +6 -1
  79. package/server/service/index.ts +3 -0
  80. package/server/service/reference/reference-live.ts +8 -2
  81. package/server/service/reference/reference-master.ts +12 -3
  82. package/server/service/reference/reference-resolver.ts +1 -1
  83. package/server/service/reference/template-registry.ts +1 -1
  84. package/server/service/twin-backfill/backfill-period-facts.ts +168 -0
  85. package/server/service/twin-backfill/backfill-shape.ts +86 -0
  86. package/server/service/twin-backfill/index.ts +3 -0
  87. package/server/service/twin-backfill/twin-backfill-resolver.ts +35 -0
  88. package/server/service/twin-control/twin-control-mutation.ts +1 -1
  89. package/server/service/twin-lifecycle/domain-catalog.ts +2 -1
  90. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +1 -1
  91. package/server/service/twin-model/name-index.ts +1 -1
  92. package/server/service/twin-model/project-structure.ts +3 -2
  93. package/server/service/twin-model/twin-equipment.ts +14 -2
  94. package/server/service/twin-model/twin-model-item-query.ts +1 -1
  95. package/server/service/twin-model/twin-model-query.ts +2 -2
  96. package/server/service/twin-model/twin-model-tree-query.ts +1 -1
  97. package/server/service/twin-space/twin-space-resolver.ts +1 -1
  98. package/shared/entity-delta.ts +1 -1
  99. package/test/axis-read.test.ts +4 -3
  100. package/test/backfill-shape.test.ts +89 -0
  101. package/test/broadcast-cost-baseline.test.ts +2 -1
  102. package/test/canonical-ingest-vocabularies.test.ts +82 -3
  103. package/test/capability-mapping.test.ts +1 -1
  104. package/test/contract-layer-guard.test.ts +48 -0
  105. package/test/declaration-reaches-model.test.ts +2 -0
  106. package/test/equipment-identity-reaches.test.ts +57 -0
  107. package/test/fact-scope-wiring.test.ts +96 -0
  108. package/test/generated-not-counted.test.ts +99 -0
  109. package/test/ingest-bench.test.ts +2 -1
  110. package/test/ingest-wiring-guard.test.ts +197 -0
  111. package/test/kpi-fold.test.ts +81 -0
  112. package/test/live-mirror-parity.test.ts +6 -3
  113. package/test/local-declarations.test.ts +3 -2
  114. package/test/master-to-twin.test.ts +2 -2
  115. package/test/oee-accumulator.test.ts +18 -3
  116. package/test/projection-reaches-screen.test.ts +5 -0
  117. package/test/projection-reads-declared.test.ts +109 -0
  118. package/test/property-effects.test.ts +7 -3
  119. package/test/scale-twin-bench.test.ts +2 -1
  120. package/test/vocabulary-guard.test.ts +1 -1
  121. package/tsconfig.shared.tsbuildinfo +1 -1
  122. package/tsconfig.tsbuildinfo +1 -1
@@ -19,7 +19,8 @@ import { mergeStreams } from '../service/twin-journal/event-type-split-shape.js'
19
19
  import { hydratedDate } from './read-time.js'
20
20
  import { Between, In } from 'typeorm'
21
21
 
22
- import { hierarchyOf, levelOfLocationType, electricalUpstreamOf, EMS_LOCATION_TYPES, EMS_PROPERTY, type TariffDeclaration } from '@operato/twin-kernel'
22
+ import { type TariffDeclaration } from '@operato/twin-kernel'
23
+ import { hierarchyOf, levelOfLocationType, electricalUpstreamOf, EMS_LOCATION_TYPES, EMS_PROPERTY } from '@operato/ops-contract'
23
24
 
24
25
  import { getReadRepository, getRepository } from '@things-factory/shell'
25
26
 
@@ -51,7 +52,7 @@ import { MAX_BASELINE_PERIODS, baselineWindows, summarizeBaseline, type Baseline
51
52
  * 나눈 값이라, 두 사실이 한 폴드에 들어와야 창이 어긋날 수 없다. 표본(`energy.measured`)은 읽지 않는다 —
52
53
  * 저널에 없고(§4.1 인제스트 계약) 여기서 필요한 것은 마감된 구간뿐이다.
53
54
  */
54
- const BUSINESS_EVENTS = ['task.status', 'order.status', 'energy.demand.window', 'energy.generation.period', 'energy.usage.period', 'energy.bill']
55
+ const BUSINESS_EVENTS = ['task.status', 'order.status', 'energy.demand.window', 'energy.generation.period', 'energy.usage.period', 'energy.bill', 'energy.generation.price']
55
56
 
56
57
  /** 한 번에 계산할 이벤트 상한 — 큰 구간이 서버를 붙잡지 않게. */
57
58
  const MAX_EVENTS = 20000
@@ -63,7 +64,27 @@ const MAX_EVENTS = 20000
63
64
  * 메모리가 그만큼 든다. 셋(종류) × 둘(트윈)까지는 최악에 12만 행이고, 그 이상은 예전 한 질의로 간다 —
64
65
  * 느리지만 답은 같다.
65
66
  */
66
- const KPI_MAX_SPLIT = 6
67
+ /*
68
+ * ── 나눠 물을 수 있는 갈래의 최대 (2026-08-30 실측) ──────────────────────────
69
+ *
70
+ * 6 이었다. 그런데 **한 현장에 트윈이 여럿인 것이 이 제품의 정상**이다(창고·공정·야드가 한 현실을
71
+ * 각각 본다). 트윈 3개 × 종류 6가지 = 18 이라 조건에 안 걸렸고, 한 질의로 떨어졌다.
72
+ *
73
+ * 같은 창·같은 행으로 두 모양을 쟀다(hatio-us, order.status 345만 건이 있는 하루):
74
+ *
75
+ * 한 질의(종류 IN + 시각순) ix_twin_event_1 로 그 날 전체를 걷는다 25.987초 · 20,000건(잘림)
76
+ * 갈래 하나(종류 정확일치) ix_twin_event_7 이 순서까지 준다 0.286초
77
+ *
78
+ * 91배다. 그리고 25초를 기다린 끝에 나오는 값이 **잘린 값**이다 — 사용자는 그 사실을 25초 뒤에 본다.
79
+ *
80
+ * ── 늘리는 대가 ────────────────────────────────────────────────────────────
81
+ * 갈래마다 상한까지 받아야 합칠 때 정확하므로(§`mergeStreams`), 갈래가 많으면 그만큼 든다.
82
+ * 최악은 `갈래 수 × MAX_EVENTS` 행이다. 다만 그 최악은 **모든 갈래가 창을 가득 채울 때**이고,
83
+ * 그때는 어차피 결과가 잘린다(그 사실은 `capped` 로 나간다).
84
+ *
85
+ * 32 는 트윈 4개 × 종류 8가지를 덮는다. 어휘가 더 늘면 이 수를 다시 재고 정한다.
86
+ */
87
+ const KPI_MAX_SPLIT = 32
67
88
 
68
89
  export interface TwinKpiInput {
69
90
  domainId: string
@@ -480,6 +501,59 @@ function latestBillOf(domainId: string, instanceIds: string[]): { to: string; bi
480
501
  return best
481
502
  }
482
503
 
504
+ /** 이 트윈들이 이어받고 있는 **발전 단가** — 창 안에 단가 사실이 없을 때 쓴다. */
505
+ function latestGenerationPriceOf(
506
+ domainId: string,
507
+ instanceIds: string[]
508
+ ): { from: string; to: string; unitPrice: number; currency: string } | undefined {
509
+ let best: { from: string; to: string; unitPrice: number; currency: string } | undefined
510
+ for (const instanceId of instanceIds) {
511
+ const p = (TwinEngine.snapshot(domainId, instanceId) as any)?.energy?.generationPrice
512
+ if (!p?.from || !Number.isFinite(Number(p.unitPrice)) || !p.currency) continue
513
+ if (!best || Date.parse(p.from) > Date.parse(best.from)) {
514
+ best = { from: String(p.from), to: String(p.to), unitPrice: Number(p.unitPrice), currency: String(p.currency) }
515
+ }
516
+ }
517
+ return best
518
+ }
519
+
520
+ /**
521
+ * 이 트윈들의 **발전 정격** — 이용률의 분모.
522
+ *
523
+ * 커널이 고른다(설비 합 → 자리 합계, 교류 → 직류). 여러 트윈이면 합한다 — 한 현장의 발전은
524
+ * 트윈마다 나뉘어 있어도 같은 현장의 발전이다. 근거가 갈리면 약한 쪽(직류·자리)으로 적는다.
525
+ */
526
+ function generationRatedOf(
527
+ domainId: string,
528
+ instanceIds: string[]
529
+ ): { kW: number; basis: 'ac' | 'dc'; from: 'equipment' | 'location' } | undefined {
530
+ let kW = 0
531
+ let basis: 'ac' | 'dc' = 'ac'
532
+ let from: 'equipment' | 'location' = 'equipment'
533
+ for (const instanceId of instanceIds) {
534
+ /* 스냅샷에서 읽는다 — 커널이 고른 값이다. 여기서 모델을 다시 뒤지면 고르는 규칙이 두 벌이 된다. */
535
+ const r = (TwinEngine.snapshot(domainId, instanceId) as any)?.energy?.generationRated
536
+ if (!r || !(Number(r.kW) > 0)) continue
537
+ kW += Number(r.kW)
538
+ if (r.basis === 'dc') basis = 'dc'
539
+ if (r.from === 'location') from = 'location'
540
+ }
541
+ return kW > 0 ? { kW, basis, from } : undefined
542
+ }
543
+
544
+ /** 이 트윈들이 이어받고 있는 **요금 기준** — 이 주기에 적용되는 값(청구서보다 먼저다). */
545
+ function latestTariffBasisOf(domainId: string, instanceIds: string[]): { from: string; billingDemandKW?: number } | undefined {
546
+ let best: { from: string; billingDemandKW?: number } | undefined
547
+ for (const instanceId of instanceIds) {
548
+ const b = (TwinEngine.snapshot(domainId, instanceId) as any)?.energy?.tariffBasis
549
+ if (!b?.from) continue
550
+ if (!best || Date.parse(b.from) > Date.parse(best.from)) {
551
+ best = { from: String(b.from), ...(Number.isFinite(Number(b.billingDemandKW)) ? { billingDemandKW: Number(b.billingDemandKW) } : {}) }
552
+ }
553
+ }
554
+ return best
555
+ }
556
+
483
557
  async function energyTopologyOf(
484
558
  domainId: string,
485
559
  instanceIds: string[],
@@ -810,7 +884,19 @@ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput
810
884
  : 'no operational events in the journal yet — there is nothing to measure (this is NOT "zero throughput").'
811
885
  }
812
886
  }
813
- toMs = Date.parse(latest)
887
+ /*
888
+ * ── **미래를 성과 구간으로 삼지 않는다** (2026-08-30) ──────────────────────
889
+ *
890
+ * 마지막 기록이 미래일 수 있다. 한 원본이 **끝나지 않은 이번 달**의 요금 정보를 청구서로 보냈고,
891
+ * 청구서의 사건 시각은 기간의 끝이라 그 사실이 **내일** 일어난 것으로 적혔다. 그러자 구간이
892
+ * `내일 00:00 → 모레 00:00` 이 되어 그 현장의 화면에서 **어제 발전량이 사라졌다.** 오류는 나지
893
+ * 않았고 화면만 비어 있었다.
894
+ *
895
+ * 커널이 이제 그런 사실을 받지 않는다. 그래도 여기서 막는다 — **이미 적힌 것은 사라지지 않고**,
896
+ * 저널은 지우지 않기 때문이다. 그리고 성과는 지나간 일이다. 아직 오지 않은 구간의 성과라는 것은
897
+ * 없다.
898
+ */
899
+ toMs = Math.min(Date.parse(latest), Date.now())
814
900
  basis = 'latest-event'
815
901
  }
816
902
 
@@ -842,6 +928,10 @@ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput
842
928
  * **커널 상태에 이어져 있는 마지막 청구서**를 함께 넘긴다(§`EnergyState.lastBill`).
843
929
  */
844
930
  const lastBill = latestBillOf(input.domainId, instanceIds)
931
+ const tariffBasis = latestTariffBasisOf(input.domainId, instanceIds)
932
+ /* 이용률의 분모 — 커널이 모델의 선언에서 고른다(설비 합 → 자리 합계, 교류 → 직류). */
933
+ const generationRated = generationRatedOf(input.domainId, instanceIds)
934
+ const generationPrice = latestGenerationPriceOf(input.domainId, instanceIds)
845
935
  const kpi = foldKpi(events, { fromMs, toMs }, {
846
936
  groupBy: input.groupBy,
847
937
  locationArea: areaMap.locationArea,
@@ -849,6 +939,9 @@ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput
849
939
  meterFeeder: feederMap.meterFeeder,
850
940
  ...(tariffFound.tariff ? { tariff: tariffFound.tariff } : {}),
851
941
  ...(lastBill ? { lastBill } : {}),
942
+ ...(tariffBasis ? { tariffBasis } : {}),
943
+ ...(generationRated ? { generationRated } : {}),
944
+ ...(generationPrice ? { generationPrice } : {}),
852
945
  ...shiftCtx,
853
946
  groupLimit: input.groupLimit
854
947
  })
@@ -37,7 +37,8 @@
37
37
  * **속성 어휘는 사용자가 정의하지 않는다** — 커널이 정한 속성에 값을 넣는 문이다(그래서 이름도
38
38
  * 「사용자 정의 속성」이 아니다).
39
39
  */
40
- import { ATTENTION_PROPERTY, EMS_PROPERTY_SPEC, OPERATION_PROPERTY, OP_PARAM } from '@operato/twin-kernel'
40
+ import { ATTENTION_PROPERTY, OPERATION_PROPERTY } from '@operato/twin-kernel'
41
+ import { EMS_PROPERTY_SPEC, OP_PARAM } from '@operato/ops-contract'
41
42
 
42
43
  import { EQUIPMENT_PROPERTY, SPEED_TO_MPS } from './travel-estimator.ts'
43
44
  import { declarationTargetsOf } from './property-effects.ts'
@@ -25,7 +25,7 @@
25
25
  * 저장은 주는 대로 한다(새 쓰기는 이미 새 어휘다). 읽기가 정규화되므로, 저장된 옛 모델는 **다음에
26
26
  * 저장될 때 자연히 새 어휘로 옮겨간다** — 마이그레이션을 따로 실행하지 않는다.
27
27
  */
28
- import { readBoardLocations, readBoardEquipment } from '@operato/twin-kernel'
28
+ import { readBoardLocations, readBoardEquipment } from '@operato/ops-contract'
29
29
 
30
30
  /** 흡수하고 **내보내지 않는** 옛 키 — 남겨 두면 소비처가 두 어휘를 다시 보게 된다. */
31
31
  const RETIRED_KEYS = ['nodes', 'movers', 'equipmentList'] as const // vocabulary-guard: allow — 옛 어휘를 흡수하는 것이 이 코드의 일이다(세대 이름을 적어야 흡수할 수 있다)
@@ -10,8 +10,10 @@
10
10
  * - quality.output 이 없으면(예: WMS/YMS) quality=1(품질 개념 없음).
11
11
  * 시간축 = Date.parse(eventTime)(ms) — 구간 차분은 sim/실시간 무관하게 정확(고정 오프셋 상쇄).
12
12
  */
13
- import { computeOee } from '@operato/twin-kernel'
14
- import type { CanonicalEnvelope, OeeCounters, OeeMetrics } from '@operato/twin-kernel'
13
+ /* OEE 공식은 계약에 있다 — 소비자가 셋이라(시뮬·미러·MES) 커널에 두면 밖에서 다시 쓰게 된다. */
14
+ import { computeOee } from '@operato/ops-contract'
15
+ import type { OeeCounters } from '@operato/ops-contract'
16
+ import type { CanonicalEnvelope, OeeMetrics } from '@operato/ops-contract'
15
17
 
16
18
  interface Acc {
17
19
  status?: string
@@ -14,7 +14,8 @@ import { pubsub, getRepository, Domain } from '@things-factory/shell'
14
14
  import { And, IsNull, LessThanOrEqual, MoreThan } from 'typeorm'
15
15
  import { cacheService } from '@things-factory/cache-service'
16
16
 
17
- import type { ReducerCheckpoint, VocabularyElement } from '@operato/twin-kernel'
17
+ import type { ReducerCheckpoint } from '@operato/twin-kernel'
18
+ import type { VocabularyElement } from '@operato/ops-contract'
18
19
  import type { OeeCheckpoint } from './oee-accumulator.js'
19
20
  import { TwinEvent } from '../service/twin-event/twin-event.js'
20
21
  import { twinEventKeys } from '../service/twin-event/twin-event-keys.js'
@@ -76,11 +77,13 @@ import {
76
77
  type IngestWindow
77
78
  } from './ingest-health.js'
78
79
  import { FactDeduper } from './ingest-dedupe.js'
79
- import { EMS_PROPERTY } from '@operato/twin-kernel'
80
- import type { TwinKernel, TwinModelDef, StructureShift, SubscriptionMessage, TwinRuntime as TwinRuntimeType, CanonicalEnvelope } from '@operato/twin-kernel'
80
+ import { EMS_PROPERTY } from '@operato/ops-contract'
81
+ import type { SubscriptionMessage, TwinRuntime as TwinRuntimeType } from '@operato/twin-kernel'
82
+ import type { TwinKernel, TwinModelDef, StructureShift, CanonicalEnvelope } from '@operato/ops-contract'
81
83
 
82
84
  /* 커널 런타임 로드 — CJS 번들(dist-cjs). 타입은 위 import type 로. replay = 이벤트열→상태 재구성(복구·시간여행). */
83
- const { WmsKernel, YmsKernel, MesKernel, EmsKernel, TwinRuntime, StateProjector, replay, replayFrom, replayWithCheckpoint, replaySegments, readBoardLocations, readBoardEquipment, DOMAIN_CATALOG, OP_EVENT, validateScenario } = require('@operato/twin-kernel')
85
+ const { WmsKernel, YmsKernel, MesKernel, EmsKernel, TwinRuntime, StateProjector, replay, replayFrom, replayWithCheckpoint, replaySegments } = require('@operato/twin-kernel')
86
+ const { readBoardLocations, readBoardEquipment, DOMAIN_CATALOG, OP_EVENT, validateScenario } = require('@operato/ops-contract')
84
87
 
85
88
  const KERNELS: Record<string, any> = { wms: WmsKernel, yms: YmsKernel, mes: MesKernel, ems: EmsKernel }
86
89
 
@@ -1609,6 +1612,8 @@ export class TwinEngine {
1609
1612
 
1610
1613
  const Kernel = kernelFor(kind)
1611
1614
  const kernel: TwinKernel = new Kernel(domainId, undefined, this.productionSpecOf(model))
1615
+ /* 커널은 도메인으로 선다 — 어느 트윈인지는 여기서만 알려 줄 수 있다(§`factScope`). */
1616
+ ;(kernel as any).scopeId = id
1612
1617
  kernel.loadTwinModel(model) // 구조만. 상태는 아래 웜스타트가 주입한다.
1613
1618
  this.applyOperations(kernel, model, id) // 시간·수율 명세(있으면) — 없으면 커널 기본값
1614
1619
  /* 추정기는 DB 조회를 포함해 비동기 — 기동을 막지 않고 붙는다(붙기 전 작업은 명세·상수로 산출). */
@@ -1814,6 +1819,8 @@ export class TwinEngine {
1814
1819
  if (this.instances[key]) return this.instances[key]
1815
1820
  const Kernel = kernelFor(kind)
1816
1821
  const kernel: any = new Kernel(domainId, undefined, this.productionSpecOf(model))
1822
+ /* 커널은 도메인으로 선다 — 어느 트윈인지는 여기서만 알려 줄 수 있다(§`factScope`). */
1823
+ ;(kernel as any).scopeId = id
1817
1824
  kernel.loadTwinModel(model)
1818
1825
  /* **세우는 쪽이 아는 사실은 세울 때 말한다.** 예전에는 첫 이벤트가 도착해야 커널이 스스로를
1819
1826
  관측 구동으로 여겼고, 그래서 아직 아무것도 못 받은 미러는 시뮬레이션 취급을 받았다. */
@@ -2416,9 +2423,24 @@ export class TwinEngine {
2416
2423
 
2417
2424
  if (carried.length) jobs.push(this.persistCarried(inst.domainId, inst.id, carried))
2418
2425
  if (plain.length && inst.revision != null) {
2426
+ /*
2427
+ * ── 마감된 구간 사실은 한 번만 적는다 (2026-08-30) ───────────────────────
2428
+ *
2429
+ * 이미 있는 것을 걸러낸 뒤에 번호를 매긴다. 걸러내기 전에 매기면 **쓰지 않은 번호가 비고**,
2430
+ * 그 구멍이 저널의 리비전 연속성을 끊는다.
2431
+ */
2432
+ /*
2433
+ * 번호는 **먼저 잡는다**(동기). 걸러낸 뒤에 잡으면 두 flush 가 겹칠 때 같은 번호를 두 번 쓴다.
2434
+ * 걸러진 만큼 번호가 비지만, 그 구멍은 해롭지 않다 — 리비전은 같은 시각을 가리는 데만 쓰고
2435
+ * 이어짐을 요구하지 않는다(저널 머리는 최대값이다).
2436
+ */
2419
2437
  const start = inst.revision
2420
2438
  inst.revision = start + plain.length
2421
- jobs.push(this.persistBatch(inst.domainId, inst.id, plain, start))
2439
+ jobs.push(
2440
+ this.dropAlreadyWritten(inst.domainId, inst.id, plain).then(fresh =>
2441
+ fresh.length ? this.persistBatch(inst.domainId, inst.id, fresh, start) : undefined
2442
+ )
2443
+ )
2422
2444
  }
2423
2445
  if (!jobs.length) return Promise.resolve()
2424
2446
  /*
@@ -2445,6 +2467,76 @@ export class TwinEngine {
2445
2467
  * 예전에는 이 경로가 **델타마다 한 행씩** 저장했다(그리고 행마다 구조 리비전을 물었다). 규모에서 그것이
2446
2468
  * 호스트를 먹었다(§7.1 실측). 여기서 구조 리비전은 **한 번만** 묻는다.
2447
2469
  */
2470
+ /**
2471
+ * **마감된 구간 사실은 한 번만 적는다** — 같은 사실이 두 번 오면 한 사실이다 (2026-08-30).
2472
+ *
2473
+ * ── 왜 쓰는 자리에서 막나 ──────────────────────────────────────────────────
2474
+ * 커널이 그 사실들의 정체성을 내용에서 만든다(종류·대상·시작·끝). 그런데 저널의 기본키는 새로
2475
+ * 만드는 uuid 라, **id 가 글자 하나까지 같아도 행이 따로 쌓였다.**
2476
+ *
2477
+ * 실측(2026-08-30): 발전 단가가 행 7개·서로 다른 id 4개였다. 8월 27일 단가가 세 벌 있었고,
2478
+ * 그 구간의 수익이 세 번 세어진다.
2479
+ *
2480
+ * 커넥터가 커서로 막는 것이 첫 방어선이고 그쪽도 고쳤다. 그런데 **커넥터가 한 번 실수하면 저널이
2481
+ * 부풀고 그 위의 모든 합이 틀린다.** 정체성은 커널이 선언한 사실이므로, 어느 문으로 들어오든
2482
+ * 지켜져야 한다 — 채우기 경로에만 있으면 그 보증은 「어느 문으로 왔느냐」에 걸린다.
2483
+ *
2484
+ * ── 값이 싼 이유 ───────────────────────────────────────────────────────────
2485
+ * 마감된 구간 사실만 본다(시점의 관측은 그대로 지난다 — 반복이 뜻을 가진다). 그것들은 드물어서
2486
+ * (한 시간에 한 줄) 한 묶음에 몇 개뿐이고, 그 구간의 그 종류만 색인으로 읽는다.
2487
+ */
2488
+ private static readonly PERIOD_FACT_TYPES = new Set([
2489
+ 'energy.usage.period',
2490
+ 'energy.generation.period',
2491
+ 'energy.generation.price',
2492
+ 'energy.tariff.basis',
2493
+ 'energy.bill'
2494
+ ])
2495
+
2496
+ static async dropAlreadyWritten(domainId: string, instanceId: string, envelopes: any[]): Promise<any[]> {
2497
+ const periodFacts = envelopes.filter(e => this.PERIOD_FACT_TYPES.has(String(e?.eventType)) && typeof e?.eventId === 'string')
2498
+ if (!periodFacts.length) return envelopes
2499
+
2500
+ const times = periodFacts.map(e => Date.parse(String(e.eventTime))).filter(n => Number.isFinite(n))
2501
+ if (!times.length) return envelopes
2502
+ const types = [...new Set(periodFacts.map(e => String(e.eventType)))]
2503
+
2504
+ const existing = await getRepository(TwinEvent)
2505
+ .createQueryBuilder('e')
2506
+ .select(['e.payload AS "payload"'])
2507
+ .where('e.domain = :domainId', { domainId })
2508
+ .andWhere('e.instanceId = :instanceId', { instanceId })
2509
+ .andWhere('e.eventType IN (:...types)', { types })
2510
+ .andWhere('e.eventTime BETWEEN :fromAt AND :toAt', { fromAt: new Date(Math.min(...times)), toAt: new Date(Math.max(...times)) })
2511
+ .getRawMany<{ payload: unknown }>()
2512
+ .catch(() => [] as { payload: unknown }[])
2513
+
2514
+ const seen = new Set<string>()
2515
+ for (const row of existing) {
2516
+ let p: any = row.payload
2517
+ if (typeof p === 'string') {
2518
+ try {
2519
+ p = JSON.parse(p)
2520
+ } catch {
2521
+ p = undefined
2522
+ }
2523
+ }
2524
+ if (typeof p?.eventId === 'string' && p.eventId) seen.add(p.eventId)
2525
+ }
2526
+ /* 한 묶음 안에 같은 것이 둘 있어도 하나만 적는다. */
2527
+ const kept: any[] = []
2528
+ for (const e of envelopes) {
2529
+ if (!this.PERIOD_FACT_TYPES.has(String(e?.eventType)) || typeof e?.eventId !== 'string') {
2530
+ kept.push(e)
2531
+ continue
2532
+ }
2533
+ if (seen.has(e.eventId)) continue
2534
+ seen.add(e.eventId)
2535
+ kept.push(e)
2536
+ }
2537
+ return kept
2538
+ }
2539
+
2448
2540
  static async persistCarried(domainId: string, instanceId: string, items: { event: any; revision: number }[]): Promise<void> {
2449
2541
  const repo = getRepository(TwinEvent)
2450
2542
  const structureRev = await this.structureRevOf(domainId, instanceId)
@@ -2580,6 +2672,23 @@ export class TwinEngine {
2580
2672
  }
2581
2673
 
2582
2674
  /**
2675
+ * ── **트윈은 설비의 마스터가 아니다** (사용자 결정 2026-08-30) ─────────────
2676
+ *
2677
+ * 인원·설비·자산의 마스터는 `@things-factory/ops-master` 다. 트윈은 그것을 **당겨 와** 모델에
2678
+ * 반영하고, 여기 남는 것은 그 결과의 캐시다.
2679
+ *
2680
+ * 그래서 이 함수가 하는 일에 선이 있다.
2681
+ *
2682
+ * 트윈 안에서 사람이 더한 설비 가정·시뮬레이션의 사실이다 — 트윈이 든다 (지금 이 함수)
2683
+ * 현장에 실제로 있는 설비 마스터의 사실이다 — 트윈이 만들지 않는다
2684
+ *
2685
+ * 이 함수는 **트윈 제어 명령**(`resource.add`) 뒤에만 불린다. 관측에서 설비가 생기는 경로가 아니다.
2686
+ * 관측으로 모르는 설비가 들어오면 그것은 **마스터에 없는 것**이고, 트윈이 조용히 만들어 주면 두
2687
+ * 곳이 서로 다른 설비 목록을 갖게 된다. 그때는 만들지 말고 「모르는 설비가 왔다」로 세어야 한다.
2688
+ *
2689
+ * 당겨 오는 길이 붙으면 이 주석의 위쪽 절반만 남는다.
2690
+ *
2691
+ * ── 원래 설명 ─────────────────────────────────────────────────────────────
2583
2692
  * 라이브 구조 변이(resource.add 등)를 저장된 model 에 반영 — 런타임 커널의 무버를 registry model.equipment 에 동기.
2584
2693
  * 이게 없으면 런타임엔 추가돼도(상태·저널엔 반영) 저장 model 는 원본이라 프로비저닝 편집기·재기동(loadTwinModel)이
2585
2694
  * 추가분을 잃는다. 기존 model.equipment 항목은 보존(homeLocation 유지)하고 새 id 만 append(추가 시점 location=homeLocation).
@@ -4372,6 +4481,55 @@ export class TwinEngine {
4372
4481
  * `offered` 는 **제시된 레코드 수**다(거부 여부 무관). 통과율을 서로 다른 두 계수기에서 나눠 계산하면
4373
4482
  * 분모와 분자가 다른 것을 세게 되므로, 한자리에서 본 수를 그대로 넘긴다.
4374
4483
  */
4484
+ /**
4485
+ * 이 트윈의 저널이 지금 몇 번까지 적혔나 — **접근자로 낸다.**
4486
+ *
4487
+ * 레지스트리를 밖에서 직접 색인하지 않게 하는 것이 이 저장소의 규율이다(§`engine-registry-access`).
4488
+ * 지난 기록을 채우는 길이 다음 번호를 알아야 해서 열었다.
4489
+ *
4490
+ * 트윈이 도는 중이 아니면 0 이다 — 그때는 저널만 있고 이어 붙일 번호를 커널이 들고 있지 않다.
4491
+ */
4492
+ /**
4493
+ * 마감된 구간 사실의 **이름을 지을 때 쓸 범위와 선언** — 유입 문에 넘긴다.
4494
+ *
4495
+ * ── 왜 호스트가 주나 (2026-08-30) ────────────────────────────────────────
4496
+ * 커널은 `new Kernel(domainId, …)` 으로 서므로 **자기가 어느 트윈인지 모른다.** 그런데 설비 번호는
4497
+ * 트윈 안에서만 통하는 이름표라, 한 도메인에 현장이 둘이면 서로 다른 설비의 같은 날이 한 사실이
4498
+ * 된다. 실측으로 그 일이 났다 — 두 발전소의 `002` 가 겹쳐, 여러 현장을 함께 계산하는 성과 화면에서
4499
+ * 한쪽 발전량이 사라진다.
4500
+ *
4501
+ * 선언된 정체성은 **커널이 답한다**(§`FlowEngine.equipmentIdentity`) — 판정을 두 곳에 두지 않는다.
4502
+ * 트윈이 돌고 있지 않으면 선언을 물을 곳이 없으므로 범위만 넘긴다(그때도 겹치지는 않는다).
4503
+ */
4504
+ static factScope(
4505
+ domainId: string,
4506
+ instanceId: string
4507
+ ): { scopeId: string; identityOf?: (kind: 'equipment' | 'meter', localId: string) => string | undefined } {
4508
+ const inst = this.instances[runtimeKey(domainId, instanceId)]
4509
+ const kernel: any = inst?.kernel
4510
+ if (typeof kernel?.equipmentIdentity !== 'function') return { scopeId: instanceId }
4511
+ return {
4512
+ scopeId: instanceId,
4513
+ identityOf: (kind, localId) => (kind === 'equipment' ? kernel.equipmentIdentity(localId) : undefined)
4514
+ }
4515
+ }
4516
+
4517
+ static journalHead(domainId: string, instanceId: string): number {
4518
+ const inst = this.instances[runtimeKey(domainId, instanceId)]
4519
+ return Number(inst?.revision ?? 0)
4520
+ }
4521
+
4522
+ /**
4523
+ * 저널에 그만큼 적었으니 번호를 밀어 준다.
4524
+ *
4525
+ * 밀어 주지 않으면 다음에 라이브가 **같은 번호를 다시 쓴다** — 같은 시각의 두 사실을 가릴 수 없게 된다.
4526
+ */
4527
+ static advanceJournalHead(domainId: string, instanceId: string, by: number): void {
4528
+ const inst = this.instances[runtimeKey(domainId, instanceId)]
4529
+ if (!inst || !(by > 0)) return
4530
+ inst.revision = Number(inst.revision ?? 0) + by
4531
+ }
4532
+
4375
4533
  static recordIngestResult(
4376
4534
  domainId: string,
4377
4535
  instanceId: string,
package/server/routes.ts CHANGED
@@ -44,7 +44,12 @@ process.on('bootstrap-module-domain-public-route' as any, (_app: any, router: an
44
44
  * 것과 밀어 받은 것이 구별되지 않는다. 구별되는 것은 유입 장부의 `sources` 한 줄뿐이다.
45
45
  */
46
46
  ingest: (instanceId, records) => {
47
- const { accepted, rejected, masterData } = ingestCanonicalRecords(records as any, domain.id, new Date().toISOString())
47
+ const { accepted, rejected, masterData } = ingestCanonicalRecords(
48
+ records as any,
49
+ domain.id,
50
+ new Date().toISOString(),
51
+ TwinEngine.factScope(domain.id, instanceId)
52
+ )
48
53
  /* 사건이 아닌 것은 상태만 세운다 — 밀어 받는 길도 같은 규율이다(§`applyMasterData`). */
49
54
  if (masterData.length) TwinEngine.applyMasterData(domain.id, instanceId, masterData)
50
55
  const { applied, duplicates } = TwinEngine.ingestLiveResult(domain.id, instanceId, accepted, 'hook')
@@ -42,6 +42,8 @@ import { resolvers as TwinForecastResolvers } from './twin-forecast/index.js'
42
42
  import { resolvers as TwinAttentionResolvers } from './twin-attention/index.js'
43
43
  import { resolvers as TwinMetricsResolvers } from './twin-metrics/index.js'
44
44
  import { resolvers as TwinReadinessResolvers } from './twin-readiness/index.js'
45
+ /* 지난 기록 채우기 — 저널에만 쓰는 문. 라이브 유입과 판단이 달라 문을 따로 둔다. */
46
+ import { resolvers as TwinBackfillResolvers } from './twin-backfill/index.js'
45
47
 
46
48
  export const entities = [
47
49
  /* ENTITIES */
@@ -73,6 +75,7 @@ export const schema = {
73
75
  ...TwinAttentionResolvers,
74
76
  ...TwinMetricsResolvers,
75
77
  ...TwinReadinessResolvers,
78
+ ...TwinBackfillResolvers,
76
79
  ...TwinIngestWindowResolvers,
77
80
  ...TwinModelResolvers,
78
81
  ...TwinSpaceResolvers,
@@ -2,7 +2,8 @@ import { twinLog, twinWarn, twinError } from '../../engine/log.js'
2
2
  import { getRepository } from '@things-factory/shell'
3
3
 
4
4
  import { getAdapter, type ConnectionConfig, type LiveFeedContinuity, type ObservedItemFact, type SiteDescriptor } from './reference-adapter.js'
5
- import { TwinEngine, ingestCanonicalRecords, registerLiveFeedProbe, type CanonicalRecord } from '../../engine/index.js'
5
+ import { TwinEngine, ingestCanonicalRecords, registerLiveFeedProbe } from '../../engine/index.js'
6
+ import { type CanonicalRecord } from '@operato/ops-contract'
6
7
  import { TwinInstance } from '../twin-instance/twin-instance.js'
7
8
  import { TwinReference } from './twin-reference.js'
8
9
 
@@ -77,7 +78,12 @@ export async function startReferenceLiveFeed(
77
78
  return
78
79
  }
79
80
  const offered = Array.isArray(records) ? records.length : records ? 1 : 0
80
- const { accepted, rejected, masterData } = ingestCanonicalRecords(records as CanonicalRecord[], domainId, new Date().toISOString())
81
+ const { accepted, rejected, masterData } = ingestCanonicalRecords(
82
+ records as CanonicalRecord[],
83
+ domainId,
84
+ new Date().toISOString(),
85
+ TwinEngine.factScope(domainId, instanceId)
86
+ )
81
87
  /*
82
88
  * ── **사건이 아닌 것은 상태만 세운다** (2026-08-28) ─────────────────────────
83
89
  *
@@ -7,7 +7,7 @@
7
7
  * 좌표·representations 는 트윈 공간층(보완/추가). 실 시스템이 좌표를 안 주면 layout 없이 위상만 인제스트.
8
8
  */
9
9
 
10
- import type { DomainSystem, EquipmentLevel, TestSpecificationCriterion, WorkCalendarEntry } from '@operato/twin-kernel'
10
+ import type { DomainSystem, EquipmentLevel, TestSpecificationCriterion, WorkCalendarEntry } from '@operato/ops-contract'
11
11
 
12
12
  export interface RefLocation {
13
13
  id: string
@@ -150,7 +150,16 @@ export interface RefEquipment {
150
150
  homeLocationId: string
151
151
  mtbfMs?: number // 평균 고장 간격(확률적 고장) — 지정 시 커널이 breakdown 시뮬 → 주목(고장) 발생
152
152
  mttrMs?: number // 평균 수리 시간
153
- gs1Id?: string // 정식 GS1 정체성(§J) — 설비=GIAI.
153
+ /**
154
+ * **이 설비의 전역 정체성** — 커널 선언의 `equipment[].identity` 로 그대로 간다.
155
+ *
156
+ * 예전 이름은 `gs1Id` 였다. 그런데 **GS1 키가 아닌 것이 정상이다** — 실측한 값은 인버터의
157
+ * 하드웨어 식별자(`ES100000007d8d061d-1`)이고, 원본이 GIAI 를 주면 그것도 여기 들어온다.
158
+ * 이름이 한 종류만 가리키면 다른 종류가 들어올 때 그 자리를 못 쓴다.
159
+ *
160
+ * 자리(`RefLocation.gs1Id`)는 그대로다 — 그쪽은 실제로 SGLN 이 온다.
161
+ */
162
+ identity?: string
154
163
  /**
155
164
  * 교대(가동시간) — 이 시간대에만 배정된다. `startHour <= endHour` 면 같은 날, 넘어가면 야간(22→6).
156
165
  * 미지정=24시간 가용. 교대 밖 자원은 고장·계획정지와 **구별되는 이유**로 쉰다(상태에 드러난다).
@@ -740,7 +749,7 @@ export function masterToTwin(master: ReferenceMaster, catalog?: any): IngestedTw
740
749
 
741
750
  const equipment = (master.equipment ?? []).map(e => ({
742
751
  id: e.id, kind: e.kind, ...(e.name ? { name: e.name } : {}), ...(e.description ? { description: e.description } : {}), ...(e.testResults?.length ? { testResults: e.testResults } : {}),
743
- homeLocation: e.homeLocationId, mtbfMs: e.mtbfMs, mttrMs: e.mttrMs, gs1Id: e.gs1Id,
752
+ homeLocation: e.homeLocationId, mtbfMs: e.mtbfMs, mttrMs: e.mttrMs, ...(e.identity ? { identity: e.identity } : {}),
744
753
  ...(e.window ? { window: e.window } : {}),
745
754
  /* 속성은 손대지 않고 통과 — 이동시간 추정기가 kind 별 속도를 여기서 읽는다(단위 코드 그대로). */
746
755
  ...(e.properties?.length ? { properties: e.properties } : {}), ...(e.workCalendar?.length ? { workCalendar: e.workCalendar } : {}), ...(e.testSpecificationIds?.length ? { testSpecificationIds: e.testSpecificationIds } : {}),
@@ -7,7 +7,7 @@ import { ScalarObject, getRepository } from '@things-factory/shell'
7
7
 
8
8
  import { TwinEngine } from '../../engine/index.js'
9
9
  import { readRestartPolicy } from '../../engine/restart-policy.js'
10
- import { readBoardEquipment } from '@operato/twin-kernel'
10
+ import { readBoardEquipment } from '@operato/ops-contract'
11
11
 
12
12
  import { applyKnobDefaults, filledKnobKeys } from './knob-defaults.js'
13
13
  import type { KnobDef } from './template-registry.js'
@@ -4,7 +4,7 @@
4
4
  * 둘 다 같은 인제스트 경로(ingestMaster)로 흐른다 — 원천이 고정이냐 합성이냐만 다르다(board-authoring §10).
5
5
  * 앱(operato-twin)이 bootstrap 에서 자기 아키타입을 registerTemplate 한다. 표현(map/image/model) 무관.
6
6
  */
7
- import type { DomainSystem } from '@operato/twin-kernel'
7
+ import type { DomainSystem } from '@operato/ops-contract'
8
8
 
9
9
  import type { ReferenceMaster } from './reference-master.js'
10
10