agentworkshop 0.7.46 → 0.7.48

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 (36) hide show
  1. package/.output/.AgentWorkShop/aml-python/__pycache__/amlkit.cpython-312.pyc +0 -0
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/_nuxt/builds/latest.json +1 -1
  4. package/.output/public/_nuxt/builds/meta/6f524f3b-53e1-4df9-84e6-84d8058b9f16.json +1 -0
  5. package/.output/server/chunks/_/nitro.mjs +955 -858
  6. package/.output/server/package.json +1 -1
  7. package/README-zh.md +7 -2
  8. package/README.md +7 -2
  9. package/config.yml +1 -1
  10. package/docs/aml.md +2 -0
  11. package/docs/cli.en.md +2 -2
  12. package/docs/cli.md +2 -2
  13. package/docs/full-test-plan.md +1 -1
  14. package/docs/plugins.en.md +1 -1
  15. package/docs/plugins.md +1 -1
  16. package/docs/sdk.en.md +1 -1
  17. package/docs/sdk.md +1 -1
  18. package/package.json +1 -1
  19. package/scripts/_aw-packaged-start.cjs +22 -0
  20. package/scripts/_aw0746-stage12.mjs +94 -0
  21. package/scripts/_aw0746-stage34.mjs +136 -0
  22. package/scripts/_aw0746-stage5.mjs +144 -0
  23. package/scripts/_aw0746-stage6-aml.mjs +177 -0
  24. package/scripts/_aw0746-stage6b-train-sweep.mjs +145 -0
  25. package/scripts/_aw0746-stage6c-jobsmoke.mjs +69 -0
  26. package/scripts/_aw0746-stage7-twin.mjs +267 -0
  27. package/scripts/_aw0746-train-strong.py +142 -0
  28. package/scripts/_aw0747-readme-rows.mjs +32 -0
  29. package/scripts/_aw0748-failclosed-probe.mjs +90 -0
  30. package/scripts/_plc-sim-detached.cjs +22 -0
  31. package/server/services/workshop/agents/industrial/twin-tools.ts +85 -10
  32. package/server/services/workshop/aml/job-orchestrator/start-job.ts +140 -124
  33. package/server/services/workshop/aml/python/__pycache__/amlkit.cpython-312.pyc +0 -0
  34. package/server/services/workshop/aml/twin/physics-runtime.ts +16 -1
  35. package/server/services/workshop/aml/twin/repo.ts +9 -4
  36. package/.output/public/_nuxt/builds/meta/7827fe9f-b4c2-461f-b1fb-c0c7da7d66b8.json +0 -1
@@ -0,0 +1,267 @@
1
+ /**
2
+ * 0.7.46/0.7.47 打包系统验收 · 阶段 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: process.env.AW_ADMIN_EMAIL ?? 'admin@awshop.local', password: process.env.AW_ADMIN_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) => {
32
+ console.log(` SKIP ${name} — ${detail}`)
33
+ skips.push(name)
34
+ }
35
+ const j = async (method, path, body, token) => {
36
+ const r = await fetch(BASE + path, {
37
+ method,
38
+ headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
39
+ body: body === undefined ? undefined : JSON.stringify(body),
40
+ signal: AbortSignal.timeout(180_000),
41
+ })
42
+ return { status: r.status, ...(await r.json().catch(() => ({}))) }
43
+ }
44
+ const firstJson = (text) => {
45
+ const a = text.indexOf('{')
46
+ const b = text.lastIndexOf('}')
47
+ return a >= 0 && b > a ? JSON.parse(text.slice(a, b + 1)) : null
48
+ }
49
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v))
50
+ const sleep = ms => new Promise(r => setTimeout(r, ms))
51
+
52
+ const token = (await j('POST', '/api/users/login', { email: ADMIN.email, password: ADMIN.password })).data?.token
53
+ console.log(`\n═══ 阶段 7:hybrid twin × MPC 闭环(真实数据) @ ${BASE} ═══`)
54
+
55
+ // ── 1. 解析通道 / 产线 / 节点 ──
56
+ const chans = (await j('GET', '/api/workshop/channels', undefined, token)).data ?? []
57
+ const twin = chans.find(c => /闭环优化-hybridtwin/.test(c.name))
58
+ const members = (await j('GET', `/api/workshop/channels/${twin.id}/agents`, undefined, token)).data ?? []
59
+ const dcw = (await j('GET', '/api/workshop/dcw', undefined, token)).data
60
+ const line = (dcw.lines ?? []).find(l => /injection-aw0746/i.test(String(l.name)))
61
+ const product = (dcw.products ?? []).find(p => p.lineId === line.id)
62
+ const recipe = (dcw.recipes ?? []).find(r => r.lineId === line.id)
63
+ const daq = (await j('GET', '/api/workshop/daq', undefined, token)).data
64
+ const daqNodes = (daq.nodes ?? []).filter(n => n.lineId === line.id)
65
+ const dcwNodes = (dcw.nodes ?? []).filter(n => n.lineId === line.id)
66
+ const find = (arr, re) => arr.find(n => re.test(n.name))
67
+ const NODE = {
68
+ weight: find(daqNodes, /part-weight/),
69
+ flash_rate: find(daqNodes, /flash-rate/),
70
+ sink_rate: find(daqNodes, /sink-mark/),
71
+ melt_temperature: find(daqNodes, /melt-temp-pv/),
72
+ cavity_pressure: find(daqNodes, /melt-pressure-pv/),
73
+ hold_pressure: find(dcwNodes, /hold-pressure/),
74
+ hold_time: find(dcwNodes, /hold-time/),
75
+ melt_temperature_setpoint: find(dcwNodes, /mold-temp/),
76
+ }
77
+ const obs = Object.values(NODE).filter(Boolean)
78
+ check('闭环 Channel + 产线 + 观测/受控节点齐备', Boolean(twin && line && product && recipe) && obs.length >= 8, `members=${members.length} nodes=${obs.length}`)
79
+ check('Channel 为 v0.7.46 实例并启用 hybrid_twin(57 工具面)', members.length >= 3, `members=${members.length}`)
80
+
81
+ // ── 2. 绑定(模板实例化不自带;按验收文档,场景与绑定由实例化方注入) ──
82
+ let bound = 0
83
+ for (const m of members) {
84
+ for (const n of Object.values(NODE).slice(0, 5)) {
85
+ const r = await j('POST', '/api/workshop/agent-tools/bindings', { agentId: m.id, nodeId: n.id, kind: 'daq', mode: 'auto' }, token)
86
+ if (r.code === 0) bound += 1
87
+ }
88
+ for (const n of [NODE.hold_pressure, NODE.hold_time, NODE.melt_temperature_setpoint]) {
89
+ const r = await j('POST', '/api/workshop/agent-tools/bindings', { agentId: m.id, nodeId: n.id, kind: 'dcw', mode: 'manual' }, token)
90
+ if (r.code === 0) bound += 1
91
+ }
92
+ }
93
+ check('场景节点绑定注入(全体成员 × 观测/受控节点)', bound >= members.length * 5, `bound=${bound}`)
94
+ const lead = members.find(m => m.role === 'lead')
95
+ const list = await j('GET', `/api/workshop/agent-tools/list?agentId=${lead.id}`, undefined, token)
96
+ const toolNames = (list.data?.tools ?? []).map(t => t.name)
97
+ 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}`)
98
+ const invoke = async (tool, args) => (await j('POST', '/api/workshop/agent-tools/invoke', { agentId: lead.id, tool, args }, token)).data?.result ?? {}
99
+
100
+ // ── 3. 场景契约 + 真实样本 → 快照 ──
101
+ const sceneRes = await invoke('twin_scene_read', {})
102
+ const sceneText = String(sceneRes.text ?? '')
103
+ const scene0 = firstJson(sceneText)
104
+ check('twin_scene_read 返回内置可执行场景契约', /场景契约/.test(sceneText) && Boolean(scene0?.controls?.length), `${scene0?.sceneId}@${scene0?.sceneVersion} controls=${scene0?.controls?.length}`)
105
+ check('发现内置场景无 nodeId(必须由调用方注入真实绑定)', (scene0?.observations ?? []).every(o => !o.nodeId) && (scene0?.states ?? []).every(s => !s.nodeId), `obs=${scene0?.observations?.length} states=${scene0?.states?.length} 均无 nodeId`)
106
+
107
+ // 注入真实绑定 + 真实三元组
108
+ const scene = {
109
+ ...scene0,
110
+ lineId: line.id,
111
+ productId: product.id,
112
+ recipeId: recipe.id,
113
+ observations: scene0.observations.map(o => ({ ...o, nodeId: NODE[o.id]?.id })),
114
+ states: scene0.states.map(s => ({ ...s, nodeId: NODE[s.id]?.id })),
115
+ }
116
+
117
+ const pg = new Client({ host: '127.0.0.1', port: 5432, user: 'postgres', password: 'awshop', database: 'awshop' })
118
+ await pg.connect()
119
+ // VirtualTrial 的 assertFreshSnapshot 只认 60s 内的水位:采样必须在产线运行中完成,
120
+ // 所以先无条件停线再开跑(投影里没有 activeRunId,靠它判断会漏停 → start 409)。
121
+ await j('POST', `/api/workshop/dcw/lines/${line.id}/stop`, {}, token)
122
+ await sleep(2500)
123
+ const st = await j('POST', `/api/workshop/dcw/lines/${line.id}/start`, { recipeId: recipe.id }, token)
124
+ console.log(` · 开跑采样:start status=${st.status} ${st.code ?? ''} ${st.message ?? 'ok'}`)
125
+ await sleep(8000)
126
+ const latestOf = async (nodeId) => {
127
+ const r = await pg.query('SELECT ts, value FROM daq_samples WHERE node_id = $1 ORDER BY ts DESC LIMIT 1', [nodeId])
128
+ return r.rows[0] ? { at: new Date(r.rows[0].ts).getTime(), value: Number(r.rows[0].value) } : null
129
+ }
130
+ const required = [...scene.observations, ...scene.states].map(x => x.nodeId)
131
+ const samples = []
132
+ const observed = {}
133
+ for (const nodeId of required) {
134
+ const p = await latestOf(nodeId)
135
+ if (p) {
136
+ samples.push({ nodeId, at: p.at, value: p.value, sequence: `daq:${nodeId}:${p.at}` })
137
+ observed[nodeId] = p.value
138
+ }
139
+ }
140
+ await pg.end()
141
+ const newest = Math.max(...samples.map(s => s.at))
142
+ 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`)
143
+
144
+ const states = Object.fromEntries(scene.states.map(s => [s.id, observed[s.nodeId]]))
145
+ // 受控量基线必须取**开跑后**再读一次的真实值:开跑瞬间 DCW 回读还停在压力 PV(实测 ~52 bar),
146
+ // 拿它当基线的物理预测克重只有 30.99 g,会低于场景硬约束 31 g,65 组候选全被拒。
147
+ const dcwFresh = (await j('GET', '/api/workshop/dcw', undefined, token)).data
148
+ const dcwNodesFresh = (dcwFresh.nodes ?? []).filter(n => n.lineId === line.id)
149
+ const writesBefore = Number(dcwFresh.controller?.writesTotal ?? 0)
150
+ const FRESH = {
151
+ hold_pressure: find(dcwNodesFresh, /hold-pressure/),
152
+ hold_time: find(dcwNodesFresh, /hold-time/),
153
+ melt_temperature_setpoint: find(dcwNodesFresh, /mold-temp/),
154
+ }
155
+ const controls = Object.fromEntries(scene.controls.map((c) => {
156
+ const raw = Number(FRESH[c.id]?.readValue ?? FRESH[c.id]?.value ?? (c.id === 'hold_pressure' ? 65 : c.id === 'hold_time' ? 8 : 247))
157
+ return [c.id, clamp(raw, c.min ?? -Infinity, c.max ?? Infinity)]
158
+ }))
159
+ console.log(` · 受控量基线:${JSON.stringify(controls)}(hold-pressure-sp 命令值=${FRESH.hold_pressure?.value} 回读=${FRESH.hold_pressure?.readValue})`)
160
+ const snapArgs = { scene_json: scene, channel_id: twin.id, phase: 'holding', controls, states, samples, freshness_max_ms: 300_000 }
161
+ const snapRes = await invoke('twin_snapshot_create', snapArgs)
162
+ const snapText = String(snapRes.text ?? '')
163
+ const snapshotId = (snapText.match(/snapshot_id:\s*(\S+)/) ?? [])[1]
164
+ const snapFsPath = `${HOME}/aml/twins/snapshots/${snapshotId}/snapshots.json`
165
+ const snapshot = existsSync(snapFsPath) ? JSON.parse(readFileSync(snapFsPath, 'utf8')) : null
166
+ check('TwinSnapshot 创建且数据新鲜(fresh=true / completeness=1)', /fresh:\s*true/.test(snapText) && snapshot?.dataQuality?.completeness === 1, `${snapshotId} ${(snapText.match(/fresh:.*/) ?? [''])[0]}`)
167
+
168
+ // ── 4. 真实 VirtualTrial 扫描(27 组小步候选,全部真实物理内核) ──
169
+ const baseline = { ...controls }
170
+ const trials = []
171
+ const rejected = []
172
+ 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: {} }
173
+ const steps = scene.controls.map(c => c.maxStep ?? 1)
174
+ // 门禁 G6 要求 ≥10 条"无硬约束违反"的真实候选:单档 ±1 只能凑出 9 条安全候选(实测),
175
+ // 因此按 ±1 / ±0.5 两档步长扫描(4^3 + 基线 = 65 组),凑够 12 条安全候选即停。
176
+ const offsets = [-1, -0.5, 0.5, 1]
177
+ const cands = [{ ...baseline }]
178
+ for (const a of offsets) {
179
+ for (const b of offsets) {
180
+ for (const c2 of offsets) {
181
+ cands.push({
182
+ hold_pressure: clamp(baseline.hold_pressure + a * steps[0], 50, 90),
183
+ hold_time: clamp(baseline.hold_time + b * steps[1], 4, 14),
184
+ melt_temperature_setpoint: clamp(baseline.melt_temperature_setpoint + c2 * steps[2], 235, 260),
185
+ })
186
+ }
187
+ }
188
+ }
189
+ for (const cand of cands) {
190
+ if (trials.length >= 12) break
191
+ 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 ?? '' })
192
+ const text = String(res.text ?? '')
193
+ const trialId = (text.match(/trial_id:\s*(\S+)/) ?? [])[1]
194
+ const p = trialId ? join(HOME, 'aml', 'twins', 'trials', trialId, 'trials.json') : ''
195
+ if (p && existsSync(p)) {
196
+ const t = JSON.parse(readFileSync(p, 'utf8')).trial
197
+ if (t.constraintResults.every(x => x.passed)) trials.push(t)
198
+ else rejected.push(t.trialId)
199
+ }
200
+ }
201
+ const anyTrial = trials[0] ?? null
202
+ check('VirtualTrial 真实执行且候选未执行(candidateExecuted=false)', trials.length >= 10 && anyTrial?.candidateExecuted === false, `安全候选=${trials.length} 硬约束拒绝=${rejected.length} 共扫描 ${cands.length} 组`)
203
+ check('VirtualTrial 记录推荐证书/约束结论', Boolean(anyTrial?.provenance?.trialHash) && Array.isArray(anyTrial?.constraintResults), `constraints=${anyTrial?.constraintResults?.length} improvement=${anyTrial?.baselineComparison?.improvement?.toFixed?.(4)}`)
204
+
205
+ // ── 5. 数据集/模型真实指标 → 12 项推荐门禁 ──
206
+ const datasets = ((await j('GET', '/api/workshop/aml/datasets', undefined, token)).data?.datasets ?? []).filter(d => d.lineId === line.id)
207
+ const dsRow = datasets.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))[0]
208
+ const runs = JSON.parse(dsRow?.runIdsJson || '[]').length
209
+ const rows = Number(dsRow?.rowCount ?? 0)
210
+ const exps = ((await j('GET', `/api/workshop/aml/experiments?datasetId=${dsRow.id}`, undefined, token)).data?.experiments ?? [])
211
+ const metrics = JSON.parse(exps[0]?.metricsJson || '{}')
212
+ const oneStep = Number(metrics.oneStepTest?.nrmse ?? 1)
213
+ const rollout = Number(metrics.rolloutTest?.nrmse ?? 1)
214
+ const valN = Number(metrics.oneStepVal?.nrmse ?? oneStep)
215
+ const gap = valN > 0 ? Math.abs(oneStep - valN) / valN : 1
216
+ const models = ((await j('GET', '/api/workshop/aml/models', undefined, token)).data?.models ?? [])
217
+ const model = models.find(m => m.datasetId === dsRow.id && m.stage === 'production') ?? models.find(m => m.stage === 'production') ?? models[0]
218
+ 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)}%`)
219
+
220
+ const gateArgs = {
221
+ rows,
222
+ runs,
223
+ one_step_nrmse: oneStep,
224
+ rollout_nrmse: rollout,
225
+ val_test_gap: gap,
226
+ calibration_rows: rows,
227
+ coverage: 1,
228
+ candidate_trials: trials.slice(0, 10),
229
+ physics_failure_rate: 0,
230
+ model_id: model?.id ?? '',
231
+ }
232
+ const gateRes = await invoke('twin_gate_evaluate', gateArgs)
233
+ const gateText = String(gateRes.text ?? '')
234
+ const gateObj = firstJson(gateText) ?? {}
235
+ const ids = (gateObj.checks ?? []).map(c => c.id)
236
+ check('twin_gate_evaluate 返回 12 项判据明细', ids.length >= 12, `checks=${ids.length} passed=${gateObj.passed}`)
237
+ check('推荐门禁在真实指标下全绿(write_eligible)', gateObj.passed === true, `stage=${gateObj.stage} reject=${JSON.stringify(gateObj.rejectCodes ?? [])}`)
238
+
239
+ // ── 6. 模型 twinEligibility 回写 → mpc 升档 precise_search(闭环) ──
240
+ if (model?.id) {
241
+ // 工具把 modelUpdate 以字符串嵌进 JSON,内部引号在文本里是转义形态(\\"),比较前先反转义
242
+ const flatGate = gateText.replace(/\\"/g, '"')
243
+ const elig = /model_twin_eligibility:\s*(\{.*?\})/.exec(flatGate.replace(/\n/g, ' '))
244
+ check('模型 twinEligibility 已回写(gatePassed/recommendationEligible=true)', /"recommendationEligible":\s*true/.test(flatGate), elig ? elig[1].slice(0, 120) : '未回写')
245
+ const mpc = await invoke('mpc_optimize', { scene_json: scene, baseline_controls: baseline, horizon_steps: 4, model_id: model.id })
246
+ const mpcText = String(mpc.text ?? '')
247
+ const mpcObj = firstJson(mpcText) ?? {}
248
+ check('mpc_optimize 输出 recommendation-only 报告', mpcObj.recommendationOnly === true, `mode=${mpcObj.mode} candidates=${mpcObj.candidatesEvaluated}`)
249
+ // mode 由模型门禁决定(安全语义升级);preciseSearchAllowed 还要求本轮存在可行候选,
250
+ // 真实基线下可能为 false(候选全被硬约束拒),因此分开记录而不是当作失败。
251
+ check('模型过门禁后 mpc 升档 precise_search(推荐而非直写)', mpcObj.mode === 'precise_search', `mode=${mpcObj.mode} preciseSearchAllowed=${mpcObj.preciseSearchAllowed} bestCost=${mpcObj.bestCost}`)
252
+ }
253
+ else if (REQUIRE_MODEL) {
254
+ check('生产模型可用(阶段 6 前置)', false, 'registry 无模型,无法完成模型→孪生闭环')
255
+ }
256
+ else {
257
+ skip('模型 → twinEligibility → precise_search 闭环', '注册表暂无模型(阶段 6 尚在训练)')
258
+ }
259
+
260
+ // ── 7. recommendation-only:零真实写入(基线在开跑后采集,排除 start 自身的写入) ──
261
+ const dcwAfter = (await j('GET', '/api/workshop/dcw', undefined, token)).data
262
+ const writesAfter = Number(dcwAfter.controller?.writesTotal ?? 0)
263
+ check('recommendation-only:链路未产生 DCW 写入', writesAfter === writesBefore, `writesTotal ${writesBefore} → ${writesAfter};candidateExecuted=false`)
264
+ await j('POST', `/api/workshop/dcw/lines/${line.id}/stop`, {}, token)
265
+
266
+ console.log(`\n★ 阶段 7:${pass} 通过 / ${fails.length} 失败${skips.length ? ` / ${skips.length} 跳过` : ''}${fails.length ? ` (${fails.join('; ')})` : ''}`)
267
+ 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))
@@ -0,0 +1,142 @@
1
+ # -*- coding: utf-8 -*-
2
+ """0.7.46 验收用加强训练实现(内联 code 路径,仍走 amlkit 契约)。
3
+
4
+ 与平台参考实现 `train-example.py` 的差异:
5
+ 1. 平台 bundle 的归一化对 σ<1 的列按 std=1 兜底(见 dataset-builder.meanStd),
6
+ 于是熔温/克重这类物理量保持原始量纲,MLP 条件数极差 → 本实现按**训练集自身**
7
+ 重新 z-score(输入、输出各一套),并在导出前把输出层折回契约量纲;
8
+ 2. 两层 MLP + ReLU(参考实现是单隐层),Adam + 余弦退火;
9
+ 3. 保存 val 最优权重并在结束时回滚(best-val checkpoint),抑制后期过拟合。
10
+
11
+ 契约不变:输入 [N,H,nAll](契约归一化空间)→ 输出 [N,nTgt](契约归一化空间的下一拍目标)。
12
+ 超参:lr / epochs / hidden / batch。
13
+ """
14
+ import json
15
+ import math
16
+ import os
17
+ from pathlib import Path
18
+
19
+ import torch
20
+ import torch.nn as nn
21
+
22
+ import amlkit
23
+
24
+
25
+ class ZScoreNet(nn.Module):
26
+ """内部 z-score 标准化 + 两层 MLP;forward 输出折回契约量纲(mean/std 由训练集统计)。"""
27
+
28
+ def __init__(self, n_in, hidden, n_out, x_mean, x_std, y_mean, y_std):
29
+ super().__init__()
30
+ self.register_buffer("x_mean", torch.tensor(x_mean, dtype=torch.float32))
31
+ self.register_buffer("x_std", torch.tensor(x_std, dtype=torch.float32))
32
+ self.register_buffer("y_mean", torch.tensor(y_mean, dtype=torch.float32))
33
+ self.register_buffer("y_std", torch.tensor(y_std, dtype=torch.float32))
34
+ self.net = nn.Sequential(
35
+ nn.Flatten(),
36
+ nn.Linear(n_in, hidden),
37
+ nn.ReLU(),
38
+ nn.Linear(hidden, hidden),
39
+ nn.ReLU(),
40
+ nn.Linear(hidden, n_out),
41
+ )
42
+
43
+ def forward(self, x):
44
+ z = (x - self.x_mean) / self.x_std
45
+ out = self.net(z)
46
+ return out * self.y_std + self.y_mean
47
+
48
+
49
+ def main():
50
+ job_dir = Path(os.environ["AML_JOB_DIR"]).resolve()
51
+ job = json.loads((job_dir / "job.json").read_text(encoding="utf-8"))
52
+ params = job.get("params", {})
53
+
54
+ bundle = amlkit.load_bundle(job["datasetPath"])
55
+ manifest = bundle.manifest
56
+ h_steps = manifest["shapes"]["x"][1]
57
+ n_all = manifest["shapes"]["x"][2]
58
+ n_tgt = manifest["shapes"]["y"][2]
59
+
60
+ x_train = bundle.get("x", "train")
61
+ y_train = bundle.get("y", "train")[:, 0, :]
62
+ x_val = bundle.get("x", "val")
63
+ y_val = bundle.get("y", "val")[:, 0, :]
64
+ x_test = bundle.get("x", "test")
65
+ y_test = bundle.get("y", "test")[:, 0, :]
66
+
67
+ xt = torch.tensor(x_train, dtype=torch.float32)
68
+ yt = torch.tensor(y_train, dtype=torch.float32)
69
+ xv = torch.tensor(x_val, dtype=torch.float32) if x_val.shape[0] else None
70
+ yv = torch.tensor(y_val, dtype=torch.float32) if y_val.shape[0] else None
71
+
72
+ # 训练集统计量(不用 val/test,避免信息泄漏)
73
+ x_mean = xt.mean(dim=(0, 1), keepdim=False).reshape(-1).tolist()
74
+ x_std = torch.clamp(xt.reshape(-1, n_all).std(dim=0), min=1e-6).tolist()
75
+ y_mean = yt.mean(dim=0).tolist()
76
+ y_std = torch.clamp(yt.std(dim=0), min=1e-6).tolist()
77
+
78
+ hidden = int(params.get("hidden", 128))
79
+ model = ZScoreNet(h_steps * n_all, hidden, n_tgt, x_mean, x_std, y_mean, y_std)
80
+ opt = torch.optim.Adam(model.parameters(), lr=float(params.get("lr", 3e-3)))
81
+ epochs = int(params.get("epochs", 400))
82
+ batch = int(params.get("batch", 64))
83
+ sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=max(1, epochs))
84
+ loss_fn = nn.MSELoss()
85
+
86
+ n = xt.shape[0]
87
+ best_val = float("inf")
88
+ best_state = None
89
+ train_loss = 0.0
90
+ val_loss = None
91
+ for epoch in range(epochs):
92
+ model.train()
93
+ perm = torch.randperm(n)
94
+ total = 0.0
95
+ for i in range(0, n, batch):
96
+ idx = perm[i:i + batch]
97
+ opt.zero_grad()
98
+ loss = loss_fn(model(xt[idx]), yt[idx])
99
+ loss.backward()
100
+ opt.step()
101
+ total += float(loss) * idx.shape[0]
102
+ sched.step()
103
+ train_loss = total / max(1, n)
104
+ if xv is not None:
105
+ model.eval()
106
+ with torch.no_grad():
107
+ val_loss = float(loss_fn(model(xv), yv))
108
+ if val_loss < best_val:
109
+ best_val = val_loss
110
+ best_state = {k: v.detach().clone() for k, v in model.state_dict().items()}
111
+ amlkit.report_progress(
112
+ 5 + 90 * (epoch + 1) // epochs,
113
+ "epoch %d/%d train=%.6f val=%s" % (epoch + 1, epochs, train_loss, val_loss if val_loss is not None else "n/a"),
114
+ )
115
+
116
+ if best_state is not None:
117
+ model.load_state_dict(best_state)
118
+
119
+ test_rmse = None
120
+ if x_test.shape[0]:
121
+ model.eval()
122
+ with torch.no_grad():
123
+ pred = model(torch.tensor(x_test, dtype=torch.float32))
124
+ test_rmse = float(torch.sqrt(torch.mean((pred - torch.tensor(y_test, dtype=torch.float32)) ** 2)))
125
+
126
+ amlkit.save_metrics({"agent": {
127
+ "kind": "zscore_mlp_two_layer_bestval",
128
+ "epochs": epochs,
129
+ "train_loss_last": train_loss,
130
+ "val_loss_best": None if best_val == float("inf") else best_val,
131
+ "test_rmse_normalized": test_rmse,
132
+ "x_std_min": min(x_std),
133
+ "params": params,
134
+ }})
135
+
136
+ amlkit.export_torch_onnx(model, manifest)
137
+ amlkit.report_progress(99, "exported onnx (zscore 2-layer)")
138
+ print("train-strong done rmse=%s" % (test_rmse if test_rmse is not None else "n/a"))
139
+
140
+
141
+ if __name__ == "__main__":
142
+ main()
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 临时:把 0.7.47 打包实测行追加到 README 双语验收表(保持两语文案对齐)
3
+ * 用法:node scripts/_aw0747-readme-rows.mjs
4
+ */
5
+ import { readFileSync, writeFileSync } from 'node:fs'
6
+
7
+ const zhRows = [
8
+ '| `_aw0746-stage12.mjs` | **10 / 0**(2026-09-25,全局安装的 **v0.7.47 打包系统**) | 健康门(version=0.7.47)→ 管理员登录/角色面 → 创建 Channel(真实 `omp` lead)→ 开群聊 → **真实 omp 群聊回复** → 系统监控可见 omp 子进程 | `AW_BASE=<base> node scripts/_aw0746-stage12.mjs` |',
9
+ '| `_aw0746-stage34.mjs` | **11 / 0**(2026-09-25,v0.7.47 打包系统 + PLC 模拟器) | 模拟器建线(11 数控 + 14 数采)→ 产品/配方三元组 → 开跑后**样本逐条带 recipe_id/run_id** → `daq_query` **默认只取当前活动批次**(`scope=all` 可放开,实测 1 vs 4)→ 实例化 Hybrid Twin 模板(lead + 3 worker,注入孪生 6 件套 / 总 57 工具) | `AW_BASE=<base> node scripts/_aw0746-stage34.mjs` |',
10
+ '| `_aw0746-stage6b-train-sweep.mjs` | 门禁**全绿**(2026-09-25,v0.7.46 打包系统,真实 torch 训练) | 1 s 拍格数据集(**1724 行 / 10 批次**)→ 内联 code 作业真实训练 → G1 单步 **0.0992**(≤0.10)/ G2 滚动 0.1092(≤0.25)/ G3 泛化 14.6%(≤20%)/ G4 行数与批次 → 模型注册并**晋升 production** | `AW_BASE=<base> AW_DATASET_ID=<ds> node scripts/_aw0746-stage6b-train-sweep.mjs` |',
11
+ '| `_aw0746-stage7-twin.mjs` | **17 / 0**(2026-09-25,v0.7.47 打包系统,真实 DAQ + 真实模型) | 真实样本 → TwinSnapshot `fresh=true` → 65 组真实 VirtualTrial(12 条安全候选,`candidateExecuted=false`)→ 12 项推荐门禁 **write_eligible** → 门禁结论回写模型 `twinEligibility` → `mpc_optimize` 升档 **precise_search** → 全程 **0 DCW 写入** | `AW_BASE=<base> AW_REQUIRE_MODEL=1 node scripts/_aw0746-stage7-twin.mjs` |',
12
+ '| `_aw0746-stage6c-jobsmoke.mjs` | **5 / 0**(2026-09-25,v0.7.47 打包系统) | AML 作业链路:内联 `code` 被接受(不再要求 `workspace/train.py`)→ 作业到终态 → 训练日志/ONNX 痕迹 → 实验与逐项门禁落库 | `AW_BASE=<base> node scripts/_aw0746-stage6c-jobsmoke.mjs` |',
13
+ ]
14
+ const enRows = [
15
+ '| `_aw0746-stage12.mjs` | **10 / 0** (2026-09-25, globally installed **v0.7.47 packaged build**) | health gate (version=0.7.47) → admin login/role surface → channel creation with a real `omp` lead → group chat opened → **real omp reply** → omp child process visible in monitoring | `AW_BASE=<base> node scripts/_aw0746-stage12.mjs` |',
16
+ '| `_aw0746-stage34.mjs` | **11 / 0** (2026-09-25, v0.7.47 packaged build + PLC simulator) | line provisioned from the simulator (11 DCW + 14 DAQ) → product/recipe triple → **every sample tagged with recipe_id/run_id** while running → `daq_query` **defaults to the active batch** (`scope=all` opts out, measured 1 vs 4) → Hybrid Twin template instantiated (lead + 3 workers, 6 twin tools injected, 57 total) | `AW_BASE=<base> node scripts/_aw0746-stage34.mjs` |',
17
+ '| `_aw0746-stage6b-train-sweep.mjs` | gates **all green** (2026-09-25, v0.7.46 packaged build, real torch training) | 1 s grid dataset (**1724 rows / 10 runs**) → inline-code job trained for real → G1 one-step **0.0992** (≤0.10) / G2 rollout 0.1092 (≤0.25) / G3 generalisation 14.6% (≤20%) / G4 rows+runs → model registered and **promoted to production** | `AW_BASE=<base> AW_DATASET_ID=<ds> node scripts/_aw0746-stage6b-train-sweep.mjs` |',
18
+ '| `_aw0746-stage7-twin.mjs` | **17 / 0** (2026-09-25, v0.7.47 packaged build, real DAQ + real model) | real samples → TwinSnapshot `fresh=true` → 65 real VirtualTrials (12 safe candidates, `candidateExecuted=false`) → 12-check recommendation gate **write_eligible** → verdict written back to the model as `twinEligibility` → `mpc_optimize` upgrades to **precise_search** → **0 DCW writes** throughout | `AW_BASE=<base> AW_REQUIRE_MODEL=1 node scripts/_aw0746-stage7-twin.mjs` |',
19
+ '| `_aw0746-stage6c-jobsmoke.mjs` | **5 / 0** (2026-09-25, v0.7.47 packaged build) | AML job path: inline `code` accepted (no `workspace/train.py` required) → job reaches a terminal state → training logs/ONNX traces → experiment plus per-check gates persisted | `AW_BASE=<base> node scripts/_aw0746-stage6c-jobsmoke.mjs` |',
20
+ ]
21
+
22
+ for (const [file, anchor, rows] of [
23
+ ['README-zh.md', '| 前端验收 |', zhRows],
24
+ ['README.md', '| Front-end acceptance |', enRows],
25
+ ]) {
26
+ const lines = readFileSync(file, 'utf8').split('\n')
27
+ const i = lines.findIndex(l => l.startsWith(anchor))
28
+ if (i < 0) throw new Error(`anchor not found in ${file}`)
29
+ lines.splice(i + 1, 0, ...rows)
30
+ writeFileSync(file, lines.join('\n'), 'utf8')
31
+ console.log(`inserted ${rows.length} rows after line ${i + 1} in ${file}`)
32
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * 0.7.48 打包系统验收 · 硬约束失败关闭探针(无需 LLM,秒级)
3
+ * 场景:同一份真实快照 + 同一组候选,只改 constraints[].id
4
+ * ① id = weight(可映射)→ 试验正常给出约束结论
5
+ * ② id = part_weight(不可映射)→ 必须**判不通过**并给出"无法映射"明细(v0.7.47 会假通过)
6
+ * 用法:AW_BASE=http://127.0.0.1:3001 [AW_HOME=<home>] node scripts/_aw0748-failclosed-probe.mjs
7
+ */
8
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
9
+
10
+ const BASE = process.env.AW_BASE ?? 'http://127.0.0.1:3001'
11
+ const HOME = process.env.AW_HOME ?? `${process.cwd()}/.aw0746-home`
12
+ const ADMIN = { email: process.env.AW_ADMIN_EMAIL ?? 'admin@awshop.local', password: process.env.AW_ADMIN_PASSWORD ?? 'Awshop@123!' }
13
+
14
+ let pass = 0
15
+ const fails = []
16
+ const check = (name, ok, detail = '') => {
17
+ console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
18
+ if (ok) pass += 1
19
+ else fails.push(name)
20
+ }
21
+ const j = async (method, path, body, token) => {
22
+ const r = await fetch(BASE + path, {
23
+ method,
24
+ headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
25
+ body: body === undefined ? undefined : JSON.stringify(body),
26
+ signal: AbortSignal.timeout(120_000),
27
+ })
28
+ return { status: r.status, ...(await r.json().catch(() => ({}))) }
29
+ }
30
+ const firstJson = (text) => {
31
+ const a = text.indexOf('{')
32
+ const b = text.lastIndexOf('}')
33
+ return a >= 0 && b > a ? JSON.parse(text.slice(a, b + 1)) : null
34
+ }
35
+
36
+ const token = (await j('POST', '/api/users/login', ADMIN)).data?.token
37
+ console.log(`\n═══ 0.7.48 硬约束失败关闭探针 @ ${BASE} ═══`)
38
+
39
+ // 复用最近一次真实快照(阶段 7 / 阶段 5 产物)
40
+ const snapRoot = `${HOME}/aml/twins/snapshots`
41
+ const dirs = existsSync(snapRoot)
42
+ ? readdirSync(snapRoot).map(d => ({ d, m: statSync(`${snapRoot}/${d}`).mtimeMs })).sort((a, b) => b.m - a.m)
43
+ : []
44
+ check('存在真实 TwinSnapshot 工件(取最新)', dirs.length > 0, dirs[0]?.d ?? '(无)')
45
+ if (!dirs.length) {
46
+ console.log('\n★ 无可复用快照,先跑阶段 7/5 生成')
47
+ process.exit(2)
48
+ }
49
+ const snapshot = JSON.parse(readFileSync(`${snapRoot}/${dirs[0].d}/snapshots.json`, 'utf8'))
50
+ check('快照新鲜且水位>0(可直接跑试验)', snapshot.dataQuality?.fresh === true && Number(snapshot.daqWatermark) > 0, `fresh=${snapshot.dataQuality?.fresh} watermark=${snapshot.daqWatermark}`)
51
+
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 lead = members.find(m => m.role === 'lead')
56
+ const invoke = async (tool, args) => (await j('POST', '/api/workshop/agent-tools/invoke', { agentId: lead.id, tool, args }, token)).data?.result ?? {}
57
+
58
+ const baseScene = firstJson(String((await invoke('twin_scene_read', {})).text ?? ''))
59
+ const controls = { hold_pressure: 65, hold_time: 8, melt_temperature_setpoint: 247 }
60
+ const objective = { schemaVersion: 1, createdAt: new Date().toISOString(), createdBy: lead.id, objectiveId: 'weight-quality', targets: { weight: 32.5 }, weights: { weight: 1 }, controlCosts: {}, horizonSteps: 1, trustRegion: {} }
61
+ const trialOf = async (scene) => {
62
+ const res = await invoke('twin_trial_run', { scene_json: scene, snapshot_json: snapshot, baseline_controls: controls, candidate_controls: [{ ...controls, hold_pressure: 67 }], objective })
63
+ const text = String(res.text ?? '')
64
+ const id = (text.match(/trial_id:\s*(\S+)/) ?? [])[1]
65
+ const artifact = id ? JSON.parse(readFileSync(`${HOME}/aml/twins/trials/${id}/trials.json`, 'utf8')) : null
66
+ return { text, trial: artifact?.trial ?? null }
67
+ }
68
+
69
+ // ① 可映射 id:baseline 预测克重 32.5 g 落在 31~34 内 → 应通过
70
+ const mapped = { ...baseScene, lineId: snapshot.lineId, productId: snapshot.productId, recipeId: snapshot.recipeId, constraints: [{ id: 'weight', kind: 'hard_range', min: 31, max: 34 }] }
71
+ const okTrial = await trialOf(mapped)
72
+ const okWeight = okTrial.trial?.constraintResults?.[0]
73
+ check('可映射约束 id(weight)正常评估', Boolean(okTrial.trial) && okWeight?.id === 'weight' && okWeight.passed === true, `weight=${okTrial.trial?.predictedTrajectory?.[0]?.weight?.toFixed?.(3)} detail=${okWeight?.detail}`)
74
+
75
+ // ② 不可映射 id:必须失败关闭(旧版本这里会返回"全轨迹通过")
76
+ const unmapped = { ...mapped, constraints: [{ id: 'part_weight', kind: 'hard_range', min: 31, max: 34 }] }
77
+ const badTrial = await trialOf(unmapped)
78
+ const badCon = badTrial.trial?.constraintResults?.[0]
79
+ const allPassed = (badTrial.trial?.constraintResults ?? []).every(c => c.passed)
80
+ check('不可映射约束 id 判为不通过(失败关闭)', Boolean(badTrial.trial) && badCon?.passed === false && /无法映射/.test(String(badCon?.detail)), `passed=${badCon?.passed} detail=${String(badCon?.detail).slice(0, 80)}`)
81
+ check('候选不再被当作安全(constraints_passed=false)', allPassed === false, `constraints_passed=${allPassed}`)
82
+ check('工具文本同步反映未通过', /constraints_passed:\s*false/.test(badTrial.text), (badTrial.text.match(/constraints_passed:.*/) ?? [''])[0])
83
+
84
+ // ③ 空轨迹(候选为空)同样失败关闭
85
+ const empty = await invoke('twin_trial_run', { scene_json: mapped, snapshot_json: snapshot, baseline_controls: controls, candidate_controls: [], objective })
86
+ const emptyTrialId = (String(empty.text ?? '').match(/trial_id:\s*(\S+)/) ?? [])[1]
87
+ const emptyTrial = emptyTrialId ? JSON.parse(readFileSync(`${HOME}/aml/twins/trials/${emptyTrialId}/trials.json`, 'utf8')).trial : null
88
+ check('空轨迹判为不通过(不再默认通过)', emptyTrial ? emptyTrial.constraintResults.every(c => c.passed === false) : false, `details=${(emptyTrial?.constraintResults ?? []).map(c => c.detail).join(' | ').slice(0, 80)}`)
89
+
90
+ console.log(`\n★ 0.7.48 探针:${pass} 通过 / ${fails.length} 失败${fails.length ? ` (${fails.join('; ')})` : ''}`)
@@ -0,0 +1,22 @@
1
+ /**
2
+ * 临时:detached 拉起 PLC 节点模拟器(API :4010 + Modbus TCP :16040 等六协议)。
3
+ * 用法:node scripts/_plc-sim-detached.cjs
4
+ */
5
+ const { spawn } = require('node:child_process')
6
+ const fs = require('node:fs')
7
+ const path = require('node:path')
8
+
9
+ const repo = path.resolve(__dirname, '..')
10
+ const simDir = path.join(repo, 'plc-node-simulator')
11
+ const logDir = path.join(repo, '.e2e-matrix')
12
+ fs.mkdirSync(logDir, { recursive: true })
13
+ const out = fs.openSync(path.join(logDir, 'plc-sim-0746.out.log'), 'a')
14
+ const tsx = path.join(simDir, 'node_modules', 'tsx', 'dist', 'cli.mjs')
15
+ const p = spawn(process.execPath, [tsx, 'src/server/index.ts'], {
16
+ detached: true,
17
+ stdio: ['ignore', out, out],
18
+ env: { ...process.env, SIM_PORT: process.env.SIM_PORT ?? '4010' },
19
+ cwd: simDir,
20
+ })
21
+ p.unref()
22
+ console.log(`[plc-sim] spawned pid=${p.pid}`)