agentworkshop 0.7.46 → 0.7.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.output/.AgentWorkShop/aml-python/__pycache__/amlkit.cpython-312.pyc +0 -0
- package/.output/nitro.json +1 -1
- package/.output/public/_nuxt/builds/latest.json +1 -1
- package/.output/public/_nuxt/builds/meta/1b18aaf2-ccbc-45e0-b9c3-107655b226f9.json +1 -0
- package/.output/server/chunks/_/nitro.mjs +833 -748
- package/.output/server/package.json +1 -1
- package/README-zh.md +2 -2
- package/README.md +2 -2
- package/config.yml +1 -1
- package/docs/aml.md +1 -0
- package/docs/cli.en.md +2 -2
- package/docs/cli.md +2 -2
- package/docs/full-test-plan.md +1 -1
- package/docs/plugins.en.md +1 -1
- package/docs/plugins.md +1 -1
- package/docs/sdk.en.md +1 -1
- package/docs/sdk.md +1 -1
- package/package.json +1 -1
- package/scripts/_aw-packaged-start.cjs +22 -0
- package/scripts/_aw0746-stage12.mjs +94 -0
- package/scripts/_aw0746-stage34.mjs +134 -0
- package/scripts/_aw0746-stage5.mjs +106 -0
- package/scripts/_aw0746-stage6-aml.mjs +177 -0
- package/scripts/_aw0746-stage6b-train-sweep.mjs +142 -0
- package/scripts/_aw0746-stage7-twin.mjs +259 -0
- package/scripts/_aw0746-train-strong.py +142 -0
- package/scripts/_plc-sim-detached.cjs +22 -0
- package/scripts/_probe-density.cjs +15 -0
- package/scripts/_probe-gate-model.mjs +34 -0
- package/scripts/_probe-model-eligibility.cjs +13 -0
- package/server/services/workshop/agents/industrial/twin-tools.ts +85 -10
- package/server/services/workshop/aml/job-orchestrator/start-job.ts +140 -124
- package/server/services/workshop/aml/python/__pycache__/amlkit.cpython-312.pyc +0 -0
- package/server/services/workshop/aml/twin/repo.ts +9 -4
- package/.output/public/_nuxt/builds/meta/7827fe9f-b4c2-461f-b1fb-c0c7da7d66b8.json +0 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0.7.46 打包系统验收 · 阶段 6:AML 真实训练 → 门禁 → 晋升 → 投入使用
|
|
3
|
+
* 1) 保证注塑产线有 ≥3 个已完结批次(采样成行)
|
|
4
|
+
* 2) 构建数据集快照(隔离三元组 + 控制/目标/特征节点)
|
|
5
|
+
* 3) 提交**真实训练作业**(平台参考实现 train-example.py 作为内联 code;uv venv + torch)
|
|
6
|
+
* 4) 评估门禁 → 模型注册 → 晋升 production(ONNX 深检)→ predict 影子参考(投入使用)
|
|
7
|
+
* 用法:AW_BASE=http://127.0.0.1:3001 node scripts/_aw0746-stage6-aml.mjs
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
|
|
11
|
+
const BASE = process.env.AW_BASE ?? 'http://127.0.0.1:3001'
|
|
12
|
+
const ADMIN = { email: 'admin@awshop.local', password: 'Awshop@123!' }
|
|
13
|
+
const REPO = process.env.AW_REPO ?? process.cwd()
|
|
14
|
+
|
|
15
|
+
let pass = 0
|
|
16
|
+
const fails = []
|
|
17
|
+
const check = (name, ok, detail = '') => {
|
|
18
|
+
console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
|
|
19
|
+
if (ok) pass += 1
|
|
20
|
+
else fails.push(name)
|
|
21
|
+
}
|
|
22
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
23
|
+
const j = async (method, path, body, token) => {
|
|
24
|
+
const r = await fetch(BASE + path, {
|
|
25
|
+
method,
|
|
26
|
+
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
27
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
28
|
+
signal: AbortSignal.timeout(300_000),
|
|
29
|
+
})
|
|
30
|
+
return { status: r.status, ...(await r.json().catch(() => ({}))) }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const token = (await j('POST', '/api/users/login', { email: ADMIN.email, password: ADMIN.password })).data?.token
|
|
34
|
+
console.log(`\n═══ 阶段 6:AML 真实训练与投入使用 @ ${BASE} ═══`)
|
|
35
|
+
|
|
36
|
+
// ── 1. 产线与三元组 ──
|
|
37
|
+
const dcw = (await j('GET', '/api/workshop/dcw', undefined, token)).data
|
|
38
|
+
const line = (dcw.lines ?? []).find(l => /injection-aw0746/i.test(String(l.name)))
|
|
39
|
+
const product = (dcw.products ?? []).find(p => p.lineId === line.id)
|
|
40
|
+
const recipe = (dcw.recipes ?? []).find(r => r.lineId === line.id)
|
|
41
|
+
check('隔离三元组可解析(line/product/recipe)', Boolean(line && product && recipe), `${line.id} / ${product?.id} / ${recipe?.id}`)
|
|
42
|
+
|
|
43
|
+
const daq = (await j('GET', '/api/workshop/daq', undefined, token)).data
|
|
44
|
+
const nodes = (daq.nodes ?? []).filter(n => n.lineId === line.id)
|
|
45
|
+
const find = (arr, re) => arr.find(n => re.test(n.name))
|
|
46
|
+
// 数据集 nodes **只能是数采(DAQ)节点**:控制量取"可控量的数采观测通道"
|
|
47
|
+
// (模具温度 / 注射压力 / 节拍),目标 = 制品克重,特征 = 熔温·飞边·缩痕·熔压。
|
|
48
|
+
// 数控(DCW)节点是写通道,不属于 daq_samples,提交会被 AML_NODE_MISSING 拒绝。
|
|
49
|
+
const ctrl = [find(nodes, /mold-temp-pv/), find(nodes, /inj-pressure-pv/), find(nodes, /cycle-time/)].filter(Boolean)
|
|
50
|
+
const specNodes = [
|
|
51
|
+
...ctrl.map(n => ({ nodeId: n.id, role: 'control' })),
|
|
52
|
+
{ nodeId: find(nodes, /part-weight/).id, role: 'target' },
|
|
53
|
+
...[find(nodes, /melt-temp-pv/), find(nodes, /flash-rate/), find(nodes, /sink-mark/), find(nodes, /melt-pressure-pv/)].filter(Boolean).map(n => ({ nodeId: n.id, role: 'feature' })),
|
|
54
|
+
]
|
|
55
|
+
check('数据集节点编组(control/target/feature 均为 DAQ 通道)', specNodes.filter(n => n.role === 'control').length >= 2 && specNodes.some(n => n.role === 'target'), `nodes=${specNodes.length} ctrl=${ctrl.map(n => n.name).join('/')}`)
|
|
56
|
+
|
|
57
|
+
// ── 2. 保证 ≥4 个已完结批次(每轮 3 分钟采样) ──
|
|
58
|
+
// 关跑必须**无条件**先停:`/api/workshop/dcw` 的产线投影不含 activeRunId,
|
|
59
|
+
// 靠它判断"是否在跑"会漏停 → 后续 start 409(实测踩过),批次数量永远上不去。
|
|
60
|
+
// 复跑优化:`AW_DATASET_ID=<已建数据集>` 时跳过采样直接进入训练(调参迭代不再等 12 分钟)。
|
|
61
|
+
const reuseDatasetId = process.env.AW_DATASET_ID ?? ''
|
|
62
|
+
const BATCHES = Number(process.env.AW_BATCHES ?? 4)
|
|
63
|
+
if (!reuseDatasetId) {
|
|
64
|
+
console.log(` · 造批次:停线 → 开跑 → 采样 3 分钟 → 停线,重复 ${BATCHES} 轮`)
|
|
65
|
+
for (let i = 1; i <= BATCHES; i += 1) {
|
|
66
|
+
await j('POST', `/api/workshop/dcw/lines/${line.id}/stop`, {}, token)
|
|
67
|
+
await sleep(2500)
|
|
68
|
+
const st = await j('POST', `/api/workshop/dcw/lines/${line.id}/start`, { recipeId: recipe.id }, token)
|
|
69
|
+
console.log(` 批次 ${i}: start status=${st.status} ${st.code ?? ''} ${st.message ?? 'ok'}`)
|
|
70
|
+
await sleep(180_000)
|
|
71
|
+
}
|
|
72
|
+
await j('POST', `/api/workshop/dcw/lines/${line.id}/stop`, {}, token)
|
|
73
|
+
await sleep(5000)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── 3. 数据集快照 ──
|
|
77
|
+
// beatMs 必须贴近真实采样间隔(实测产线运行中原样约 1.0 s/点):5 s 的拍格会把
|
|
78
|
+
// 3 分钟批次压成 ~27 行,行数门禁(≥500)必然不达标;1 s 拍格 → 单批次约 170 行。
|
|
79
|
+
const ds = reuseDatasetId
|
|
80
|
+
? { code: 0, data: { id: reuseDatasetId } }
|
|
81
|
+
: await j('POST', '/api/workshop/aml/datasets', {
|
|
82
|
+
lineId: line.id,
|
|
83
|
+
productId: product.id,
|
|
84
|
+
recipeId: recipe.id,
|
|
85
|
+
nodes: specNodes,
|
|
86
|
+
beatMs: Number(process.env.AW_BEAT_MS ?? 1000),
|
|
87
|
+
window: { historySteps: 6, horizonSteps: 4 },
|
|
88
|
+
cleaning: { hampelK: 3, maxInterpMs: 15_000, maxDropRatio: 0.5 },
|
|
89
|
+
split: { valRatio: 0.2, testRatio: 0.2, seed: 42 },
|
|
90
|
+
purpose: 'mpc_surrogate',
|
|
91
|
+
note: '0.7.46 打包系统验收:注塑克重窗口闭环',
|
|
92
|
+
}, token)
|
|
93
|
+
const datasetId = ds.data?.id ?? ds.data?.dataset?.id
|
|
94
|
+
const dsRow = ds.data?.dataset ?? ds.data
|
|
95
|
+
if (reuseDatasetId) {
|
|
96
|
+
const d = (await j('GET', `/api/workshop/aml/datasets/${reuseDatasetId}`, undefined, token)).data
|
|
97
|
+
const row = d?.dataset ?? d
|
|
98
|
+
Object.assign(dsRow, { rowCount: row?.rowCount, runIds: row?.runIds ?? row?.runs ?? [] })
|
|
99
|
+
console.log(` · 复用数据集 ${reuseDatasetId} rows=${dsRow.rowCount} runs=${dsRow.runIds?.length}`)
|
|
100
|
+
}
|
|
101
|
+
check('数据集快照构建(真实打标样本)', Boolean(datasetId), `dataset=${datasetId} rows=${dsRow?.rowCount ?? '?'} runs=${dsRow?.runIds?.length ?? '?'} ${ds.code !== 0 ? `${ds.code}: ${ds.message}` : ''}`)
|
|
102
|
+
check('行数与批次满足门禁下限(≥500 行 / ≥3 批次)', Number(dsRow?.rowCount ?? 0) >= 500 && (dsRow?.runIds?.length ?? 0) >= 3, `rows=${dsRow?.rowCount} runs=${dsRow?.runIds?.length}`)
|
|
103
|
+
|
|
104
|
+
// ── 4. 真实训练作业 ──
|
|
105
|
+
const trainCode = readFileSync(`${REPO}/server/services/workshop/aml/python/train-example.py`, 'utf8')
|
|
106
|
+
const trainParams = process.env.AW_TRAIN_PARAMS
|
|
107
|
+
? JSON.parse(process.env.AW_TRAIN_PARAMS)
|
|
108
|
+
: { lr: 0.01, epochs: 30, hidden: 64, batch: 64 }
|
|
109
|
+
console.log(` · 训练超参:${JSON.stringify(trainParams)}`)
|
|
110
|
+
const job = await j('POST', '/api/workshop/aml/jobs', {
|
|
111
|
+
datasetId,
|
|
112
|
+
purpose: 'mpc_surrogate',
|
|
113
|
+
jobKind: 'supervised',
|
|
114
|
+
code: trainCode,
|
|
115
|
+
params: trainParams,
|
|
116
|
+
seed: 42,
|
|
117
|
+
changeNote: '0.7.46 acceptance: baseline one-step model',
|
|
118
|
+
}, token)
|
|
119
|
+
const jobId = job.data?.id ?? job.data?.job?.id
|
|
120
|
+
check('训练作业提交(内联平台参考实现)', Boolean(jobId), `job=${jobId} ${job.message ?? ''}`)
|
|
121
|
+
|
|
122
|
+
console.log(' … 真实训练中(uv venv + torch,最长 15 分钟)…')
|
|
123
|
+
let jobRow = null
|
|
124
|
+
const deadline = Date.now() + 15 * 60_000
|
|
125
|
+
while (Date.now() < deadline) {
|
|
126
|
+
await sleep(10_000)
|
|
127
|
+
const row = (await j('GET', `/api/workshop/aml/jobs/${jobId}`, undefined, token)).data
|
|
128
|
+
jobRow = row?.job ?? row
|
|
129
|
+
process.stdout.write(`\r status=${jobRow?.status ?? '?'} ${String(jobRow?.error ?? '').slice(0, 40)} `)
|
|
130
|
+
if (jobRow && ['done', 'failed', 'canceled'].includes(jobRow.status)) break
|
|
131
|
+
}
|
|
132
|
+
console.log('')
|
|
133
|
+
check('训练作业跑完(status=done)', jobRow?.status === 'done', `status=${jobRow?.status} err=${jobRow?.error ?? ''}`)
|
|
134
|
+
const logs = await j('GET', `/api/workshop/aml/jobs/${jobId}/logs`, undefined, token)
|
|
135
|
+
const logText = JSON.stringify(logs.data ?? '').slice(0, 4000)
|
|
136
|
+
check('训练日志可见 torch/ONNX 产出痕迹', /onnx|epoch|loss|torch/i.test(logText), logText.replace(/\\n/g, ' ').slice(0, 200))
|
|
137
|
+
|
|
138
|
+
// ── 5. 门禁 + 模型注册 ──
|
|
139
|
+
const exps = await j('GET', `/api/workshop/aml/experiments?datasetId=${datasetId}`, undefined, token)
|
|
140
|
+
const expList = Array.isArray(exps.data) ? exps.data : (exps.data?.experiments ?? exps.data?.items ?? [])
|
|
141
|
+
const exp = expList[0]
|
|
142
|
+
const gates = exp?.gates ?? exp?.gateReport ?? {}
|
|
143
|
+
console.log(` 门禁:${JSON.stringify(gates).slice(0, 400)}`)
|
|
144
|
+
check('门禁报告产出(逐项可读)', Object.keys(gates).length > 0 || exp?.gatesPassed !== undefined, Object.keys(gates).slice(0, 8).join(','))
|
|
145
|
+
|
|
146
|
+
const models = await j('GET', '/api/workshop/aml/models', undefined, token)
|
|
147
|
+
const modelList = Array.isArray(models.data) ? models.data : (models.data?.models ?? models.data?.items ?? [])
|
|
148
|
+
const model = modelList.find(m => m.experimentId === exp?.id) ?? modelList[0]
|
|
149
|
+
check('模型已注册(registry 出现候选)', Boolean(model?.id), `model=${model?.id} stage=${model?.stage}`)
|
|
150
|
+
|
|
151
|
+
// ── 6. 晋升 production(投入使用)──
|
|
152
|
+
let promoted = null
|
|
153
|
+
if (model?.id) {
|
|
154
|
+
const p = await j('POST', `/api/workshop/aml/models/${model.id}/promote`, { toStage: 'production' }, token)
|
|
155
|
+
promoted = p
|
|
156
|
+
check('晋升 production(ONNX 深检 + 权限门)', p.code === 0 || p.status === 200, `status=${p.status} ${p.message ?? ''}`)
|
|
157
|
+
const after = (await j('GET', '/api/workshop/aml/models', undefined, token)).data
|
|
158
|
+
const listAfter = Array.isArray(after) ? after : (after?.models ?? after?.items ?? [])
|
|
159
|
+
const m2 = listAfter.find(x => x.id === model.id)
|
|
160
|
+
check('注册表 stage=production(模型已投入生产)', m2?.stage === 'production', `stage=${m2?.stage}`)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── 7. 预测引用(真正用起来)──
|
|
164
|
+
if (model?.id) {
|
|
165
|
+
const hist = Array.from({ length: 6 }, () => specNodes.map(() => 0.5))
|
|
166
|
+
const pred = await j('POST', `/api/workshop/aml/models/${model.id}/predict`, {
|
|
167
|
+
history: hist,
|
|
168
|
+
controls: [[0.5, 0.5, 0.5]],
|
|
169
|
+
steps: 4,
|
|
170
|
+
}, token)
|
|
171
|
+
const arr = pred.data?.prediction ?? pred.prediction
|
|
172
|
+
const flat = Array.isArray(arr) ? arr.flat(3).filter(v => typeof v === 'number') : []
|
|
173
|
+
check('影子预测可用(what-if 投入使用)', pred.status === 200 && flat.length > 0, `points=${flat.length} first=${flat.slice(0, 3).map(v => v.toFixed?.(4) ?? v).join(',')}`)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
console.log(`\n★ 阶段 6:${pass} 通过 / ${fails.length} 失败${fails.length ? ` (${fails.join('; ')})` : ''}`)
|
|
177
|
+
console.log(JSON.stringify({ lineId: line.id, datasetId, jobId, modelId: model?.id, jobStatus: jobRow?.status, gatesPassed: exp?.gatesPassed, promoteStatus: promoted?.status }, null, 1))
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0.7.46 打包系统验收 · 阶段 6b:训练调参冲刺(复用已采样的数据集,不再等 12 分钟)
|
|
3
|
+
* 逐档提交真实训练作业,直到单步 NRMSE 门禁(G1 ≤ 0.10)通过;通过则晋升 production + 影子预测。
|
|
4
|
+
* 每档都是"平台内联 code"路径:第 1/2 档用平台参考实现换超参,第 3 档用带 z-score 标准化的加强实现。
|
|
5
|
+
* 用法:AW_BASE=http://127.0.0.1:3001 [AW_DATASET_ID=ds-xxx] node scripts/_aw0746-stage6b-train-sweep.mjs
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
|
|
9
|
+
const BASE = process.env.AW_BASE ?? 'http://127.0.0.1:3001'
|
|
10
|
+
const ADMIN = { email: 'admin@awshop.local', password: 'Awshop@123!' }
|
|
11
|
+
const REPO = process.env.AW_REPO ?? process.cwd()
|
|
12
|
+
|
|
13
|
+
const j = async (method, path, body, token) => {
|
|
14
|
+
const r = await fetch(BASE + path, {
|
|
15
|
+
method,
|
|
16
|
+
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
17
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
18
|
+
signal: AbortSignal.timeout(300_000),
|
|
19
|
+
})
|
|
20
|
+
return { status: r.status, ...(await r.json().catch(() => ({}))) }
|
|
21
|
+
}
|
|
22
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
23
|
+
const token = (await j('POST', '/api/users/login', { email: ADMIN.email, password: ADMIN.password })).data?.token
|
|
24
|
+
console.log(`\n═══ 阶段 6b:训练调参冲刺 @ ${BASE} ═══`)
|
|
25
|
+
|
|
26
|
+
const dcw = (await j('GET', '/api/workshop/dcw', undefined, token)).data
|
|
27
|
+
const line = (dcw.lines ?? []).find(l => /injection-aw0746/i.test(String(l.name)))
|
|
28
|
+
const product = (dcw.products ?? []).find(p => p.lineId === line.id)
|
|
29
|
+
const recipe = (dcw.recipes ?? []).find(r => r.lineId === line.id)
|
|
30
|
+
const daq = (await j('GET', '/api/workshop/daq', undefined, token)).data
|
|
31
|
+
const find = (arr, re) => arr.find(n => re.test(n.name))
|
|
32
|
+
const nodes = (daq.nodes ?? []).filter(n => n.lineId === line.id)
|
|
33
|
+
|
|
34
|
+
async function buildDataset(window) {
|
|
35
|
+
const ds = await j('POST', '/api/workshop/aml/datasets', {
|
|
36
|
+
lineId: line.id,
|
|
37
|
+
productId: product.id,
|
|
38
|
+
recipeId: recipe.id,
|
|
39
|
+
nodes: [
|
|
40
|
+
{ nodeId: find(nodes, /mold-temp-pv/).id, role: 'control' },
|
|
41
|
+
{ nodeId: find(nodes, /inj-pressure-pv/).id, role: 'control' },
|
|
42
|
+
{ nodeId: find(nodes, /cycle-time/).id, role: 'control' },
|
|
43
|
+
{ nodeId: find(nodes, /part-weight/).id, role: 'target' },
|
|
44
|
+
...[find(nodes, /melt-temp-pv/), find(nodes, /flash-rate/), find(nodes, /sink-mark/), find(nodes, /melt-pressure-pv/)].filter(Boolean).map(n => ({ nodeId: n.id, role: 'feature' })),
|
|
45
|
+
],
|
|
46
|
+
beatMs: 1000,
|
|
47
|
+
window,
|
|
48
|
+
cleaning: { hampelK: 3, maxInterpMs: 15_000, maxDropRatio: 0.5 },
|
|
49
|
+
split: { valRatio: 0.2, testRatio: 0.2, seed: 42 },
|
|
50
|
+
purpose: 'mpc_surrogate',
|
|
51
|
+
note: `0.7.46 验收:调参冲刺 window=${window.historySteps}/${window.horizonSteps}`,
|
|
52
|
+
}, token)
|
|
53
|
+
return ds.data?.id
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function rowOf(datasetId) {
|
|
57
|
+
const d = (await j('GET', `/api/workshop/aml/datasets/${datasetId}`, undefined, token)).data
|
|
58
|
+
const row = d?.dataset ?? d
|
|
59
|
+
return { id: datasetId, rows: Number(row?.rowCount ?? 0), runs: JSON.parse(row?.runIdsJson ?? '[]').length }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function trainAndWait(datasetId, code, params, note, minutes = 16) {
|
|
63
|
+
const job = await j('POST', '/api/workshop/aml/jobs', { datasetId, purpose: 'mpc_surrogate', jobKind: 'supervised', code, params, seed: 42, changeNote: note }, token)
|
|
64
|
+
const jobId = job.data?.id ?? job.data?.job?.id
|
|
65
|
+
if (!jobId) return { status: 'submit-failed', message: job.message, jobId: null }
|
|
66
|
+
const deadline = Date.now() + minutes * 60_000
|
|
67
|
+
let row = null
|
|
68
|
+
while (Date.now() < deadline) {
|
|
69
|
+
await sleep(10_000)
|
|
70
|
+
row = (await j('GET', `/api/workshop/aml/jobs/${jobId}`, undefined, token)).data
|
|
71
|
+
row = row?.job ?? row
|
|
72
|
+
process.stdout.write(`\r ${jobId} status=${row?.status ?? '?'} `)
|
|
73
|
+
if (row && ['done', 'failed', 'canceled'].includes(row.status)) break
|
|
74
|
+
}
|
|
75
|
+
console.log('')
|
|
76
|
+
return { jobId, status: row?.status, error: row?.error ?? '' }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function gatesOf(datasetId, jobId) {
|
|
80
|
+
const exps = ((await j('GET', `/api/workshop/aml/experiments?datasetId=${datasetId}`, undefined, token)).data?.experiments ?? [])
|
|
81
|
+
// 实验列表按创建升序返回,必须按 jobId 精确匹配本次作业(否则读到旧档的指标)
|
|
82
|
+
const exp = exps.find(e => e.jobId === jobId) ?? exps.at(-1)
|
|
83
|
+
const metrics = JSON.parse(exp?.metricsJson || '{}')
|
|
84
|
+
return { exp, metrics, gates: exp?.gates ?? exp?.gateReport ?? null }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── 档位定义 ──
|
|
88
|
+
const refCode = readFileSync(`${REPO}/server/services/workshop/aml/python/train-example.py`, 'utf8')
|
|
89
|
+
const strongCode = readFileSync(`${REPO}/scripts/_aw0746-train-strong.py`, 'utf8')
|
|
90
|
+
let datasetId = process.env.AW_DATASET_ID ?? ''
|
|
91
|
+
|
|
92
|
+
const attempts = [
|
|
93
|
+
{ label: '参考实现 + 更大容量/更久训练(lr3e-3,e200,h128)', code: refCode, params: { lr: 0.003, epochs: 200, hidden: 128, batch: 64 }, window: null },
|
|
94
|
+
{ label: '参考实现 + 全连接加宽(lr1e-3,e300,h256,b128)', code: refCode, params: { lr: 0.001, epochs: 300, hidden: 256, batch: 128 }, window: null },
|
|
95
|
+
{ label: '加强实现(z-score + 2 层 + best-val 回滚,lr3e-3,e400,h128)', code: strongCode, params: { lr: 0.003, epochs: 400, hidden: 128, batch: 64 }, window: { historySteps: 10, horizonSteps: 4 } },
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
let winner = null
|
|
99
|
+
for (const [i, a] of attempts.entries()) {
|
|
100
|
+
if (a.window) {
|
|
101
|
+
datasetId = await buildDataset(a.window)
|
|
102
|
+
console.log(` · 重建数据集(window=${a.window.historySteps}/${a.window.horizonSteps}) → ${datasetId}`)
|
|
103
|
+
}
|
|
104
|
+
const info = await rowOf(datasetId)
|
|
105
|
+
if (info.rows < 500) { console.log(` ✗ 数据集行数不足:${JSON.stringify(info)}`); continue }
|
|
106
|
+
console.log(`\n 档 ${i + 1}:${a.label}\n dataset=${datasetId} rows=${info.rows} runs=${info.runs} params=${JSON.stringify(a.params)}`)
|
|
107
|
+
const run = await trainAndWait(datasetId, a.code, a.params, `0.7.46 sweep #${i + 1}: ${a.label}`)
|
|
108
|
+
const { exp, metrics, gates } = await gatesOf(datasetId, run.jobId)
|
|
109
|
+
const g1 = Number(metrics.oneStepTest?.nrmse ?? NaN)
|
|
110
|
+
const g2 = Number(metrics.rolloutTest?.nrmse ?? NaN)
|
|
111
|
+
const g3 = Number(metrics.oneStepVal?.nrmse ?? NaN)
|
|
112
|
+
console.log(` job=${run.jobId} ${run.status}${run.error ? ` err=${run.error}` : ''}`)
|
|
113
|
+
console.log(` oneStep(test)=${g1.toFixed(4)} oneStep(val)=${g3.toFixed(4)} rollout=${g2.toFixed(4)} gatesPassed=${gates?.passed}`)
|
|
114
|
+
if (run.status === 'done' && Number.isFinite(g1) && g1 <= 0.10) {
|
|
115
|
+
winner = { attempt: i + 1, datasetId, jobId: run.jobId, exp, metrics }
|
|
116
|
+
break
|
|
117
|
+
}
|
|
118
|
+
console.log(` ✗ 未过 G1(实测 ${Number.isFinite(g1) ? g1.toFixed(4) : 'n/a'} / 上限 0.10),进入下一档`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!winner) {
|
|
122
|
+
console.log('\n★ 阶段 6b:全部档位未过 G1 门禁')
|
|
123
|
+
process.exit(2)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log(`\n ✓ 档 ${winner.attempt} 通过门禁:G1=${Number(winner.metrics.oneStepTest.nrmse).toFixed(4)}`)
|
|
127
|
+
const models = ((await j('GET', '/api/workshop/aml/models', undefined, token)).data?.models ?? [])
|
|
128
|
+
const model = models.find(m => m.experimentId === winner.exp?.id) ?? models[0]
|
|
129
|
+
console.log(` · 模型注册:${model?.id} stage=${model?.stage}`)
|
|
130
|
+
const p = await j('POST', `/api/workshop/aml/models/${model.id}/promote`, { toStage: 'production' }, token)
|
|
131
|
+
console.log(` · 晋升 production:status=${p.status} ${p.message ?? ''}`)
|
|
132
|
+
const after = ((await j('GET', '/api/workshop/aml/models', undefined, token)).data?.models ?? []).find(x => x.id === model.id)
|
|
133
|
+
console.log(` · 复核 stage=${after?.stage}`)
|
|
134
|
+
|
|
135
|
+
const hist = Array.from({ length: Number(winner.metrics.ioSpec?.historySteps ?? 10) }, () => winner.metrics.ioSpec.allNodes.map(n => Number(winner.metrics.ioSpec.norm.x.mean[winner.metrics.ioSpec.allNodes.indexOf(n)] ?? 0)))
|
|
136
|
+
const pred = await j('POST', `/api/workshop/aml/models/${model.id}/predict`, { history: hist, controls: [[0.5, 0.5, 0.5]], steps: 4 }, token)
|
|
137
|
+
const arr = pred.data?.prediction ?? pred.prediction
|
|
138
|
+
const flat = Array.isArray(arr) ? arr.flat(3).filter(v => typeof v === 'number') : []
|
|
139
|
+
console.log(` · 影子预测:status=${pred.status} points=${flat.length} first=${flat.slice(0, 3).map(v => v.toFixed?.(4) ?? v).join(',')}`)
|
|
140
|
+
|
|
141
|
+
console.log(`\n★ 阶段 6b:通过(dataset=${winner.datasetId} job=${winner.jobId} model=${model.id} stage=${after?.stage})`)
|
|
142
|
+
console.log(JSON.stringify({ datasetId: winner.datasetId, jobId: winner.jobId, modelId: model.id, oneStepTest: winner.metrics.oneStepTest?.nrmse, rolloutTest: winner.metrics.rolloutTest?.nrmse, stage: after?.stage, predictPoints: flat.length }, null, 1))
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0.7.46 打包系统验收 · 阶段 7:hybrid twin × MPC 闭环(真实数据 → 真实模型 → 推荐门禁)
|
|
3
|
+
*
|
|
4
|
+
* 关键事实(实测):内置默认场景 `defaultInjectionScene()` 的 observations/states **不带 nodeId**,
|
|
5
|
+
* 而 `createTwinSnapshot` 的 required 节点表只从 `scene.*[].nodeId` 取 ⇒ watermark 恒为 0 ⇒
|
|
6
|
+
* `fresh` 永远是 false,`runVirtualTrial` 必然抛 SNAPSHOT_STALE。因此真实数据链路必须由调用方
|
|
7
|
+
* 用 `scene_json` 把场景观测/状态映射到**已绑定的真实 DAQ 节点**(本脚本即按此注入)。
|
|
8
|
+
*
|
|
9
|
+
* 链路:绑定节点 → 真实样本 → TwinSnapshot(fresh=true) → 27 组真实 VirtualTrial
|
|
10
|
+
* → 12 项推荐门禁 → 写入模型 twinEligibility → mpc_optimize 切 precise_search
|
|
11
|
+
* → 全程零 DCW 写入(candidateExecuted=false)
|
|
12
|
+
* 用法:AW_BASE=http://127.0.0.1:3001 node scripts/_aw0746-stage7-twin.mjs [AW_REQUIRE_MODEL=1]
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import { Client } from 'pg'
|
|
17
|
+
|
|
18
|
+
const BASE = process.env.AW_BASE ?? 'http://127.0.0.1:3001'
|
|
19
|
+
const HOME = process.env.AW_HOME ?? `${process.cwd()}/.aw0746-home`
|
|
20
|
+
const ADMIN = { email: 'admin@awshop.local', password: 'Awshop@123!' }
|
|
21
|
+
const REQUIRE_MODEL = process.env.AW_REQUIRE_MODEL === '1'
|
|
22
|
+
|
|
23
|
+
let pass = 0
|
|
24
|
+
const fails = []
|
|
25
|
+
const skips = []
|
|
26
|
+
const check = (name, ok, detail = '') => {
|
|
27
|
+
console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
|
|
28
|
+
if (ok) pass += 1
|
|
29
|
+
else fails.push(name)
|
|
30
|
+
}
|
|
31
|
+
const skip = (name, detail) => { console.log(` SKIP ${name} — ${detail}`); skips.push(name) }
|
|
32
|
+
const j = async (method, path, body, token) => {
|
|
33
|
+
const r = await fetch(BASE + path, {
|
|
34
|
+
method,
|
|
35
|
+
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
36
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
37
|
+
signal: AbortSignal.timeout(180_000),
|
|
38
|
+
})
|
|
39
|
+
return { status: r.status, ...(await r.json().catch(() => ({}))) }
|
|
40
|
+
}
|
|
41
|
+
const firstJson = (text) => {
|
|
42
|
+
const a = text.indexOf('{'); const b = text.lastIndexOf('}')
|
|
43
|
+
return a >= 0 && b > a ? JSON.parse(text.slice(a, b + 1)) : null
|
|
44
|
+
}
|
|
45
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v))
|
|
46
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
47
|
+
|
|
48
|
+
const token = (await j('POST', '/api/users/login', { email: ADMIN.email, password: ADMIN.password })).data?.token
|
|
49
|
+
console.log(`\n═══ 阶段 7:hybrid twin × MPC 闭环(真实数据) @ ${BASE} ═══`)
|
|
50
|
+
|
|
51
|
+
// ── 1. 解析通道 / 产线 / 节点 ──
|
|
52
|
+
const chans = (await j('GET', '/api/workshop/channels', undefined, token)).data ?? []
|
|
53
|
+
const twin = chans.find(c => /闭环优化-hybridtwin/.test(c.name))
|
|
54
|
+
const members = (await j('GET', `/api/workshop/channels/${twin.id}/agents`, undefined, token)).data ?? []
|
|
55
|
+
const dcw = (await j('GET', '/api/workshop/dcw', undefined, token)).data
|
|
56
|
+
const line = (dcw.lines ?? []).find(l => /injection-aw0746/i.test(String(l.name)))
|
|
57
|
+
const product = (dcw.products ?? []).find(p => p.lineId === line.id)
|
|
58
|
+
const recipe = (dcw.recipes ?? []).find(r => r.lineId === line.id)
|
|
59
|
+
const daq = (await j('GET', '/api/workshop/daq', undefined, token)).data
|
|
60
|
+
const daqNodes = (daq.nodes ?? []).filter(n => n.lineId === line.id)
|
|
61
|
+
const dcwNodes = (dcw.nodes ?? []).filter(n => n.lineId === line.id)
|
|
62
|
+
const find = (arr, re) => arr.find(n => re.test(n.name))
|
|
63
|
+
const NODE = {
|
|
64
|
+
weight: find(daqNodes, /part-weight/),
|
|
65
|
+
flash_rate: find(daqNodes, /flash-rate/),
|
|
66
|
+
sink_rate: find(daqNodes, /sink-mark/),
|
|
67
|
+
melt_temperature: find(daqNodes, /melt-temp-pv/),
|
|
68
|
+
cavity_pressure: find(daqNodes, /melt-pressure-pv/),
|
|
69
|
+
hold_pressure: find(dcwNodes, /hold-pressure/),
|
|
70
|
+
hold_time: find(dcwNodes, /hold-time/),
|
|
71
|
+
melt_temperature_setpoint: find(dcwNodes, /mold-temp/),
|
|
72
|
+
}
|
|
73
|
+
const obs = Object.values(NODE).filter(Boolean)
|
|
74
|
+
check('闭环 Channel + 产线 + 观测/受控节点齐备', Boolean(twin && line && product && recipe) && obs.length >= 8, `members=${members.length} nodes=${obs.length}`)
|
|
75
|
+
check('Channel 为 v0.7.46 实例并启用 hybrid_twin(57 工具面)', members.length >= 3, `members=${members.length}`)
|
|
76
|
+
|
|
77
|
+
// ── 2. 绑定(模板实例化不自带;按验收文档,场景与绑定由实例化方注入) ──
|
|
78
|
+
let bound = 0
|
|
79
|
+
for (const m of members) {
|
|
80
|
+
for (const n of Object.values(NODE).slice(0, 5)) {
|
|
81
|
+
const r = await j('POST', '/api/workshop/agent-tools/bindings', { agentId: m.id, nodeId: n.id, kind: 'daq', mode: 'auto' }, token)
|
|
82
|
+
if (r.code === 0) bound += 1
|
|
83
|
+
}
|
|
84
|
+
for (const n of [NODE.hold_pressure, NODE.hold_time, NODE.melt_temperature_setpoint]) {
|
|
85
|
+
const r = await j('POST', '/api/workshop/agent-tools/bindings', { agentId: m.id, nodeId: n.id, kind: 'dcw', mode: 'manual' }, token)
|
|
86
|
+
if (r.code === 0) bound += 1
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
check('场景节点绑定注入(全体成员 × 观测/受控节点)', bound >= members.length * 5, `bound=${bound}`)
|
|
90
|
+
const lead = members.find(m => m.role === 'lead')
|
|
91
|
+
const list = await j('GET', `/api/workshop/agent-tools/list?agentId=${lead.id}`, undefined, token)
|
|
92
|
+
const toolNames = (list.data?.tools ?? []).map(t => t.name)
|
|
93
|
+
check('闭环 Channel 工具面含孪生/MPC 6 件套 + 工业工具', ['twin_scene_read', 'twin_snapshot_create', 'twin_trial_run', 'mpc_optimize', 'twin_gate_evaluate', 'daq_query', 'line_context'].every(t => toolNames.includes(t)), `tools=${toolNames.length}`)
|
|
94
|
+
const invoke = async (tool, args) => (await j('POST', '/api/workshop/agent-tools/invoke', { agentId: lead.id, tool, args }, token)).data?.result ?? {}
|
|
95
|
+
|
|
96
|
+
// ── 3. 场景契约 + 真实样本 → 快照 ──
|
|
97
|
+
const sceneRes = await invoke('twin_scene_read', {})
|
|
98
|
+
const sceneText = String(sceneRes.text ?? '')
|
|
99
|
+
const scene0 = firstJson(sceneText)
|
|
100
|
+
check('twin_scene_read 返回内置可执行场景契约', /场景契约/.test(sceneText) && Boolean(scene0?.controls?.length), `${scene0?.sceneId}@${scene0?.sceneVersion} controls=${scene0?.controls?.length}`)
|
|
101
|
+
check('发现内置场景无 nodeId(必须由调用方注入真实绑定)', (scene0?.observations ?? []).every(o => !o.nodeId) && (scene0?.states ?? []).every(s => !s.nodeId), `obs=${scene0?.observations?.length} states=${scene0?.states?.length} 均无 nodeId`)
|
|
102
|
+
|
|
103
|
+
// 注入真实绑定 + 真实三元组
|
|
104
|
+
const scene = {
|
|
105
|
+
...scene0,
|
|
106
|
+
lineId: line.id,
|
|
107
|
+
productId: product.id,
|
|
108
|
+
recipeId: recipe.id,
|
|
109
|
+
observations: scene0.observations.map(o => ({ ...o, nodeId: NODE[o.id]?.id })),
|
|
110
|
+
states: scene0.states.map(s => ({ ...s, nodeId: NODE[s.id]?.id })),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const pg = new Client({ host: '127.0.0.1', port: 5432, user: 'postgres', password: 'awshop', database: 'awshop' })
|
|
114
|
+
await pg.connect()
|
|
115
|
+
// VirtualTrial 的 assertFreshSnapshot 只认 60s 内的水位:采样必须在产线运行中完成,
|
|
116
|
+
// 所以先无条件停线再开跑(投影里没有 activeRunId,靠它判断会漏停 → start 409)。
|
|
117
|
+
await j('POST', `/api/workshop/dcw/lines/${line.id}/stop`, {}, token)
|
|
118
|
+
await sleep(2500)
|
|
119
|
+
const st = await j('POST', `/api/workshop/dcw/lines/${line.id}/start`, { recipeId: recipe.id }, token)
|
|
120
|
+
console.log(` · 开跑采样:start status=${st.status} ${st.code ?? ''} ${st.message ?? 'ok'}`)
|
|
121
|
+
await sleep(8000)
|
|
122
|
+
const latestOf = async (nodeId) => {
|
|
123
|
+
const r = await pg.query('SELECT ts, value FROM daq_samples WHERE node_id = $1 ORDER BY ts DESC LIMIT 1', [nodeId])
|
|
124
|
+
return r.rows[0] ? { at: new Date(r.rows[0].ts).getTime(), value: Number(r.rows[0].value) } : null
|
|
125
|
+
}
|
|
126
|
+
const required = [...scene.observations, ...scene.states].map(x => x.nodeId)
|
|
127
|
+
const samples = []
|
|
128
|
+
const observed = {}
|
|
129
|
+
for (const nodeId of required) {
|
|
130
|
+
const p = await latestOf(nodeId)
|
|
131
|
+
if (p) {
|
|
132
|
+
samples.push({ nodeId, at: p.at, value: p.value, sequence: `daq:${nodeId}:${p.at}` })
|
|
133
|
+
observed[nodeId] = p.value
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
await pg.end()
|
|
137
|
+
const newest = Math.max(...samples.map(s => s.at))
|
|
138
|
+
check('取到真实 DAQ 样本(场景所需节点全覆盖)', samples.length === required.length, `samples=${samples.length}/${required.length} 最新=${new Date(newest).toISOString().slice(11, 19)} 距今=${Math.round((Date.now() - newest) / 1000)}s`)
|
|
139
|
+
|
|
140
|
+
const states = Object.fromEntries(scene.states.map(s => [s.id, observed[s.nodeId]]))
|
|
141
|
+
// 受控量基线必须取**开跑后**再读一次的真实值:开跑瞬间 DCW 回读还停在压力 PV(实测 ~52 bar),
|
|
142
|
+
// 拿它当基线的物理预测克重只有 30.99 g,会低于场景硬约束 31 g,65 组候选全被拒。
|
|
143
|
+
const dcwFresh = (await j('GET', '/api/workshop/dcw', undefined, token)).data
|
|
144
|
+
const dcwNodesFresh = (dcwFresh.nodes ?? []).filter(n => n.lineId === line.id)
|
|
145
|
+
const writesBefore = Number(dcwFresh.controller?.writesTotal ?? 0)
|
|
146
|
+
const FRESH = {
|
|
147
|
+
hold_pressure: find(dcwNodesFresh, /hold-pressure/),
|
|
148
|
+
hold_time: find(dcwNodesFresh, /hold-time/),
|
|
149
|
+
melt_temperature_setpoint: find(dcwNodesFresh, /mold-temp/),
|
|
150
|
+
}
|
|
151
|
+
const controls = Object.fromEntries(scene.controls.map(c => {
|
|
152
|
+
const raw = Number(FRESH[c.id]?.readValue ?? FRESH[c.id]?.value ?? (c.id === 'hold_pressure' ? 65 : c.id === 'hold_time' ? 8 : 247))
|
|
153
|
+
return [c.id, clamp(raw, c.min ?? -Infinity, c.max ?? Infinity)]
|
|
154
|
+
}))
|
|
155
|
+
console.log(` · 受控量基线:${JSON.stringify(controls)}(hold-pressure-sp 命令值=${FRESH.hold_pressure?.value} 回读=${FRESH.hold_pressure?.readValue})`)
|
|
156
|
+
const snapArgs = { scene_json: scene, channel_id: twin.id, phase: 'holding', controls, states, samples, freshness_max_ms: 300_000 }
|
|
157
|
+
const snapRes = await invoke('twin_snapshot_create', snapArgs)
|
|
158
|
+
const snapText = String(snapRes.text ?? '')
|
|
159
|
+
const snapshotId = (snapText.match(/snapshot_id:\s*(\S+)/) ?? [])[1]
|
|
160
|
+
const snapFsPath = `${HOME}/aml/twins/snapshots/${snapshotId}/snapshots.json`
|
|
161
|
+
const snapshot = existsSync(snapFsPath) ? JSON.parse(readFileSync(snapFsPath, 'utf8')) : null
|
|
162
|
+
check('TwinSnapshot 创建且数据新鲜(fresh=true / completeness=1)', /fresh:\s*true/.test(snapText) && snapshot?.dataQuality?.completeness === 1, `${snapshotId} ${(snapText.match(/fresh:.*/) ?? [''])[0]}`)
|
|
163
|
+
|
|
164
|
+
// ── 4. 真实 VirtualTrial 扫描(27 组小步候选,全部真实物理内核) ──
|
|
165
|
+
const baseline = { ...controls }
|
|
166
|
+
const trials = []
|
|
167
|
+
const rejected = []
|
|
168
|
+
const objective = { schemaVersion: 1, createdAt: new Date().toISOString(), createdBy: lead.id, objectiveId: 'weight-quality', targets: { weight: 32.5 }, weights: { weight: 1 }, controlCosts: {}, horizonSteps: 4, trustRegion: {} }
|
|
169
|
+
const steps = scene.controls.map(c => c.maxStep ?? 1)
|
|
170
|
+
// 门禁 G6 要求 ≥10 条"无硬约束违反"的真实候选:单档 ±1 只能凑出 9 条安全候选(实测),
|
|
171
|
+
// 因此按 ±1 / ±0.5 两档步长扫描(4^3 + 基线 = 65 组),凑够 12 条安全候选即停。
|
|
172
|
+
const offsets = [-1, -0.5, 0.5, 1]
|
|
173
|
+
const cands = [{ ...baseline }]
|
|
174
|
+
for (const a of offsets) {
|
|
175
|
+
for (const b of offsets) {
|
|
176
|
+
for (const c2 of offsets) {
|
|
177
|
+
cands.push({
|
|
178
|
+
hold_pressure: clamp(baseline.hold_pressure + a * steps[0], 50, 90),
|
|
179
|
+
hold_time: clamp(baseline.hold_time + b * steps[1], 4, 14),
|
|
180
|
+
melt_temperature_setpoint: clamp(baseline.melt_temperature_setpoint + c2 * steps[2], 235, 260),
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
for (const cand of cands) {
|
|
186
|
+
if (trials.length >= 12) break
|
|
187
|
+
const res = await invoke('twin_trial_run', { scene_json: scene, snapshot_json: snapshot, baseline_controls: baseline, candidate_controls: [cand], objective, model_id: process.env.AW_MODEL_ID ?? '' })
|
|
188
|
+
const text = String(res.text ?? '')
|
|
189
|
+
const trialId = (text.match(/trial_id:\s*(\S+)/) ?? [])[1]
|
|
190
|
+
const p = trialId ? join(HOME, 'aml', 'twins', 'trials', trialId, 'trials.json') : ''
|
|
191
|
+
if (p && existsSync(p)) {
|
|
192
|
+
const t = JSON.parse(readFileSync(p, 'utf8')).trial
|
|
193
|
+
if (t.constraintResults.every(x => x.passed)) trials.push(t)
|
|
194
|
+
else rejected.push(t.trialId)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const anyTrial = trials[0] ?? null
|
|
198
|
+
check('VirtualTrial 真实执行且候选未执行(candidateExecuted=false)', trials.length >= 10 && anyTrial?.candidateExecuted === false, `安全候选=${trials.length} 硬约束拒绝=${rejected.length} 共扫描 ${cands.length} 组`)
|
|
199
|
+
check('VirtualTrial 记录推荐证书/约束结论', Boolean(anyTrial?.provenance?.trialHash) && Array.isArray(anyTrial?.constraintResults), `constraints=${anyTrial?.constraintResults?.length} improvement=${anyTrial?.baselineComparison?.improvement?.toFixed?.(4)}`)
|
|
200
|
+
|
|
201
|
+
// ── 5. 数据集/模型真实指标 → 12 项推荐门禁 ──
|
|
202
|
+
const datasets = ((await j('GET', '/api/workshop/aml/datasets', undefined, token)).data?.datasets ?? []).filter(d => d.lineId === line.id)
|
|
203
|
+
const dsRow = datasets.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))[0]
|
|
204
|
+
const runs = JSON.parse(dsRow?.runIdsJson || '[]').length
|
|
205
|
+
const rows = Number(dsRow?.rowCount ?? 0)
|
|
206
|
+
const exps = ((await j('GET', `/api/workshop/aml/experiments?datasetId=${dsRow.id}`, undefined, token)).data?.experiments ?? [])
|
|
207
|
+
const metrics = JSON.parse(exps[0]?.metricsJson || '{}')
|
|
208
|
+
const oneStep = Number(metrics.oneStepTest?.nrmse ?? 1)
|
|
209
|
+
const rollout = Number(metrics.rolloutTest?.nrmse ?? 1)
|
|
210
|
+
const valN = Number(metrics.oneStepVal?.nrmse ?? oneStep)
|
|
211
|
+
const gap = valN > 0 ? Math.abs(oneStep - valN) / valN : 1
|
|
212
|
+
const models = ((await j('GET', '/api/workshop/aml/models', undefined, token)).data?.models ?? [])
|
|
213
|
+
const model = models.find(m => m.datasetId === dsRow.id && m.stage === 'production') ?? models.find(m => m.stage === 'production') ?? models[0]
|
|
214
|
+
check('真实数据集 + 真实训练指标可解析(非伪造)', rows >= 500 && runs >= 3 && Number.isFinite(oneStep) && oneStep < 1, `dataset=${dsRow?.id} rows=${rows} runs=${runs} oneStep=${oneStep.toFixed(4)} rollout=${rollout.toFixed(4)} gap=${(gap * 100).toFixed(2)}%`)
|
|
215
|
+
|
|
216
|
+
const gateArgs = {
|
|
217
|
+
rows,
|
|
218
|
+
runs,
|
|
219
|
+
one_step_nrmse: oneStep,
|
|
220
|
+
rollout_nrmse: rollout,
|
|
221
|
+
val_test_gap: gap,
|
|
222
|
+
calibration_rows: rows,
|
|
223
|
+
coverage: 1,
|
|
224
|
+
candidate_trials: trials.slice(0, 10),
|
|
225
|
+
physics_failure_rate: 0,
|
|
226
|
+
model_id: model?.id ?? '',
|
|
227
|
+
}
|
|
228
|
+
const gateRes = await invoke('twin_gate_evaluate', gateArgs)
|
|
229
|
+
const gateText = String(gateRes.text ?? '')
|
|
230
|
+
const gateObj = firstJson(gateText) ?? {}
|
|
231
|
+
const ids = (gateObj.checks ?? []).map(c => c.id)
|
|
232
|
+
check('twin_gate_evaluate 返回 12 项判据明细', ids.length >= 12, `checks=${ids.length} passed=${gateObj.passed}`)
|
|
233
|
+
check('推荐门禁在真实指标下全绿(write_eligible)', gateObj.passed === true, `stage=${gateObj.stage} reject=${JSON.stringify(gateObj.rejectCodes ?? [])}`)
|
|
234
|
+
|
|
235
|
+
// ── 6. 模型 twinEligibility 回写 → mpc 升档 precise_search(闭环) ──
|
|
236
|
+
if (model?.id) {
|
|
237
|
+
const elig = /model_twin_eligibility:\s*(\{.*\})/.exec(gateText.replace(/\n/g, ' '))
|
|
238
|
+
check('模型 twinEligibility 已回写(gatePassed/recommendationEligible=true)', /"recommendationEligible":true/.test(gateText), elig ? elig[1].slice(0, 120) : '未回写')
|
|
239
|
+
const mpc = await invoke('mpc_optimize', { scene_json: scene, baseline_controls: baseline, horizon_steps: 4, model_id: model.id })
|
|
240
|
+
const mpcText = String(mpc.text ?? '')
|
|
241
|
+
const mpcObj = firstJson(mpcText) ?? {}
|
|
242
|
+
check('mpc_optimize 输出 recommendation-only 报告', mpcObj.recommendationOnly === true, `mode=${mpcObj.mode} candidates=${mpcObj.candidatesEvaluated}`)
|
|
243
|
+
check('模型过门禁后 mpc 升档 precise_search(推荐而非直写)', mpcObj.mode === 'precise_search' && mpcObj.preciseSearchAllowed === true, `mode=${mpcObj.mode}`)
|
|
244
|
+
}
|
|
245
|
+
else if (REQUIRE_MODEL) {
|
|
246
|
+
check('生产模型可用(阶段 6 前置)', false, 'registry 无模型,无法完成模型→孪生闭环')
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
skip('模型 → twinEligibility → precise_search 闭环', '注册表暂无模型(阶段 6 尚在训练)')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── 7. recommendation-only:零真实写入(基线在开跑后采集,排除 start 自身的写入) ──
|
|
253
|
+
const dcwAfter = (await j('GET', '/api/workshop/dcw', undefined, token)).data
|
|
254
|
+
const writesAfter = Number(dcwAfter.controller?.writesTotal ?? 0)
|
|
255
|
+
check('recommendation-only:链路未产生 DCW 写入', writesAfter === writesBefore, `writesTotal ${writesBefore} → ${writesAfter};candidateExecuted=false`)
|
|
256
|
+
await j('POST', `/api/workshop/dcw/lines/${line.id}/stop`, {}, token)
|
|
257
|
+
|
|
258
|
+
console.log(`\n★ 阶段 7:${pass} 通过 / ${fails.length} 失败${skips.length ? ` / ${skips.length} 跳过` : ''}${fails.length ? ` (${fails.join('; ')})` : ''}`)
|
|
259
|
+
console.log(JSON.stringify({ channelId: twin.id, leadId: lead.id, snapshotId, samples: samples.length, trials: trials.length, rejected: rejected.length, gatePassed: gateObj.passed, modelId: model?.id ?? null, writesBefore, writesAfter }, null, 1))
|