@things-factory/headless-twin 10.0.10 → 10.0.12

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 (138) hide show
  1. package/dist-server/engine/canonical-ingest.d.ts +25 -2
  2. package/dist-server/engine/canonical-ingest.js +48 -10
  3. package/dist-server/engine/canonical-ingest.js.map +1 -1
  4. package/dist-server/engine/declared-stimulus.d.ts +43 -0
  5. package/dist-server/engine/declared-stimulus.js +57 -0
  6. package/dist-server/engine/declared-stimulus.js.map +1 -0
  7. package/dist-server/engine/index.d.ts +2 -0
  8. package/dist-server/engine/index.js +2 -0
  9. package/dist-server/engine/index.js.map +1 -1
  10. package/dist-server/engine/kpi-fold.d.ts +12 -0
  11. package/dist-server/engine/kpi-fold.js +21 -1
  12. package/dist-server/engine/kpi-fold.js.map +1 -1
  13. package/dist-server/engine/kpi-query.d.ts +3 -3
  14. package/dist-server/engine/kpi-query.js +4 -4
  15. package/dist-server/engine/kpi-query.js.map +1 -1
  16. package/dist-server/engine/live-feed-registry.d.ts +1 -1
  17. package/dist-server/engine/live-feed-registry.js +1 -1
  18. package/dist-server/engine/live-feed-registry.js.map +1 -1
  19. package/dist-server/engine/local-declarations.d.ts +3 -6
  20. package/dist-server/engine/local-declarations.js +97 -16
  21. package/dist-server/engine/local-declarations.js.map +1 -1
  22. package/dist-server/engine/measured-yield.d.ts +42 -0
  23. package/dist-server/engine/measured-yield.js +75 -0
  24. package/dist-server/engine/measured-yield.js.map +1 -0
  25. package/dist-server/engine/operation-basis.d.ts +16 -0
  26. package/dist-server/engine/operation-basis.js +20 -2
  27. package/dist-server/engine/operation-basis.js.map +1 -1
  28. package/dist-server/engine/property-effects.js +17 -0
  29. package/dist-server/engine/property-effects.js.map +1 -1
  30. package/dist-server/engine/restart-policy.d.ts +13 -0
  31. package/dist-server/engine/restart-policy.js +52 -0
  32. package/dist-server/engine/restart-policy.js.map +1 -0
  33. package/dist-server/engine/spec-coverage.d.ts +8 -0
  34. package/dist-server/engine/spec-coverage.js +3 -1
  35. package/dist-server/engine/spec-coverage.js.map +1 -1
  36. package/dist-server/engine/twin-engine.d.ts +48 -15
  37. package/dist-server/engine/twin-engine.js +179 -39
  38. package/dist-server/engine/twin-engine.js.map +1 -1
  39. package/dist-server/index.js +1 -1
  40. package/dist-server/index.js.map +1 -1
  41. package/dist-server/service/index.d.ts +1 -1
  42. package/dist-server/service/reference/control-routing.d.ts +14 -0
  43. package/dist-server/service/reference/control-routing.js +64 -0
  44. package/dist-server/service/reference/control-routing.js.map +1 -0
  45. package/dist-server/service/reference/index.d.ts +1 -0
  46. package/dist-server/service/reference/index.js +2 -0
  47. package/dist-server/service/reference/index.js.map +1 -1
  48. package/dist-server/service/reference/reference-adapter.d.ts +67 -0
  49. package/dist-server/service/reference/reference-adapter.js +18 -0
  50. package/dist-server/service/reference/reference-adapter.js.map +1 -1
  51. package/dist-server/service/reference/reference-live.js +3 -3
  52. package/dist-server/service/reference/reference-live.js.map +1 -1
  53. package/dist-server/service/reference/reference-resolver.d.ts +3 -3
  54. package/dist-server/service/reference/reference-resolver.js +35 -18
  55. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  56. package/dist-server/service/twin-audit/command-audit.d.ts +34 -0
  57. package/dist-server/service/twin-audit/command-audit.js +15 -1
  58. package/dist-server/service/twin-audit/command-audit.js.map +1 -1
  59. package/dist-server/service/twin-audit/twin-audit-event.d.ts +3 -0
  60. package/dist-server/service/twin-audit/twin-audit-event.js +28 -2
  61. package/dist-server/service/twin-audit/twin-audit-event.js.map +1 -1
  62. package/dist-server/service/twin-control/twin-control-mutation.d.ts +15 -2
  63. package/dist-server/service/twin-control/twin-control-mutation.js +85 -34
  64. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  65. package/dist-server/service/twin-event/twin-event.js +1 -1
  66. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  67. package/dist-server/service/twin-instance/twin-instance.d.ts +1 -1
  68. package/dist-server/service/twin-instance/twin-instance.js +2 -2
  69. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  70. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +2 -1
  71. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  72. package/dist-server/service/twin-model/twin-equipment.js +1 -1
  73. package/dist-server/service/twin-model/twin-equipment.js.map +1 -1
  74. package/dist-server/service/twin-model/twin-location.js +1 -1
  75. package/dist-server/service/twin-model/twin-location.js.map +1 -1
  76. package/dist-server/service/twin-model/twin-model-query.js +1 -1
  77. package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
  78. package/dist-server/service/twin-model/twin-operation.js +1 -1
  79. package/dist-server/service/twin-model/twin-operation.js.map +1 -1
  80. package/package.json +3 -3
  81. package/server/engine/canonical-ingest.ts +78 -14
  82. package/server/engine/declared-stimulus.ts +66 -0
  83. package/server/engine/index.ts +2 -0
  84. package/server/engine/kpi-fold.ts +29 -1
  85. package/server/engine/kpi-query.ts +8 -8
  86. package/server/engine/live-feed-registry.ts +2 -2
  87. package/server/engine/local-declarations.ts +97 -19
  88. package/server/engine/measured-yield.ts +89 -0
  89. package/server/engine/operation-basis.ts +33 -2
  90. package/server/engine/property-effects.ts +17 -0
  91. package/server/engine/restart-policy.ts +55 -0
  92. package/server/engine/spec-coverage.ts +23 -3
  93. package/server/engine/twin-engine.ts +201 -42
  94. package/server/index.ts +1 -1
  95. package/server/service/reference/control-routing.ts +62 -0
  96. package/server/service/reference/index.ts +1 -0
  97. package/server/service/reference/reference-adapter.ts +78 -0
  98. package/server/service/reference/reference-live.ts +3 -3
  99. package/server/service/reference/reference-resolver.ts +46 -7
  100. package/server/service/twin-audit/command-audit.ts +34 -1
  101. package/server/service/twin-audit/twin-audit-event.ts +50 -4
  102. package/server/service/twin-control/twin-control-mutation.ts +75 -28
  103. package/server/service/twin-event/twin-event.ts +10 -2
  104. package/server/service/twin-instance/twin-instance.ts +16 -10
  105. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +2 -1
  106. package/server/service/twin-model/twin-equipment.ts +8 -1
  107. package/server/service/twin-model/twin-location.ts +8 -1
  108. package/server/service/twin-model/twin-model-query.ts +1 -1
  109. package/server/service/twin-model/twin-operation.ts +8 -1
  110. package/test/adopt-structure-live.test.ts +7 -7
  111. package/test/boot-resume.test.ts +3 -3
  112. package/test/column-type-portability.test.ts +122 -0
  113. package/test/control-capability.test.ts +103 -0
  114. package/test/declared-stimulus.test.ts +88 -0
  115. package/test/ingest-running-guard.test.ts +5 -5
  116. package/test/instance-cache-lifecycle.test.ts +1 -1
  117. package/test/kpi-baseline-db.test.ts +1 -1
  118. package/test/kpi-query-bench.test.ts +1 -1
  119. package/test/lineage-survives-restart.test.ts +3 -3
  120. package/test/live-feed-registry.test.ts +6 -6
  121. package/test/local-declarations.test.ts +108 -1
  122. package/test/measured-yield.test.ts +90 -0
  123. package/test/operation-basis.test.ts +28 -1
  124. package/test/operational-vocabulary.test.ts +108 -0
  125. package/test/operations-capability-db.test.ts +4 -4
  126. package/test/project-structure-db.test.ts +1 -1
  127. package/test/projection-reaches-screen.test.ts +1 -1
  128. package/test/property-effects.test.ts +28 -0
  129. package/test/restart-policy.test.ts +111 -0
  130. package/test/resync-origin-site.test.ts +7 -1
  131. package/test/source-outcome-audit.test.ts +104 -0
  132. package/test/structure-revision-db.test.ts +27 -27
  133. package/test/tenant-registry-db.test.ts +2 -2
  134. package/test/twin-model-item-db.test.ts +3 -3
  135. package/test/twin-model-tree-db.test.ts +7 -7
  136. package/test/twin-origin-resync.test.ts +4 -4
  137. package/test/yield-loop.test.ts +144 -0
  138. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,104 @@
1
+ /*
2
+ * **허락과 일어남 사이를 같은 행에 닫는다** — ADR-0029 §1 결정 C (2026-08-19).
3
+ *
4
+ * ── 무엇이 열려 있었나 ──────────────────────────────────────────────────────
5
+ * 명령이 원본으로 가면 감사는 「허락했다」까지만 증명한다: 어댑터가 조용히 실패하면 원장에는 승인만 남는다.
6
+ * 그리고 구동 문(`controlTwinSource`)은 감사를 **아예 남기지 않았다** — 자극을 바꾸고 배속을 올리는 것은
7
+ * 그 트윈의 사실을 바꾸는 행위인데, 나중에 「무엇이 이 공장을 흔들었나」에 답할 수 없었다.
8
+ *
9
+ * ── 세 값을 구별한다 ────────────────────────────────────────────────────────
10
+ * · 칸이 **없음** — 원본에 묻지 않았다(커널이 받은 명령).
11
+ * · `unknown` — 물었는데 답이 없다. **성공으로 적지 않는다.**
12
+ * · `applied`·`refused` — 원본이 답했다(둘 다 사실이다).
13
+ *
14
+ * 셋을 뭉개면 둘 중 하나가 된다: 일어난 일을 없던 일로 만들거나, 없던 일을 일어난 일로 만든다.
15
+ */
16
+ import { test } from 'node:test'
17
+ import assert from 'node:assert/strict'
18
+ import { readFileSync } from 'node:fs'
19
+ import { fileURLToPath } from 'node:url'
20
+
21
+ import { buildCommandAudit, sourceOutcomeOf } from '../server/service/twin-audit/command-audit.ts'
22
+
23
+ const at = new Date('2026-08-19T05:00:00.000Z')
24
+
25
+ test('묻지 않았으면 칸을 만들지 않는다 — 커널이 받은 명령이 그렇다', () => {
26
+ const row = buildCommandAudit({ command: { type: 'order.hold', origin: 'user' }, ack: { accepted: true }, at })
27
+ assert.equal('sourceOutcome' in row, false, '없음은 「모른다」가 아니라 「묻지 않았다」다')
28
+ assert.equal('sourceCode' in row, false)
29
+ })
30
+
31
+ test('물었는데 답이 없으면 unknown — 성공으로 적지 않는다', () => {
32
+ const row = buildCommandAudit({
33
+ command: { type: 'source.stimulus', origin: 'user' },
34
+ ack: { accepted: true },
35
+ source: { answered: false },
36
+ at
37
+ })
38
+ assert.equal(row.sourceOutcome, 'unknown')
39
+ })
40
+
41
+ test('답이 있으면 그대로 적는다 — 거절도 사실이다', () => {
42
+ const applied = buildCommandAudit({ command: {}, ack: { accepted: true }, source: { answered: true, ok: true }, at })
43
+ assert.equal(applied.sourceOutcome, 'applied')
44
+ const refused = buildCommandAudit({
45
+ command: {},
46
+ ack: { accepted: true },
47
+ source: { answered: true, ok: false, code: 'not-declared' },
48
+ at
49
+ })
50
+ assert.equal(refused.sourceOutcome, 'refused')
51
+ assert.equal(refused.sourceCode, 'not-declared', '원본이 준 사유를 그대로 적는다')
52
+ })
53
+
54
+ test('사유가 없으면 사유 칸도 없다 — 지어내지 않는다', () => {
55
+ const row = buildCommandAudit({ command: {}, ack: { accepted: true }, source: { answered: true, ok: true }, at })
56
+ assert.equal('sourceCode' in row, false)
57
+ })
58
+
59
+ test('판정 함수가 세 값만 낸다 — ok 가 참/거짓이 아니면 모른다', () => {
60
+ assert.equal(sourceOutcomeOf({ answered: true, ok: true }), 'applied')
61
+ assert.equal(sourceOutcomeOf({ answered: true, ok: false }), 'refused')
62
+ assert.equal(sourceOutcomeOf({ answered: false }), 'unknown')
63
+ assert.equal(sourceOutcomeOf({ answered: true }), 'unknown', '답했다고 하면서 값이 없으면 모르는 것이다')
64
+ assert.equal(sourceOutcomeOf({ answered: true, ok: undefined as any }), 'unknown')
65
+ })
66
+
67
+ test('신원은 여전히 서버만 채운다 — 원본의 답이 그 규율을 흔들지 않는다', () => {
68
+ const row = buildCommandAudit({
69
+ command: { type: 'source.pause', origin: 'user', actorId: 'forged', actorEmail: 'forged@x' },
70
+ ack: { accepted: true },
71
+ source: { answered: true, ok: true },
72
+ at,
73
+ actor: { id: 'u1', email: 'real@x' }
74
+ })
75
+ assert.equal(row.actorId, 'u1')
76
+ assert.equal(row.actorEmail, 'real@x')
77
+ })
78
+
79
+ /*
80
+ * ── 구동 문이 실제로 남기는지 (소스로 지킨다) ───────────────────────────────
81
+ * 순수 함수만 시험하면 「기록을 만들 수 있다」까지만 지킨다. 남기지 않으면 그 함수는 아무 일도 하지 않는다.
82
+ */
83
+ test('구동 문이 게이트 뒤에서 원본의 답까지 남긴다', () => {
84
+ const src = readFileSync(fileURLToPath(new URL('../server/service/twin-control/twin-control-mutation.ts', import.meta.url)), 'utf-8')
85
+ const code = src
86
+ .split('\n')
87
+ .filter(line => !/^\s*(\*|\/\/|\/\*)/.test(line))
88
+ .join('\n')
89
+ /* 게이트는 남는다(결정 C 의 절반). */
90
+ const gates = code.match(/@privilege\(category: "twin", privilege: "mutation"/g) ?? []
91
+ assert.ok(gates.length >= 2, `구동 문에도 게이트가 있어야 한다(발견 ${gates.length})`)
92
+ /* 원본의 답을 같은 행에 닫는다. */
93
+ assert.match(code, /source: \{ answered: typeof result\?\.ok === 'boolean', ok: result\?\.ok, code: result\?\.code \}/)
94
+ assert.match(code, /type: `source\.\$\{action\}`/, '커맨드 채널과 같은 어휘로 적는다')
95
+ /* 기록 실패를 삼키지 않는다. */
96
+ assert.match(code, /source control audit not recorded/)
97
+ })
98
+
99
+ test('엔티티가 그 두 칸을 든다 — 비어 있음이 뜻을 갖는다', () => {
100
+ const src = readFileSync(fileURLToPath(new URL('../server/service/twin-audit/twin-audit-event.ts', import.meta.url)), 'utf-8')
101
+ assert.match(src, /sourceOutcome\?: string/)
102
+ assert.match(src, /sourceCode\?: string/)
103
+ assert.match(src, /the source was not asked/, '비어 있음의 뜻을 필드 설명이 말한다')
104
+ })
@@ -95,9 +95,9 @@ beforeEach(async () => {
95
95
 
96
96
  test('같은 구조로 다시 프로비저닝하면 리비전이 늘지 않는다 — 부팅마다 늘면 이력이 잘게 쪼개진다', async () => {
97
97
  const model = boardWith(2, 1)
98
- await TwinEngine.provision(domainId, INSTANCE, 'wms', model)
99
- await TwinEngine.provision(domainId, INSTANCE, 'wms', model)
100
- await TwinEngine.provision(domainId, INSTANCE, 'wms', JSON.parse(JSON.stringify(model)))
98
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', model, 'reset')
99
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', model, 'reset')
100
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', JSON.parse(JSON.stringify(model)), 'reset')
101
101
 
102
102
  const revs = await ds.getRepository(TwinStructure).find({ where: { domain: { id: domainId }, instanceId: INSTANCE } })
103
103
  assert.equal(revs.length, 1)
@@ -105,12 +105,12 @@ test('같은 구조로 다시 프로비저닝하면 리비전이 늘지 않는
105
105
  })
106
106
 
107
107
  test('구조가 바뀌면 새 리비전이 생기고 **저널은 살아남는다**', async () => {
108
- await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
108
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset')
109
109
  const first = realEvents(boardWith(2, 1), 300)
110
110
  assert.ok(first.length > 0, '이벤트가 있어야 "살아남는지" 를 물을 수 있다')
111
111
  await TwinEngine.persistBatch(domainId, INSTANCE, first, 0)
112
112
 
113
- await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3))
113
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), 'reset')
114
114
 
115
115
  const kept = await ds.getRepository(TwinEvent).count({ where: { domain: { id: domainId }, instanceId: INSTANCE } })
116
116
  assert.equal(kept, first.length, '구조가 바뀌었다고 이력을 지우면 안 된다 — 그것이 이 작업의 전부다')
@@ -121,9 +121,9 @@ test('구조가 바뀌면 새 리비전이 생기고 **저널은 살아남는다
121
121
  })
122
122
 
123
123
  test('이벤트가 자기 구조 번호를 달고 저장된다 — 안 달면 나중에 어느 공장인지 알 길이 없다', async () => {
124
- await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
124
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset')
125
125
  await TwinEngine.persistBatch(domainId, INSTANCE, realEvents(boardWith(2, 1), 200), 0)
126
- await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3))
126
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), 'reset')
127
127
  const before = await ds.getRepository(TwinEvent).count({ where: { domain: { id: domainId }, instanceId: INSTANCE } })
128
128
  await TwinEngine.persistBatch(domainId, INSTANCE, realEvents(boardWith(6, 3), 200), before)
129
129
 
@@ -135,11 +135,11 @@ test('이벤트가 자기 구조 번호를 달고 저장된다 — 안 달면
135
135
  })
136
136
 
137
137
  test('★ 재생이 경계를 넘는다 — 앞선 사실은 남고, 새 구조의 설비가 들어온다', async () => {
138
- await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
138
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset')
139
139
  const first = realEvents(boardWith(2, 1), 300)
140
140
  await TwinEngine.persistBatch(domainId, INSTANCE, first, 0)
141
141
 
142
- await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3))
142
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), 'reset')
143
143
  await TwinEngine.persistBatch(domainId, INSTANCE, realEvents(boardWith(6, 3), 300), first.length)
144
144
 
145
145
  const state = await TwinEngine.recover(domainId, INSTANCE)
@@ -153,20 +153,20 @@ test('구조 번호가 없는 옛 행은 **가장 오래된 구조**로 접는
153
153
  * 컬럼이 생기기 전에 쓰인 행은 `structureRev` 가 비어 있다. 그것을 현재 보드로 접으면 그때 없던
154
154
  * 설비에서 일이 있었던 것처럼 보인다 — 이 규칙이 그 행들을 제자리에 붙인다.
155
155
  */
156
- await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
156
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset')
157
157
  const legacy = realEvents(boardWith(2, 1), 300)
158
158
  await TwinEngine.persistBatch(domainId, INSTANCE, legacy, 0)
159
159
  /* 컬럼이 없던 시절을 흉내 낸다 — 번호를 지운다. */
160
160
  await ds.query(`UPDATE "${ds.getMetadata(TwinEvent).tableName}" SET structure_rev = NULL`)
161
161
 
162
- await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3))
162
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), 'reset')
163
163
  const state = await TwinEngine.recover(domainId, INSTANCE)
164
164
  assert.ok(state.items.length > 0, '번호 없는 옛 행도 읽혀야 한다 — 조용히 버리지 않는다')
165
165
  assert.equal(state.equipment.length, 3, '마지막 구조가 자원의 진실이다')
166
166
  })
167
167
 
168
168
  test('리비전이 하나뿐이면 예전과 똑같이 접는다 — 구조 전환이 결과를 바꾸지 않는다', async () => {
169
- await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(3, 2))
169
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(3, 2), 'reset')
170
170
  const events = realEvents(boardWith(3, 2), 300)
171
171
  await TwinEngine.persistBatch(domainId, INSTANCE, events, 0)
172
172
 
@@ -177,7 +177,7 @@ test('리비전이 하나뿐이면 예전과 똑같이 접는다 — 구조 전
177
177
  })
178
178
 
179
179
  test('꺼진 트윈의 이력도 읽을 수 있다 — 저널은 트윈이 꺼져 있을 때 가장 필요하다', async () => {
180
- await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
180
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset')
181
181
  assert.equal(TwinEngine.owns(domainId, INSTANCE), false, '떠 있지 않다')
182
182
  assert.equal(await TwinEngine.ownsRegistered(domainId, INSTANCE), true, '그래도 이 테넌트 것이다')
183
183
  assert.equal(await TwinEngine.ownsRegistered(domainId, 'someone-elses-twin'), false)
@@ -189,11 +189,11 @@ test('시간여행은 **그때의 공장**을 보여준다 — 과거에 최신
189
189
  * 그런데 그 규칙을 과거 조회에도 적용하면, 그 시점에 존재하지도 않던 설비가 화면에 선다 —
190
190
  * 이 작업이 막으려던 바로 그 거짓말이다. 규칙이 "지금" 에만 걸리는지 못박는다.
191
191
  */
192
- await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
192
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset')
193
193
  const first = realEvents(boardWith(2, 1), 300)
194
194
  await TwinEngine.persistBatch(domainId, INSTANCE, first, 0)
195
195
 
196
- await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3))
196
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), 'reset')
197
197
 
198
198
  const now = await TwinEngine.recover(domainId, INSTANCE)
199
199
  assert.equal(now.equipment.length, 3, '"지금" 은 늘린 설비를 보여준다')
@@ -204,10 +204,10 @@ test('시간여행은 **그때의 공장**을 보여준다 — 과거에 최신
204
204
  })
205
205
 
206
206
  test('구조 이력이 **무엇이 달라졌는지** 알린다 — 보드 두 개를 눈으로 비교할 수는 없다', async () => {
207
- await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
207
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset')
208
208
  const first = realEvents(boardWith(2, 1), 200)
209
209
  await TwinEngine.persistBatch(domainId, INSTANCE, first, 0)
210
- await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), '도장 능력 보강')
210
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), 'reset', '도장 능력 보강')
211
211
  await TwinEngine.persistBatch(domainId, INSTANCE, realEvents(boardWith(6, 3), 100), first.length)
212
212
 
213
213
  const history = await TwinEngine.structureHistory(domainId, INSTANCE)
@@ -232,11 +232,11 @@ test('구조 이력이 **무엇이 달라졌는지** 알린다 — 보드 두
232
232
  })
233
233
 
234
234
  test('번호 없는 옛 행도 가장 오래된 리비전의 몫으로 센다 — 재생과 같은 규칙', async () => {
235
- await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
235
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset')
236
236
  const legacy = realEvents(boardWith(2, 1), 200)
237
237
  await TwinEngine.persistBatch(domainId, INSTANCE, legacy, 0)
238
238
  await ds.query(`UPDATE "${ds.getMetadata(TwinEvent).tableName}" SET structure_rev = NULL`)
239
- await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3))
239
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), 'reset')
240
240
 
241
241
  const history = await TwinEngine.structureHistory(domainId, INSTANCE)
242
242
  assert.equal(history[0].events, legacy.length, '재생이 rev 1 로 접는 행들을 세는 쪽만 빠뜨리면 두 화면이 다른 말을 한다')
@@ -250,11 +250,11 @@ test('지문 표기가 낡았을 뿐이면 리비전을 만들지 않는다 —
250
250
  * 그런 일이 없다.
251
251
  */
252
252
  const model = boardWith(2, 1)
253
- await TwinEngine.provision(domainId, INSTANCE, 'wms', model)
253
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', model, 'reset')
254
254
  const table = ds.getMetadata(TwinStructure).tableName
255
255
  await ds.query(`UPDATE "${table}" SET signature = 'old-style-signature-from-a-previous-algorithm'`)
256
256
 
257
- await TwinEngine.provision(domainId, INSTANCE, 'wms', model)
257
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', model, 'reset')
258
258
 
259
259
  const revs = await ds.getRepository(TwinStructure).find({ where: { domain: { id: domainId }, instanceId: INSTANCE } })
260
260
  assert.equal(revs.length, 1, '공장은 그대로인데 리비전이 늘면 이력이 거짓으로 부푼다')
@@ -269,11 +269,11 @@ test('지문 표기가 낡았을 뿐이면 리비전을 만들지 않는다 —
269
269
  * 행과 겹치는 문제였다. 그 겹침은 오류를 내지 않고 조용히 재생 순서를 뒤섞는다.
270
270
  */
271
271
  test('sim-world 로 다시 띄워도 저널이 살아남는다 — 선언이 지키는 약속', async () => {
272
- await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
272
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset')
273
273
  const first = realEvents(boardWith(2, 1), 300)
274
274
  await TwinEngine.persistBatch(domainId, INSTANCE, first, 0)
275
275
 
276
- const rt = await TwinEngine.bootDeclared(domainId, INSTANCE, 'sim-world')
276
+ const rt = await TwinEngine.bootDeclared(domainId, INSTANCE, 'resume')
277
277
  try {
278
278
  const kept = await ds.getRepository(TwinEvent).count({ where: { domain: { id: domainId }, instanceId: INSTANCE } })
279
279
  assert.equal(kept, first.length, 'sim-world 는 이어지는 현실이다 — 지우면 선언이 거짓이 된다')
@@ -283,11 +283,11 @@ test('sim-world 로 다시 띄워도 저널이 살아남는다 — 선언이 지
283
283
  })
284
284
 
285
285
  test('★ 재기동 후 새 이벤트는 마지막 번호 뒤에 온다 — 겹치면 재생 순서가 뒤섞인다', async () => {
286
- await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
286
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset')
287
287
  const first = realEvents(boardWith(2, 1), 300)
288
288
  await TwinEngine.persistBatch(domainId, INSTANCE, first, 0)
289
289
 
290
- const rt = await TwinEngine.bootDeclared(domainId, INSTANCE, 'sim-world')
290
+ const rt = await TwinEngine.bootDeclared(domainId, INSTANCE, 'resume')
291
291
  try {
292
292
  const kernel: any = rt.kernel
293
293
  assert.ok(kernel, '커널이 떠야 이어 세기를 물을 수 있다')
@@ -301,8 +301,8 @@ test('★ 재기동 후 새 이벤트는 마지막 번호 뒤에 온다 — 겹
301
301
  })
302
302
 
303
303
  test('저널이 비어 있으면 이어 셀 것이 없다 — 새로 시작하는 트윈에 영향 없음', async () => {
304
- await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
305
- const rt = await TwinEngine.bootDeclared(domainId, INSTANCE, 'sim-world')
304
+ await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset')
305
+ const rt = await TwinEngine.bootDeclared(domainId, INSTANCE, 'resume')
306
306
  try {
307
307
  assert.equal((rt.kernel as any).getSnapshot().revision, 0)
308
308
  } finally {
@@ -61,8 +61,8 @@ before(async () => {
61
61
  beta = b.id
62
62
 
63
63
  /* 두 테넌트가 **같은 id** 로 각자 프로비저닝한다 — DB 유일성이 `[domain, instanceId]` 라 허용된다. */
64
- await TwinEngine.provision(alpha, SHARED_ID, 'wms', model())
65
- await TwinEngine.provision(beta, SHARED_ID, 'wms', model())
64
+ await TwinEngine.provision(alpha, SHARED_ID, 'wms', model(), 'reset')
65
+ await TwinEngine.provision(beta, SHARED_ID, 'wms', model(), 'reset')
66
66
  })
67
67
 
68
68
  /**
@@ -85,7 +85,7 @@ before(async () => {
85
85
  q = new TwinModelItemQuery()
86
86
  ctx = { state: { domain: { id: domainId } } }
87
87
 
88
- await TwinEngine.ingestMaster(domainId, master())
88
+ await TwinEngine.ingestMaster(domainId, master(), 'reset')
89
89
  })
90
90
 
91
91
  after(async () => {
@@ -213,7 +213,7 @@ test('지도의 선이 진단이 된다 — 이어진 것뿐 아니라 **끊긴
213
213
  const bad = master()
214
214
  bad.equipment.push({ id: 'ghost-1', kind: 'welder', homeLocationId: 'no-such-station' }) // 끊어진 참조
215
215
  bad.equipment.push({ id: 'floater-1', kind: 'welder' }) // 기준 자리 없음
216
- await TwinEngine.ingestMaster(domainId, bad)
216
+ await TwinEngine.ingestMaster(domainId, bad, 'reset')
217
217
 
218
218
  const sum = await new TwinModelQuery().twinModelSummary(INSTANCE, ctx)
219
219
  const home = sum.edges.find((e: any) => e.from === 'equipment' && e.to === 'locations' && e.via === 'twin.rel.home')
@@ -263,7 +263,7 @@ test('멈춘 트윈도 저널을 접어 답하고, 그 답은 다시 계산한
263
263
  for (let i = 0; i < 200; i++) k.tick(1000)
264
264
 
265
265
  const INST = 'journal-probe'
266
- await TwinEngine.provision(domainId, INST, 'wms', model)
266
+ await TwinEngine.provision(domainId, INST, 'wms', model, 'reset')
267
267
  await TwinEngine.persistBatch(domainId, INST, journal.all(), 0)
268
268
  await TwinEngine.stop(domainId, INST).catch(() => {})
269
269
  delete (TwinEngine as any).recovered[`${domainId}:${INST}`]
@@ -76,7 +76,7 @@ before(async () => {
76
76
  domainId = d.id
77
77
  q = new TwinModelTreeQuery()
78
78
  ctx = { state: { domain: { id: domainId } } }
79
- await TwinEngine.ingestMaster(domainId, master())
79
+ await TwinEngine.ingestMaster(domainId, master(), 'reset')
80
80
  })
81
81
 
82
82
  after(async () => {
@@ -108,13 +108,13 @@ test('수량을 곱해 내려간다 — 그리고 모르면 곱하지 않는다'
108
108
  { key: 'frame-line', label: 'f', inputs: [{ material: 'steel', qty: 4 }], outputs: [{ material: 'frame', qty: 1 }] }
109
109
  ]
110
110
  })
111
- await TwinEngine.ingestMaster(domainId, noQty)
111
+ await TwinEngine.ingestMaster(domainId, noQty, 'reset')
112
112
  const t2 = await explode('trailer')
113
113
  assert.equal(t2.items.find((n: any) => n.id === 'frame').qty, null, '모르는 수량을 1 로 메우지 않는다')
114
114
  assert.equal(t2.items.find((n: any) => n.id === 'steel').qty, null, '모르는 값 아래는 전부 모르는 값이다')
115
115
  assert.equal(t2.qtyIncomplete, true, '합계가 성립하지 않는다는 사실을 낸다')
116
116
 
117
- await TwinEngine.ingestMaster(domainId, master())
117
+ await TwinEngine.ingestMaster(domainId, master(), 'reset')
118
118
  })
119
119
 
120
120
  test('역전개도 같은 선언으로 — 방향만 뒤집는다', async () => {
@@ -146,11 +146,11 @@ test('사이클은 다시 탐색하지 않고 알린다', async () => {
146
146
  { key: 'frame-line', label: 'f', inputs: [{ material: 'trailer', qty: 1 }], outputs: [{ material: 'frame', qty: 1 }] }
147
147
  ]
148
148
  })
149
- await TwinEngine.ingestMaster(domainId, loop)
149
+ await TwinEngine.ingestMaster(domainId, loop, 'reset')
150
150
  const t = await explode('trailer')
151
151
  assert.ok(t.items.length < 10, '무한히 자라지 않는다')
152
152
  assert.ok(t.cycles.length > 0, '사이클을 만났다는 사실을 말한다')
153
- await TwinEngine.ingestMaster(domainId, master())
153
+ await TwinEngine.ingestMaster(domainId, master(), 'reset')
154
154
  })
155
155
 
156
156
  test('상한에서 끊기면 끊겼다고 알린다 — 조용히 자르지 않는다', async () => {
@@ -167,10 +167,10 @@ test('선언에 없는 자재를 참조하면 그 사실이 드러난다', async
167
167
  { key: 'trailer-line', label: 't', inputs: [{ material: 'no-such-part', qty: 1 }], outputs: [{ material: 'trailer', qty: 1 }] }
168
168
  ]
169
169
  })
170
- await TwinEngine.ingestMaster(domainId, ghost)
170
+ await TwinEngine.ingestMaster(domainId, ghost, 'reset')
171
171
  const t = await explode('trailer')
172
172
  const child = t.items.find((n: any) => n.id === 'no-such-part')
173
173
  assert.ok(child, '가리켜진 것은 그리되')
174
174
  assert.equal(child.known, false, '선언에 없다는 사실을 함께 낸다(있는 척하지 않는다)')
175
- await TwinEngine.ingestMaster(domainId, master())
175
+ await TwinEngine.ingestMaster(domainId, master(), 'reset')
176
176
  })
@@ -68,7 +68,7 @@ after(async () => {
68
68
  })
69
69
 
70
70
  test('인제스트가 원천을 각인한다 — 원천이 무엇이든 한 자리에', async () => {
71
- await TwinEngine.ingestMaster(domainId, master(BORN, 3, { kind: 'template', templateId: 'wh-unit-load', params: { bins: 3 } }))
71
+ await TwinEngine.ingestMaster(domainId, master(BORN, 3, { kind: 'template', templateId: 'wh-unit-load', params: { bins: 3 } }), 'reset')
72
72
  assert.deepEqual(await originOf(BORN), { kind: 'template', templateId: 'wh-unit-load', params: { bins: 3 } })
73
73
  })
74
74
 
@@ -78,7 +78,7 @@ test('원천을 모르는 재프로비전이 이미 아는 것을 지우지 않
78
78
  * 인제스트한다. 그때 `origin` 을 덮어 비우면 그 트윈은 **조용히 다시 읽을 수 없게 된다.**
79
79
  * 그런 종류의 손상은 다음번에 다시 읽으려 할 때까지 아무도 모른다.
80
80
  */
81
- await TwinEngine.ingestMaster(domainId, master(BORN, 5))
81
+ await TwinEngine.ingestMaster(domainId, master(BORN, 5), 'reset')
82
82
  assert.deepEqual(
83
83
  await originOf(BORN),
84
84
  { kind: 'template', templateId: 'wh-unit-load', params: { bins: 3 } },
@@ -90,12 +90,12 @@ test('원천을 모르는 재프로비전이 이미 아는 것을 지우지 않
90
90
  })
91
91
 
92
92
  test('새 원천을 말하면 갱신된다 — 보존은 침묵에만 적용된다', async () => {
93
- await TwinEngine.ingestMaster(domainId, master(BORN, 5, { kind: 'reference', source: 'oracle-wms' }))
93
+ await TwinEngine.ingestMaster(domainId, master(BORN, 5, { kind: 'reference', source: 'oracle-wms' }), 'reset')
94
94
  assert.deepEqual(await originOf(BORN), { kind: 'reference', source: 'oracle-wms' })
95
95
  })
96
96
 
97
97
  test('원천 없이 태어난 트윈은 원천이 없다 — 짐작해 채우지 않는다', async () => {
98
- await TwinEngine.ingestMaster(domainId, master(ORPHAN, 2))
98
+ await TwinEngine.ingestMaster(domainId, master(ORPHAN, 2), 'reset')
99
99
  assert.equal(await originOf(ORPHAN), null, '모르는 것을 아는 척하면 엉뚱한 원본으로 현장을 덮는다')
100
100
  })
101
101
 
@@ -0,0 +1,144 @@
1
+ /*
2
+ * **한 바퀴** — 커널이 라인을 돌리고, 그 이벤트를 접어, 배운 수율이 다시 커널로 들어간다.
3
+ *
4
+ * ── 왜 이 시험이 필요했나 (2026-08-19) ─────────────────────────────────────
5
+ * 층마다 시험이 있었다(커널 7 · 호스트 9). 그런데 **사슬이 이어졌는지**는 층 시험으로 알 수 없다 —
6
+ * 이벤트 모양이 한 칸만 어긋나도 각 층은 통과하면서 전체는 조용히 상수로 돈다.
7
+ *
8
+ * 실 앱에서 보려고 데모 MES 트윈으로 한 바퀴를 돌리려 했는데 두 번 막혔다: ① 데모의 자극(시나리오)은
9
+ * **시드 때만 실려서** 트윈을 재기동하면 사라진다 ② 모델에서 읽은 값으로 자극을 재구성했지만 부품 도착이
10
+ * 레시피 입력과 맞물리지 않아 라인이 돌지 않았다. 그 두 개는 **데모 데이터의 문제**이고, 우리가 증명해야
11
+ * 하는 것(사슬)과는 다른 문제다.
12
+ *
13
+ * 그래서 여기서 **DB·서버 없이** 진짜 사슬을 돌린다: 커널(MES) → `task.status` 이벤트 → 호스트의 실제
14
+ * 폴드(`foldKpi`) → 실제 추정기(`buildYieldEstimator`) → 커널 시임. 가짜 이벤트를 손으로 만들지 않는다 —
15
+ * 커널이 낸 것을 그대로 접는다.
16
+ */
17
+ import { test } from 'node:test'
18
+ import assert from 'node:assert/strict'
19
+ import { createRequire } from 'node:module'
20
+
21
+ import { foldKpi } from '../server/engine/kpi-fold.ts'
22
+ import { buildYieldEstimator } from '../server/engine/measured-yield.ts'
23
+
24
+ /* 커널은 한 벌로 묶인 산출물로 들어온다(서브패스는 exports 로 막혀 있다 — property-effects 시험과 같은 이유). */
25
+ const require = createRequire(import.meta.url)
26
+ const { MesKernel, MES_PART_GTINS } = require('@operato/twin-kernel')
27
+
28
+ /** 보드·자극은 커널 시험(`mes.test.ts`)의 것과 같은 모양 — 여기서 공장을 지어내지 않는다. */
29
+ function board() {
30
+ return {
31
+ locations: [
32
+ { id: 'raw-store', type: 'raw-store', capacity: 500 },
33
+ { id: 'cut-station', type: 'cut-station', capacity: 20 },
34
+ { id: 'weld-station', type: 'weld-station', capacity: 20 },
35
+ { id: 'paint-booth', type: 'paint-booth', capacity: 20 },
36
+ { id: 'assembly-line', type: 'assembly-line', capacity: 20 },
37
+ { id: 'fg-store', type: 'fg-store', capacity: 200 }
38
+ ],
39
+ equipment: [
40
+ { id: 'cutter-1', kind: 'cutter', homeLocation: 'cut-station' },
41
+ { id: 'welder-1', kind: 'welder', homeLocation: 'weld-station' },
42
+ { id: 'painter-1', kind: 'painter', homeLocation: 'paint-booth' },
43
+ { id: 'assembler-1', kind: 'assembler', homeLocation: 'assembly-line' }
44
+ ]
45
+ }
46
+ }
47
+
48
+ const partsInbound = {
49
+ seed: 3,
50
+ generators: [
51
+ {
52
+ kind: 'inbound-arrival',
53
+ rate: { distribution: 'constant', meanPerHour: 720 },
54
+ content: { skuMix: [{ gtin: MES_PART_GTINS.partA, weight: 1 }, { gtin: MES_PART_GTINS.partB, weight: 1 }], qtyPerLine: { min: 1, max: 1 } }
55
+ }
56
+ ]
57
+ }
58
+ const workOrders = {
59
+ seed: 7,
60
+ generators: [
61
+ {
62
+ kind: 'outbound-order',
63
+ rate: { distribution: 'constant', meanPerHour: 360 },
64
+ content: { skuMix: [{ gtin: MES_PART_GTINS.partA, weight: 1 }], qtyPerLine: { min: 1, max: 1 } }
65
+ }
66
+ ]
67
+ }
68
+
69
+ /** 라인을 한 바퀴 돌린다 — 부품을 채우고 작업지시를 흘리고 배수한다. */
70
+ function runLine(declaredYieldPct?: number): { events: any[]; kernel: any } {
71
+ const k: any = new MesKernel('plantA')
72
+ const events: any[] = []
73
+ k.onEvent((e: any) => events.push(e))
74
+ k.loadTwinModel(board())
75
+ /* 선언값을 넣어 두면 「실측이 선언을 이긴다」를 같은 라인에서 확인할 수 있다. */
76
+ if (declaredYieldPct !== undefined) k.declareParameters({ assembly: { yield: String(declaredYieldPct / 100) } })
77
+ k.scenario.load(partsInbound)
78
+ k.scenario.start()
79
+ for (let i = 0; i < 400; i++) k.tick(1000)
80
+ k.scenario.pause()
81
+ for (let i = 0; i < 30; i++) k.tick(1000)
82
+ k.scenario.load(workOrders)
83
+ k.scenario.start()
84
+ for (let i = 0; i < 300; i++) k.tick(1000)
85
+ k.scenario.pause()
86
+ for (let i = 0; i < 4000; i++) k.tick(1000)
87
+ return { events, kernel: k }
88
+ }
89
+
90
+ /** 커널 이벤트를 저널 줄 모양으로 — 호스트가 DB 에서 읽는 것과 같은 모양(payload 안에 data). */
91
+ const asJournal = (events: any[]) =>
92
+ events.filter(e => e.eventType === 'task.status').map(e => ({ eventType: 'task.status', eventTime: e.eventTime, payload: { data: e.data } }))
93
+
94
+ const window = (rows: { eventTime: string }[]) => {
95
+ const times = rows.map(r => Date.parse(r.eventTime)).filter(n => Number.isFinite(n))
96
+ return { fromMs: Math.min(...times) - 1000, toMs: Math.max(...times) + 1000 }
97
+ }
98
+
99
+ test('한 바퀴: 커널이 판정을 내고 → 폴드가 종류별로 세고 → 추정기가 비율을 배운다', () => {
100
+ const { events } = runLine()
101
+ const rows = asJournal(events)
102
+ assert.ok(rows.length > 50, `작업 전이가 충분히 나야 한다(관측 ${rows.length})`)
103
+
104
+ /* 호스트의 **실제** 폴드로 접는다 — 시험용 계산을 따로 만들지 않는다. */
105
+ const kpi = foldKpi(rows as any, window(rows as any), { groupBy: 'taskKind' })
106
+ const judged = (kpi.groups?.items ?? []).filter(g => g.quality)
107
+ assert.ok(judged.length > 0, '품질이 세어진 종류가 있어야 한다')
108
+ for (const g of judged) {
109
+ assert.ok(g.quality!.good + g.quality!.scrap > 0)
110
+ assert.ok(g.quality!.good + g.quality!.scrap <= g.tasks, '판정 수는 완료 수를 넘지 못한다')
111
+ }
112
+ /* 판정이 없는 종류도 있어야 한다 — 중간 스테이션은 품질을 가리지 않는다(분모에 섞이면 안 된다). */
113
+ assert.ok((kpi.groups?.items ?? []).some(g => !g.quality), '판정하지 않는 공정이 있다')
114
+
115
+ const learned = buildYieldEstimator(kpi.groups?.items, { minSamples: 5 })
116
+ assert.ok(Object.keys(learned.learned).length > 0, '수율을 배워야 한다')
117
+ for (const [kind, ratio] of Object.entries(learned.learned)) {
118
+ assert.ok(ratio >= 0 && ratio <= 1, `${kind} 의 비율이 0..1 이어야 한다(관측 ${ratio})`)
119
+ const s = learned.samples[kind]
120
+ assert.equal(learned.learned[kind], s.good / (s.good + s.scrap), '비율은 판정 수에서 곧바로 나온다')
121
+ }
122
+ })
123
+
124
+ test('배운 값이 커널로 되돌아가 선언값을 이긴다 — 사슬의 마지막 칸', () => {
125
+ /* 선언 60% 로 라인을 돌린다(그 현장의 이력이 그렇게 남는다). */
126
+ const { events } = runLine(60)
127
+ const rows = asJournal(events)
128
+ const kpi = foldKpi(rows as any, window(rows as any), { groupBy: 'taskKind' })
129
+ const learned = buildYieldEstimator(kpi.groups?.items, { minSamples: 5 })
130
+ const kinds = Object.keys(learned.learned)
131
+ assert.ok(kinds.length > 0, '이력에서 배운 종류가 있어야 한다')
132
+
133
+ /* 새 커널에 그 이력을 실으면 — 선언값(90%)이 아니라 이력값으로 답한다. */
134
+ const k2: any = new MesKernel('plantA')
135
+ k2.declareParameters(Object.fromEntries(kinds.map(kind => [kind, { yield: '0.9' }])))
136
+ k2.yieldEstimator = learned.estimator
137
+ for (const kind of kinds) {
138
+ assert.equal(k2.yieldOf(kind), learned.learned[kind], `${kind}: 이력값이 이긴다`)
139
+ assert.deepEqual(k2.specCoverage().operations.find((o: any) => o.kind === kind)?.parameterBasis, { yield: 'measured' })
140
+ }
141
+ /* 이력이 없는 종류는 그대로 선언값이다 — 배운 것만 이긴다(전부 덮지 않는다). */
142
+ k2.declareParameters({ 'never-run': { yield: '0.5' } })
143
+ assert.equal(k2.yieldOf('never-run'), 0.5)
144
+ })