@things-factory/headless-twin 10.0.11 → 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 (125) 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 +10 -0
  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-instance/twin-instance.d.ts +1 -1
  66. package/dist-server/service/twin-instance/twin-instance.js +2 -2
  67. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  68. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +2 -1
  69. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  70. package/dist-server/service/twin-model/twin-model-query.js +1 -1
  71. package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
  72. package/package.json +3 -3
  73. package/server/engine/canonical-ingest.ts +78 -14
  74. package/server/engine/declared-stimulus.ts +66 -0
  75. package/server/engine/index.ts +2 -0
  76. package/server/engine/kpi-fold.ts +29 -1
  77. package/server/engine/kpi-query.ts +8 -8
  78. package/server/engine/live-feed-registry.ts +2 -2
  79. package/server/engine/local-declarations.ts +97 -19
  80. package/server/engine/measured-yield.ts +89 -0
  81. package/server/engine/operation-basis.ts +33 -2
  82. package/server/engine/property-effects.ts +17 -0
  83. package/server/engine/restart-policy.ts +55 -0
  84. package/server/engine/spec-coverage.ts +23 -3
  85. package/server/engine/twin-engine.ts +201 -42
  86. package/server/index.ts +1 -1
  87. package/server/service/reference/control-routing.ts +62 -0
  88. package/server/service/reference/index.ts +1 -0
  89. package/server/service/reference/reference-adapter.ts +78 -0
  90. package/server/service/reference/reference-live.ts +3 -3
  91. package/server/service/reference/reference-resolver.ts +46 -7
  92. package/server/service/twin-audit/command-audit.ts +34 -1
  93. package/server/service/twin-audit/twin-audit-event.ts +20 -0
  94. package/server/service/twin-control/twin-control-mutation.ts +75 -28
  95. package/server/service/twin-instance/twin-instance.ts +16 -10
  96. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +2 -1
  97. package/server/service/twin-model/twin-model-query.ts +1 -1
  98. package/test/adopt-structure-live.test.ts +7 -7
  99. package/test/boot-resume.test.ts +3 -3
  100. package/test/control-capability.test.ts +103 -0
  101. package/test/declared-stimulus.test.ts +88 -0
  102. package/test/ingest-running-guard.test.ts +5 -5
  103. package/test/instance-cache-lifecycle.test.ts +1 -1
  104. package/test/kpi-baseline-db.test.ts +1 -1
  105. package/test/kpi-query-bench.test.ts +1 -1
  106. package/test/lineage-survives-restart.test.ts +3 -3
  107. package/test/live-feed-registry.test.ts +6 -6
  108. package/test/local-declarations.test.ts +108 -1
  109. package/test/measured-yield.test.ts +90 -0
  110. package/test/operation-basis.test.ts +28 -1
  111. package/test/operational-vocabulary.test.ts +108 -0
  112. package/test/operations-capability-db.test.ts +4 -4
  113. package/test/project-structure-db.test.ts +1 -1
  114. package/test/projection-reaches-screen.test.ts +1 -1
  115. package/test/property-effects.test.ts +28 -0
  116. package/test/restart-policy.test.ts +111 -0
  117. package/test/resync-origin-site.test.ts +7 -1
  118. package/test/source-outcome-audit.test.ts +104 -0
  119. package/test/structure-revision-db.test.ts +27 -27
  120. package/test/tenant-registry-db.test.ts +2 -2
  121. package/test/twin-model-item-db.test.ts +3 -3
  122. package/test/twin-model-tree-db.test.ts +7 -7
  123. package/test/twin-origin-resync.test.ts +4 -4
  124. package/test/yield-loop.test.ts +144 -0
  125. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,103 @@
1
+ /*
2
+ * **구동은 선언된 능력이다** — 그리고 원본을 지난다 (ADR-0029 §8, 2026-08-19).
3
+ *
4
+ * ── 무엇이 뚫려 있었나 ──────────────────────────────────────────────────────
5
+ * `controlTwinScenario` 가 트윈의 커널 런타임(`runtime.scenario`)을 **직접** 잡았다. 시뮬만 할 수 있는 일
6
+ * (자극·배속·정지)이 계약 밖의 지름길로 들어온 셈이고, 실 시스템에는 그 자리가 아예 없었다. ADR-0029 의
7
+ * 판정 문장(*"실물이 같은 계약으로 할 수 있는가"*)을 그대로 대면 그 통제면은 영원히 「규약의 결손」이 된다.
8
+ *
9
+ * 뒤집는다: 통제는 **선언된 어댑터 능력**이다. 선언하지 않은 원본에는 문이 열리지 않고, 그 사실이 결손이
10
+ * 아니라 사실이다(실 WMS 는 정지되지 않는다).
11
+ *
12
+ * 이 시험이 지키는 것:
13
+ * ① 선언과 구현이 **함께** 있어야 능력이다 — 선언만 있으면 화면이 거짓말을 하고, 구현만 있으면 닫아 둔
14
+ * 문을 여는 것이다(선언이 권위다).
15
+ * ② 능력 없는 원본은 **사유 코드로** 거절한다(조용한 실패 금지).
16
+ * ③ 없는 기능을 있는 것처럼 답하지 않는다(내장 시뮬은 아직 재씨앗을 못 한다).
17
+ */
18
+ import { test } from 'node:test'
19
+ import assert from 'node:assert/strict'
20
+ import { readFileSync } from 'node:fs'
21
+ import { fileURLToPath } from 'node:url'
22
+
23
+ import { capabilitiesOf, type ReferenceAdapter } from '../server/service/reference/reference-adapter.ts'
24
+
25
+ const base: ReferenceAdapter = {
26
+ type: 'probe',
27
+ async testConnection() {
28
+ return { ok: true }
29
+ },
30
+ async discoverSites() {
31
+ return []
32
+ },
33
+ async fetchMaster() {
34
+ return { source: 'p', system: 'wms', space: { spaceId: 'p-site' }, locations: [] } as any
35
+ }
36
+ }
37
+
38
+ test('선언도 구현도 없으면 아무 능력이 없다 — 구조만 주는 원본이 정상이다', () => {
39
+ assert.deepEqual(capabilitiesOf(base), [])
40
+ })
41
+
42
+ test('선언만 있으면 능력이 아니다 — 화면이 「할 수 있다」고 말한 뒤 아무 일도 없게 두지 않는다', () => {
43
+ assert.deepEqual(capabilitiesOf({ ...base, capabilities: ['control'] }), [])
44
+ assert.deepEqual(capabilitiesOf({ ...base, capabilities: ['live'] }), [])
45
+ })
46
+
47
+ test('구현만 있으면 능력이 아니다 — 선언이 권위다(닫아 둔 문을 열지 않는다)', () => {
48
+ assert.deepEqual(capabilitiesOf({ ...base, control: async () => ({ ok: true }) }), [])
49
+ assert.deepEqual(capabilitiesOf({ ...base, openLiveFeed: () => () => {} }), [])
50
+ })
51
+
52
+ test('선언과 구현이 함께 있으면 그 능력이다', () => {
53
+ assert.deepEqual(capabilitiesOf({ ...base, capabilities: ['control'], control: async () => ({ ok: true }) }), ['control'])
54
+ assert.deepEqual(
55
+ capabilitiesOf({
56
+ ...base,
57
+ capabilities: ['live', 'control'],
58
+ openLiveFeed: () => () => {},
59
+ control: async () => ({ ok: true })
60
+ }),
61
+ ['live', 'control']
62
+ )
63
+ })
64
+
65
+ test('어댑터가 없으면 빈손이다 — 지어내지 않는다', () => {
66
+ assert.deepEqual(capabilitiesOf(undefined), [])
67
+ })
68
+
69
+ /*
70
+ * ── 길이 하나뿐인지 (소스로 지킨다) ────────────────────────────────────────
71
+ * 지름길이 남아 있으면 이 결정은 문서상의 것이 된다. 그래서 ① 구동 문이 라우터를 지나고 ② 라우터가 능력을
72
+ * 보고 ③ 트윈 런타임을 직접 잡는 옛 경로가 사라졌는지 본다.
73
+ */
74
+ test('구동 문이 원본을 지난다 — 트윈 런타임을 직접 잡지 않는다', () => {
75
+ const src = readFileSync(fileURLToPath(new URL('../server/service/twin-control/twin-control-mutation.ts', import.meta.url)), 'utf-8')
76
+ const code = src
77
+ .split('\n')
78
+ .filter(line => !/^\s*(\*|\/\/|\/\*)/.test(line))
79
+ .join('\n')
80
+ assert.match(code, /routeControl\(domainId, instanceId/, '구동은 라우터를 지난다')
81
+ assert.equal(/runtime\.scenario\./.test(code), false, '트윈의 시나리오 엔진을 직접 잡는 지름길이 없다')
82
+ assert.equal(/controlTwinScenario/.test(code), false, '옛 문이 남아 있지 않다')
83
+ assert.match(code, /twinSourceCapability/, '화면이 먼저 물을 자리가 있다')
84
+ })
85
+
86
+ test('라우터가 선언을 본다 — 없으면 사유 코드로 거절한다', () => {
87
+ const src = readFileSync(fileURLToPath(new URL('../server/service/reference/control-routing.ts', import.meta.url)), 'utf-8')
88
+ assert.match(src, /capabilitiesOf\(adapter\)\.includes\('control'\)/)
89
+ assert.match(src, /'not-declared'/)
90
+ assert.match(src, /'no-source'/, '원본을 모르는 트윈은 구동 대상이 아니다')
91
+ })
92
+
93
+ test('내장 시뮬은 자기가 못 하는 것을 못 한다고 말한다 — 재씨앗은 아직 없다', () => {
94
+ const src = readFileSync(
95
+ fileURLToPath(new URL('../../operato-twin/server/board/virtual-adapter.ts', import.meta.url)),
96
+ 'utf-8'
97
+ )
98
+ /* §4 이후 이 원본은 둘을 선언한다(`live`·`control`) — 순서가 아니라 **있음**을 본다. */
99
+ assert.match(src, /capabilities: \[[^\]]*'control'[^\]]*\]/, '구동 능력을 선언한다')
100
+ assert.match(src, /capabilities: \[[^\]]*'live'[^\]]*\]/, '피드 능력도 선언한다(§4)')
101
+ assert.match(src, /cannot re-seed itself yet/, '없는 기능을 있는 것처럼 답하지 않는다')
102
+ assert.match(src, /not-running/, '아직 서지 않은 트윈에는 자극을 지어 싣지 않는다')
103
+ })
@@ -0,0 +1,88 @@
1
+ /*
2
+ * 자극은 **원본이 선언한다** — 그리고 재기동에도 살아 있다.
3
+ *
4
+ * ── 무엇이 났나 (2026-08-19) ────────────────────────────────────────────────
5
+ * 데모의 시나리오는 시드 코드가 **메모리에만** 실었다(`runtime.scenario.load`). 그래서 트윈을 재기동하면
6
+ * 자극이 사라지고 구조만 서 있는 트윈이 남았다 — 작업 0·오더 0. 「살아 있는 데모」가 첫 재기동까지만
7
+ * 살았고, 오늘 서버를 여러 번 띄우는 동안 데모 트윈들이 실제로 그 상태였다.
8
+ *
9
+ * ── 집을 트윈이 아니라 원본으로 (ADR-0029 · plans/simulator-as-source.md §4) ─
10
+ * 무엇이 들어오고 무슨 주문이 나는지는 **시뮬레이터(원본)가 정하는 사실**이고 트윈은 반영한다. 트윈에
11
+ * 축을 만들면 ADR-0029 가 나중에 걷어내야 하고, 원본(`TwinReference.connectionConfig`)에 두면 그 계획의
12
+ * 첫 조각(`openLiveFeed`)이 같은 선언을 그대로 읽는다.
13
+ *
14
+ * 이 시험이 지키는 것:
15
+ * ① 선언이 없으면 조용히 지난다(정적 구조만 반영하는 트윈이 정상이다).
16
+ * ② **미러에는 싣지 않는다** — 외부 실물이 진실인 트윈에 도착을 지어 주면 그것은 현실이 아니다.
17
+ * ③ 검사를 통과하지 못한 선언은 **태우지 않고** 이유를 남긴다(잘못된 선언 하나가 서버를 내린 이력이 있다).
18
+ * ④ 검사기가 없으면 싣지 않는다 — 검사할 수 없는 선언을 태우는 것이 가장 비싼 선택이다.
19
+ * ⑤ 선언을 얹을 때 **옆 설정을 지우지 않는다**(접속 정보가 같은 자리에 산다).
20
+ */
21
+ import { test } from 'node:test'
22
+ import assert from 'node:assert/strict'
23
+ import { readFileSync } from 'node:fs'
24
+ import { fileURLToPath } from 'node:url'
25
+
26
+ import { planStimulus, withStimulus } from '../server/engine/declared-stimulus.ts'
27
+
28
+ const scenario = { seed: 1, generators: [{ kind: 'inbound-arrival', rate: { distribution: 'constant', meanPerHour: 60 }, content: {} }] }
29
+ const ok = () => ({ ok: true as const })
30
+ const bad = () => ({ ok: false as const, errorCode: 'scenario-sku-mix-required' })
31
+ const sim = { hasScenarioEngine: true, mode: 'sim' as const }
32
+
33
+ test('선언이 없으면 조용히 지난다 — 구조만 반영하는 트윈이 정상이다', () => {
34
+ assert.deepEqual(planStimulus(undefined, sim, ok), { action: 'skip', reason: 'none' })
35
+ assert.deepEqual(planStimulus({ endpoint: 'x' }, sim, ok), { action: 'skip', reason: 'none' })
36
+ /* 시나리오 자리에 객체가 아닌 것이 오면 선언이 아니다(문자열을 시나리오로 읽지 않는다). */
37
+ assert.deepEqual(planStimulus({ scenario: 'daily' }, sim, ok), { action: 'skip', reason: 'none' })
38
+ })
39
+
40
+ test('관측으로 도는 트윈에는 싣지 않는다 — 현실을 지어내지 않는다', () => {
41
+ const plan = planStimulus({ scenario }, { hasScenarioEngine: true, mode: 'live' }, ok)
42
+ assert.deepEqual(plan, { action: 'skip', reason: 'observed' })
43
+ })
44
+
45
+ test('시나리오 엔진이 없으면 싣지 않는다 — 조용히 아무 일도 없게 두지 않는다', () => {
46
+ const plan = planStimulus({ scenario }, { hasScenarioEngine: false, mode: 'sim' }, ok)
47
+ assert.deepEqual(plan, { action: 'skip', reason: 'no-engine' })
48
+ })
49
+
50
+ test('검사를 통과하지 못한 선언은 태우지 않는다 — 사유를 든다', () => {
51
+ const plan = planStimulus({ scenario }, sim, bad)
52
+ assert.deepEqual(plan, { action: 'skip', reason: 'invalid', detail: 'scenario-sku-mix-required' })
53
+ })
54
+
55
+ test('검사기가 없으면 싣지 않는다 — 검사할 수 없는 선언이 가장 위험하다', () => {
56
+ const plan = planStimulus({ scenario }, sim, undefined)
57
+ assert.equal(plan.action, 'skip')
58
+ assert.equal((plan as any).reason, 'invalid')
59
+ })
60
+
61
+ test('통과한 선언은 그대로 싣는다 — 값을 손대지 않는다', () => {
62
+ const plan = planStimulus({ scenario }, sim, ok)
63
+ assert.deepEqual(plan, { action: 'load', scenario })
64
+ })
65
+
66
+ test('선언을 얹으면서 접속 정보를 지우지 않는다', () => {
67
+ const merged = withStimulus({ endpoint: 'https://sim', token: 't' }, scenario)
68
+ assert.deepEqual(merged, { endpoint: 'https://sim', token: 't', scenario })
69
+ /* 설정이 없던 원본에도 얹힌다(빈 객체에서 시작). */
70
+ assert.deepEqual(withStimulus(undefined, scenario), { scenario })
71
+ })
72
+
73
+ /*
74
+ * ── 배선 (소스로 지킨다) ────────────────────────────────────────────────────
75
+ * 판정은 순수 함수가 하지만, 그것을 **기동 때 부르지 않으면** 아무 일도 일어나지 않는다. 그리고 못 실은
76
+ * 이유는 말해야 한다 — 「선언했는데 안 실렸다」가 조용하면 사용자는 트윈이 고장 난 줄로 읽는다.
77
+ */
78
+ test('두 기동 경로에서 부른다 — 시뮬은 싣고, 미러는 그 사실을 말한다', () => {
79
+ const src = readFileSync(fileURLToPath(new URL('../server/engine/twin-engine.ts', import.meta.url)), 'utf-8')
80
+ const calls = src.match(/this\.installStimulus\(domainId, id, inst\)/g) ?? []
81
+ assert.equal(calls.length, 2, `기동 경로 둘에서 불러야 한다(발견 ${calls.length})`)
82
+ assert.match(src, /planStimulus\(config, \{ hasScenarioEngine/, '판정은 순수 함수에 맡긴다')
83
+ assert.match(src, /validateScenario/, '커널 검사기를 그대로 쓴다(규칙을 두 벌로 만들지 않는다)')
84
+ assert.match(src, /a stimulus is declared on its source but was not loaded/, '못 실은 이유를 말한다')
85
+ /* 선언을 적는 문도 있어야 한다 — 읽기만 있으면 아무도 선언할 수 없다. */
86
+ assert.match(src, /static async declareStimulus/)
87
+ assert.match(src, /no source reference — a stimulus has nowhere to be declared/, '원본이 없으면 지어 만들지 않는다')
88
+ })
@@ -86,7 +86,7 @@ before(async () => {
86
86
  const d = await ds.getRepository(Domain).save({ name: 'guard-test', subdomain: 'guard-test' } as any)
87
87
  domainId = d.id
88
88
 
89
- await TwinEngine.ingestMaster(domainId, master(3, '서울 창고', '보관 구역'))
89
+ await TwinEngine.ingestMaster(domainId, master(3, '서울 창고', '보관 구역'), 'reset')
90
90
  })
91
91
 
92
92
  after(async () => {
@@ -95,7 +95,7 @@ after(async () => {
95
95
  })
96
96
 
97
97
  test('멈춰 있으면 재인제스트가 통한다 — 막는 것은 가동 중일 때뿐이다', async () => {
98
- await TwinEngine.ingestMaster(domainId, master(5, '서울 창고', '보관 구역'))
98
+ await TwinEngine.ingestMaster(domainId, master(5, '서울 창고', '보관 구역'), 'reset')
99
99
  const t = await trace()
100
100
  assert.equal(t.boardLocations, 6, '자리 6개(도크 1 + 빈 5)로 갱신된다')
101
101
  assert.deepEqual(t.revs, [1, 2], '구조가 달라졌으니 리비전이 하나 늘어난다')
@@ -103,7 +103,7 @@ test('멈춰 있으면 재인제스트가 통한다 — 막는 것은 가동 중
103
103
  })
104
104
 
105
105
  test('가동 중이면 거절하고 — **아무것도 바꾸지 않는다**', async () => {
106
- await TwinEngine.startFromRegistry(domainId, INSTANCE, 'sim-experiment')
106
+ await TwinEngine.startFromRegistry(domainId, INSTANCE, 'reset')
107
107
  const before = await trace()
108
108
 
109
109
  /*
@@ -111,7 +111,7 @@ test('가동 중이면 거절하고 — **아무것도 바꾸지 않는다**', a
111
111
  * 부분 쓰기는 앞쪽(공간·구역)에서 일어났으므로, 구조만 확인하면 예전 결함을 못 잡는다.
112
112
  */
113
113
  await assert.rejects(
114
- () => TwinEngine.ingestMaster(domainId, master(9, '부산 창고', '다른 구역')),
114
+ () => TwinEngine.ingestMaster(domainId, master(9, '부산 창고', '다른 구역'), 'reset'),
115
115
  /is running/,
116
116
  '가동 중에는 거절해야 한다'
117
117
  )
@@ -121,7 +121,7 @@ test('가동 중이면 거절하고 — **아무것도 바꾸지 않는다**', a
121
121
 
122
122
  test('멈추면 다시 통한다 — 거절은 잠금이 아니라 순서다', async () => {
123
123
  await TwinEngine.stop(domainId, INSTANCE)
124
- await TwinEngine.ingestMaster(domainId, master(9, '부산 창고', '다른 구역'))
124
+ await TwinEngine.ingestMaster(domainId, master(9, '부산 창고', '다른 구역'), 'reset')
125
125
 
126
126
  const t = await trace()
127
127
  assert.equal(t.boardLocations, 10, '아까 거절당한 그 구조가 이제 들어간다')
@@ -74,7 +74,7 @@ after(async () => {
74
74
  })
75
75
 
76
76
  test('지운 트윈의 캐시는 남지 않는다 — TTL 은 없어진 주인을 지우지 않는다', async () => {
77
- await TwinEngine.ingestMaster(domainId, master())
77
+ await TwinEngine.ingestMaster(domainId, master(), 'reset')
78
78
  /* 캐시를 실제로 채운다 — 손으로 넣으면 "채우는 경로" 가 바뀌어도 검사가 통과한다. */
79
79
  await TwinEngine.structureRevOf(domainId, INSTANCE)
80
80
  const { measured, structureRev } = caches()
@@ -90,7 +90,7 @@ before(async () => {
90
90
  domain: { id: domainId },
91
91
  instanceId: INSTANCE,
92
92
  kind: 'wms',
93
- realityMode: 'sim-experiment', /* 선언은 이제 필수 — 비워 두면 읽는 자리마다 기본값을 고른다 */
93
+ restartPolicy: 'reset', /* 선언은 이제 필수 — 비워 두면 읽는 자리마다 기본값을 고른다 */
94
94
  purpose: 'operational',
95
95
  status: 'stopped',
96
96
  model: { locations: [{ id: 'dock', type: 'dock', capacity: 10 }], equipment: [{ id: 'fk-1', kind: 'forklift', homeLocation: 'dock' }] }
@@ -93,7 +93,7 @@ before(async () => {
93
93
  domain: { id: domainId },
94
94
  instanceId: INSTANCE,
95
95
  kind: 'wms',
96
- realityMode: 'sim-experiment', /* 선언은 이제 필수 — 비워 두면 읽는 자리마다 기본값을 고른다 */
96
+ restartPolicy: 'reset', /* 선언은 이제 필수 — 비워 두면 읽는 자리마다 기본값을 고른다 */
97
97
  purpose: 'operational',
98
98
  status: 'stopped',
99
99
  model: { locations, equipment: [{ id: 'fk-1', kind: 'forklift', homeLocation: 'loc-0' }] }
@@ -124,7 +124,7 @@ before(async () => {
124
124
  addDataSource('default', ds)
125
125
  const d = await ds.getRepository(Domain).save({ name: 'lineage-test', subdomain: 'lineage-test' } as any)
126
126
  domainId = d.id
127
- await TwinEngine.ingestMaster(domainId, master())
127
+ await TwinEngine.ingestMaster(domainId, master(), 'reset')
128
128
  })
129
129
 
130
130
  after(async () => {
@@ -134,7 +134,7 @@ after(async () => {
134
134
 
135
135
  test('재기동을 건너도 계보에 입력이 실린다 — 웜스타트가 확보분을 잃지 않는다', async () => {
136
136
  /* ① 한 번 굴려 진행 중 할당을 만든다. */
137
- await TwinEngine.startFromRegistry(domainId, INSTANCE, 'sim-world')
137
+ await TwinEngine.startFromRegistry(domainId, INSTANCE, 'resume')
138
138
  drive()
139
139
  tick(200)
140
140
  const workingBefore = orders().filter(o => (o.allocated ?? []).length)
@@ -142,7 +142,7 @@ test('재기동을 건너도 계보에 입력이 실린다 — 웜스타트가
142
142
 
143
143
  /* ② 멈추고 **저널을 재생해** 다시 세운다 — 호스트의 웜스타트 경로 그대로. */
144
144
  await TwinEngine.stop(domainId, INSTANCE)
145
- await TwinEngine.startFromRegistry(domainId, INSTANCE, 'sim-world')
145
+ await TwinEngine.startFromRegistry(domainId, INSTANCE, 'resume')
146
146
 
147
147
  const revived = orders().filter(o => (o.allocated ?? []).length)
148
148
  assert.ok(revived.length, '되살아난 오더가 확보분을 들고 있어야 한다 — 잃으면 계보의 입력이 빈다')
@@ -41,15 +41,15 @@ test('답하지 못하는 장부가 다른 계층의 사실을 죽이지 않는
41
41
 
42
42
  test('「없음」과 「해당 없음」을 섞지 않는다', () => {
43
43
  /* 도는 미러 — 이 둘이 우리가 보이려던 구별이다. */
44
- assert.equal(liveFeedStateOf({ realityMode: 'mirror', running: true, instanceId: 'ems-plant-1' }), 'attached')
45
- assert.equal(liveFeedStateOf({ realityMode: 'mirror', running: true, instanceId: 'ems-plant-2' }), 'detached')
44
+ assert.equal(liveFeedStateOf({ restartPolicy: 'resync', running: true, instanceId: 'ems-plant-1' }), 'attached')
45
+ assert.equal(liveFeedStateOf({ restartPolicy: 'resync', running: true, instanceId: 'ems-plant-2' }), 'detached')
46
46
 
47
47
  /* 시뮬 트윈에는 나를 피드가 **없어야 한다** — 결함이 아니라 그 트윈의 성질이다. */
48
- assert.equal(liveFeedStateOf({ realityMode: 'sim-world', running: true, instanceId: 'hatio-us' }), 'not-applicable')
49
- assert.equal(liveFeedStateOf({ realityMode: 'sim-experiment', running: true, instanceId: 'trailer-new' }), 'not-applicable')
48
+ assert.equal(liveFeedStateOf({ restartPolicy: 'resume', running: true, instanceId: 'hatio-us' }), 'not-applicable')
49
+ assert.equal(liveFeedStateOf({ restartPolicy: 'reset', running: true, instanceId: 'trailer-new' }), 'not-applicable')
50
50
 
51
51
  /* 사람이 멈춰 둔 트윈에게 피드를 묻는 것은 뜻이 없다. */
52
- assert.equal(liveFeedStateOf({ realityMode: 'mirror', running: false, instanceId: 'ems-plant-1' }), 'not-applicable')
52
+ assert.equal(liveFeedStateOf({ restartPolicy: 'resync', running: false, instanceId: 'ems-plant-1' }), 'not-applicable')
53
53
 
54
54
  /* 선언이 없으면 미러라고 짐작하지 않는다(짐작으로 경보를 울리지 않는다). */
55
55
  assert.equal(liveFeedStateOf({ running: true, instanceId: 'ems-plant-1' }), 'not-applicable')
@@ -63,5 +63,5 @@ test('목록이 그 사실을 함께 낸다 — 화면이 따로 물어야 하
63
63
  assert.ok(at >= 0, 'list() 가 없다 — 이름이 바뀌었으면 이 검사도 함께 고친다')
64
64
  const body = src.slice(at, src.indexOf('\n /** 운영 공간 목록', at))
65
65
  assert.match(body, /liveFeed: liveFeedStateOf\(/, '관리 목록 행에 피드 사실이 실려야 한다')
66
- assert.match(body, /realityMode: r\.realityMode/, '선언된 모드를 넘겨야 한다(짐작하지 않는다)')
66
+ assert.match(body, /restartPolicy: r\.restartPolicy/, '선언된 모드를 넘겨야 한다(짐작하지 않는다)')
67
67
  })
@@ -69,7 +69,15 @@ test('EMS 밖에도 공용 어휘가 있다 — 속도는 어느 트윈에서나
69
69
  /* 읽는 코드가 있는 것만 — 판정 기준 셋(주의)과 속도. 새 효과를 붙이면 이 목록도 함께 자란다. */
70
70
  assert.deepEqual(
71
71
  specs.map(s => s.id).sort(),
72
- ['attention.congestionRatio', 'attention.scrapMinSamples', 'attention.scrapRate', 'operation.duration', 'speed'],
72
+ [
73
+ 'attention.congestionRatio',
74
+ 'attention.scrapMinSamples',
75
+ 'attention.scrapRate',
76
+ 'operation.duration',
77
+ 'operation.setupDuration',
78
+ 'operation.yield',
79
+ 'speed'
80
+ ],
73
81
  '읽는 코드가 있는 것만'
74
82
  )
75
83
  /* 모르는 속성은 이제 「어휘가 없다」가 아니라 **「그 속성을 읽지 않는다」**로 거절된다(더 정확하다). */
@@ -552,3 +560,102 @@ test('두 겹이 함께 얹힌다 — 트윈 값이 현장 값을 이긴다(시
552
560
  assert.deepEqual((out.model as any).localDurations, { putaway: 'PT120S' }, '좁은 겹이 이긴다')
553
561
  assert.deepEqual(out.unreadable, [])
554
562
  })
563
+
564
+ /*
565
+ * ── 공정 모수: 수율·셋업 (2026-08-19) ───────────────────────────────────────
566
+ * 양품률 기본값은 커널 소스의 상수(0.8)였고 **그 상수가 불량 과다 주목 신호까지 만들었다.** 현장이 자기
567
+ * 수를 말할 자리를 준다. 사람은 퍼센트로 말하고(80=80%), 커널은 비율(0..1)을 읽는다 — 환산은 한 곳에서.
568
+ */
569
+ test('수율은 퍼센트로 받아 비율로 얹는다 — 80 이 0.8 이다(한 곳에서만 나눈다)', () => {
570
+ const { accepted, rejected } = validateLocalDeclarations('mes', [
571
+ { target: 'operation', id: 'weld', properties: [{ id: 'operation.yield', value: '95' }] }
572
+ ])
573
+ assert.deepEqual(rejected, [])
574
+ const { model, unreadable } = applyLocalDeclarations({ locations: [], equipment: [] }, accepted)
575
+ assert.deepEqual(unreadable, [])
576
+ assert.deepEqual((model as any).localParams, { weld: { yield: '0.95' } })
577
+ assert.equal((model as any).localDurations, undefined, '모수는 시간 표를 건드리지 않는다')
578
+ })
579
+
580
+ test('수율은 퍼센트 범위를 지킨다 — 0.8 을 넣으면 걸린다(SOC 에서 겪은 함정)', () => {
581
+ const low = validateLocalDeclarations('mes', [
582
+ { target: 'operation', id: 'weld', properties: [{ id: 'operation.yield', value: '0.8' }] }
583
+ ])
584
+ assert.equal(low.rejected[0]?.reason, 'out-of-range')
585
+ const high = validateLocalDeclarations('mes', [
586
+ { target: 'operation', id: 'weld', properties: [{ id: 'operation.yield', value: '150' }] }
587
+ ])
588
+ assert.equal(high.rejected[0]?.reason, 'out-of-range')
589
+ })
590
+
591
+ test('셋업은 단위와 함께 받아 ISO 8601 로 얹는다 — 시간 표가 아니라 모수 표에', () => {
592
+ const { accepted } = validateLocalDeclarations('mes', [
593
+ { target: 'operation', id: 'paint', properties: [{ id: 'operation.setupDuration', value: '7.5', uom: 'MIN' }] }
594
+ ])
595
+ const { model } = applyLocalDeclarations({ locations: [], equipment: [] }, accepted)
596
+ assert.deepEqual((model as any).localParams, { paint: { setupDuration: 'PT450S' } })
597
+ })
598
+
599
+ test('셋업도 단위 없이 받지 않는다 — 7 초와 7 분은 60배 다르다', () => {
600
+ const { rejected } = validateLocalDeclarations('mes', [
601
+ { target: 'operation', id: 'paint', properties: [{ id: 'operation.setupDuration', value: '7' }] }
602
+ ])
603
+ assert.equal(rejected[0]?.reason, 'uom-required')
604
+ })
605
+
606
+ test('한 공정에 시간·수율·셋업을 함께 얹는다 — 서로 덮지 않는다', () => {
607
+ const { accepted } = validateLocalDeclarations('mes', [
608
+ {
609
+ target: 'operation',
610
+ id: 'weld',
611
+ properties: [
612
+ { id: 'operation.duration', value: '3', uom: 'MIN' },
613
+ { id: 'operation.yield', value: '92' },
614
+ { id: 'operation.setupDuration', value: '30', uom: 'SEC' }
615
+ ]
616
+ }
617
+ ])
618
+ const { model } = applyLocalDeclarations({ locations: [], equipment: [] }, accepted)
619
+ assert.deepEqual((model as any).localDurations, { weld: 'PT180S' })
620
+ assert.deepEqual((model as any).localParams, { weld: { yield: '0.92', setupDuration: 'PT30S' } })
621
+ })
622
+
623
+ test('모수 철회는 모수 표에서 뺀다 — 시간 선언은 그대로 남는다', () => {
624
+ const declared = [
625
+ {
626
+ target: 'operation' as const,
627
+ id: 'weld',
628
+ properties: [
629
+ { id: 'operation.duration', value: '3', uom: 'MIN' },
630
+ { id: 'operation.yield', value: '92' }
631
+ ]
632
+ }
633
+ ]
634
+ const applied = applyLocalDeclarations({ locations: [], equipment: [] }, declared)
635
+ const { layer, revoked, notFound } = revokeLocalDeclarations(applied.layer, [
636
+ { target: 'operation', id: 'weld', propertyId: 'operation.yield' }
637
+ ])
638
+ assert.deepEqual(notFound, [])
639
+ assert.deepEqual(layer[0].properties.map(p => p.id), ['operation.duration'], '시간 선언은 겹에 남는다')
640
+ const back = restoreRevoked(applied.model, revoked)
641
+ assert.equal((back.model as any).localParams, undefined, '빈 껍데기를 남기지 않는다')
642
+ assert.deepEqual((back.model as any).localDurations, { weld: 'PT180S' }, '시간은 그대로')
643
+ })
644
+
645
+ test('없는 모수를 철회했다고 말하지 않는다 — 조용한 성공이 가장 비싸다', () => {
646
+ const back = restoreRevoked({ locations: [], equipment: [], localParams: { weld: { yield: '0.9' } } }, [
647
+ { target: 'operation', id: 'weld', propertyId: 'operation.setupDuration', replaced: null }
648
+ ])
649
+ assert.deepEqual(back.restored, [])
650
+ assert.deepEqual(back.missingTargets, [{ target: 'operation', id: 'weld' }])
651
+ assert.deepEqual((back.model as any).localParams, { weld: { yield: '0.9' } }, '남의 값을 건드리지 않는다')
652
+ })
653
+
654
+ test('두 겹이 함께 얹혀도 모수가 섞이지 않는다 — 좁은 겹이 이긴다', () => {
655
+ const out = applyDeclarationLayers(
656
+ { locations: [], equipment: [] },
657
+ [{ target: 'operation', id: 'weld', properties: [{ id: 'operation.yield', value: '80' }] }],
658
+ [{ target: 'operation', id: 'weld', properties: [{ id: 'operation.yield', value: '95' }] }]
659
+ )
660
+ assert.deepEqual((out.model as any).localParams, { weld: { yield: '0.95' } })
661
+ })
@@ -0,0 +1,90 @@
1
+ /*
2
+ * 실측 양품률 — **이력에서 배운 수율이 선언값·상수를 이긴다.**
3
+ *
4
+ * ── 왜 (2026-08-19) ─────────────────────────────────────────────────────────
5
+ * 소요시간에는 이력 층이 있었는데 수율에는 없었다. 양품률 기본값은 커널 소스의 상수(0.8)였고 **그 상수가
6
+ * 불량 과다 주목 신호까지 만들었다.** 이제 커널이 판정을 작업 완료에 적고(`outcome`), 폴드가 축으로 들고,
7
+ * KPI 가 종류별로 세고(`quality`), 이 모듈이 비율로 바꿔 커널 시임에 넣는다.
8
+ *
9
+ * 이 시험이 지키는 것:
10
+ * ① 분모는 **판정된 것만** — 이동·체류를 섞으면 양품률이 낮게 나온다.
11
+ * ② 표본이 모자라면 **배우지 않는다** — 비율은 드문 사건을 재는 값이라 작은 표본이 잡음을 만든다.
12
+ * ③ 0 도 사실이다 — 전량 불량이면 0 을 배운다(「값이 없다」와 다르다).
13
+ * ④ 모르는 종류에는 `undefined` — 그러면 커널이 선언값·상수로 내려간다.
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 { buildYieldEstimator, DEFAULT_YIELD_MIN_SAMPLES } from '../server/engine/measured-yield.ts'
22
+
23
+ const group = (key: string, good: number, scrap: number) => ({ key, quality: { good, scrap } })
24
+
25
+ test('판정된 것만 분모다 — 이동·체류가 섞이지 않는다', () => {
26
+ /* tasks 는 1000 건이어도 판정은 40 건뿐인 종류가 정상이다(중간 스테이션은 품질을 가리지 않는다). */
27
+ const r = buildYieldEstimator([{ key: 'assembly', tasks: 1000, quality: { good: 36, scrap: 4 } } as any])
28
+ assert.equal(r.learned.assembly, 0.9, '36/40 = 0.9')
29
+ assert.deepEqual(r.samples.assembly, { good: 36, scrap: 4 })
30
+ })
31
+
32
+ test('표본이 모자라면 배우지 않고 그 사실을 낸다 — 세 건 중 한 건 불량은 33% 가 아니다', () => {
33
+ const r = buildYieldEstimator([group('weld', 2, 1)])
34
+ assert.deepEqual(r.learned, {})
35
+ assert.deepEqual(r.skipped, [{ kind: 'weld', judged: 3 }])
36
+ assert.equal(r.estimator, undefined, '배운 것이 없으면 시임을 만들지 않는다')
37
+ assert.equal(r.minSamples, DEFAULT_YIELD_MIN_SAMPLES)
38
+ })
39
+
40
+ test('하한은 소요보다 높다 — 비율은 드문 사건을 재는 값이다', () => {
41
+ /* 불량률 5% 인 공정은 20건에서 불량이 한 건이다 — 그 아래로 내려가면 이력이 잡음이 된다. */
42
+ assert.ok(DEFAULT_YIELD_MIN_SAMPLES >= 20, `하한이 ${DEFAULT_YIELD_MIN_SAMPLES} 이면 비율에 너무 낮다`)
43
+ })
44
+
45
+ test('전량 불량은 0 을 배운다 — 「값이 없다」와 구별한다', () => {
46
+ const r = buildYieldEstimator([group('paint', 0, 25)])
47
+ assert.equal(r.learned.paint, 0)
48
+ assert.equal(r.estimator!.estimate({ kind: 'paint' }), 0, '0 을 답한다(undefined 로 뭉개지 않는다)')
49
+ })
50
+
51
+ test('판정이 아예 없는 종류는 건너뛴 것이 아니다 — 품질을 가리지 않는 공정이 정상이다', () => {
52
+ const r = buildYieldEstimator([{ key: 'putaway', tasks: 500 } as any])
53
+ assert.deepEqual(r.learned, {})
54
+ assert.deepEqual(r.skipped, [], '표본 부족으로 세지 않는다')
55
+ })
56
+
57
+ test('모르는 종류에는 undefined — 커널이 선언값·상수로 내려갈 수 있게', () => {
58
+ const r = buildYieldEstimator([group('weld', 30, 2)])
59
+ assert.equal(r.estimator!.estimate({ kind: 'weld' }), 30 / 32)
60
+ assert.equal(r.estimator!.estimate({ kind: 'paint' }), undefined)
61
+ })
62
+
63
+ test("'unknown' 축은 쓰지 않는다 — 종류를 모르는 기록을 특정 종류의 근거로 삼지 않는다", () => {
64
+ const r = buildYieldEstimator([group('unknown', 100, 5)])
65
+ assert.deepEqual(r.learned, {})
66
+ })
67
+
68
+ test('빈 입력에도 답을 만든다 — 부르는 쪽이 조건문을 두 벌 갖지 않게', () => {
69
+ for (const input of [undefined, []]) {
70
+ const r = buildYieldEstimator(input as any)
71
+ assert.deepEqual(r.learned, {})
72
+ assert.equal(r.estimator, undefined)
73
+ }
74
+ })
75
+
76
+ /*
77
+ * ── 사슬이 이어져 있나 (소스로 지킨다) ──────────────────────────────────────
78
+ * 이 층은 넷이 이어져야 뜻이 있다: 커널이 판정을 적고 → 폴드가 축으로 들고 → KPI 가 세고 → 여기서 비율.
79
+ * 한 칸만 빠져도 화면은 「이력에서 배운 값」이라고 말하면서 상수로 돈다. 실 앱에서 관측하려면 라인이
80
+ * 한 바퀴 돌아야 하므로(판정은 완료에서 일어난다), 경로는 여기서 지킨다.
81
+ */
82
+ test('사슬 넷이 이어져 있다 — 판정→축→집계→비율', () => {
83
+ const hostSrc = (f: string) => readFileSync(fileURLToPath(new URL(`../server/${f}`, import.meta.url)), 'utf-8')
84
+ assert.match(hostSrc('engine/kpi-fold.ts'), /facets\.outcome/, 'KPI 가 판정 축을 읽는다')
85
+ assert.match(hostSrc('engine/kpi-fold.ts'), /quality\?: \{ good: number; scrap: number \}/, '종류별로 센다')
86
+ const engine = hostSrc('engine/twin-engine.ts')
87
+ assert.match(engine, /buildYieldEstimator\(kpi\?\.groups\?\.items/, '같은 폴드에서 배운다(두 번 접지 않는다)')
88
+ assert.match(engine, /kernel\.yieldEstimator = yields\.estimator/, '커널 시임에 넣는다')
89
+ assert.match(engine, /yieldOf missing/, '옛 커널이면 그 사실을 말한다')
90
+ })
@@ -93,8 +93,35 @@ test('이력 목록은 선언 대조에도 쓰인다 — 오타를 그때부터
93
93
  */
94
94
  test('시뮬 기동이 모드를 적는다 — 「돌고 있나」를 묻는 쪽이 답을 받아야 한다', () => {
95
95
  const src = readFileSync(fileURLToPath(new URL('../server/engine/twin-engine.ts', import.meta.url)), 'utf-8')
96
- assert.match(src, /id, domainId, mode: 'sim', runtime/, '시뮬 런타임도 모드를 든다')
96
+ assert.match(src, /mode: 'sim',\s*\n\s*runtime/, '시뮬 런타임도 모드를 든다')
97
97
  /* 답이 갈리는 자리 — 도는 트윈이면 「재기동 때 받는다」로 말해야 한다. */
98
98
  const mut = readFileSync(fileURLToPath(new URL('../server/service/twin-model/twin-model-mutation.ts', import.meta.url)), 'utf-8')
99
99
  assert.match(mut, /running \? 'on-restart' : 'stored'/)
100
100
  })
101
+
102
+ /*
103
+ * ── 공정 모수도 목록에 실린다 (2026-08-19) ──────────────────────────────────
104
+ * 수율·셋업은 시간과 다른 표에 담긴다(`localParams`). 목록이 그것을 함께 들지 않으면 선언해 둔 값이
105
+ * 화면에서 사라지고, 그러면 철회할 자리도 없어진다. 원천이 말한 모수도 함께 들어 견줄 수 있게 한다.
106
+ */
107
+ test('선언한 모수와 원천 모수를 함께 든다 — 무엇을 고쳤나가 보인다', () => {
108
+ const model = {
109
+ operations: [{ key: 'weld', label: 'Welding', intent: 'process', parameters: [{ id: 'yield', value: '0.7' }] }],
110
+ localParams: { weld: { yield: '0.92', setupDuration: 'PT30S' } }
111
+ }
112
+ const rows = operationBasis(model, kernelReporting([{ kind: 'weld', duration: 'declared' }]))
113
+ assert.deepEqual(rows, [
114
+ {
115
+ kind: 'weld',
116
+ label: 'Welding',
117
+ referenceParams: { yield: '0.7' },
118
+ basis: 'declared',
119
+ localParams: { yield: '0.92', setupDuration: 'PT30S' }
120
+ }
121
+ ])
122
+ })
123
+
124
+ test('모수만 선언한 공정도 목록에 남는다 — 커널이 아직 돌리지 않았어도', () => {
125
+ const rows = operationBasis({ localParams: { inspect: { yield: '0.99' } } }, undefined)
126
+ assert.deepEqual(rows, [{ kind: 'inspect', localParams: { yield: '0.99' } }])
127
+ })