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.
Files changed (35) 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/1b18aaf2-ccbc-45e0-b9c3-107655b226f9.json +1 -0
  5. package/.output/server/chunks/_/nitro.mjs +833 -748
  6. package/.output/server/package.json +1 -1
  7. package/README-zh.md +2 -2
  8. package/README.md +2 -2
  9. package/config.yml +1 -1
  10. package/docs/aml.md +1 -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 +134 -0
  22. package/scripts/_aw0746-stage5.mjs +106 -0
  23. package/scripts/_aw0746-stage6-aml.mjs +177 -0
  24. package/scripts/_aw0746-stage6b-train-sweep.mjs +142 -0
  25. package/scripts/_aw0746-stage7-twin.mjs +259 -0
  26. package/scripts/_aw0746-train-strong.py +142 -0
  27. package/scripts/_plc-sim-detached.cjs +22 -0
  28. package/scripts/_probe-density.cjs +15 -0
  29. package/scripts/_probe-gate-model.mjs +34 -0
  30. package/scripts/_probe-model-eligibility.cjs +13 -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/repo.ts +9 -4
  35. package/.output/public/_nuxt/builds/meta/7827fe9f-b4c2-461f-b1fb-c0c7da7d66b8.json +0 -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,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}`)
@@ -0,0 +1,15 @@
1
+ /** 临时:探测 daq_samples 采样密度(评估 AML beatMs 可下调空间) */
2
+ const { Client } = require('pg')
3
+ ;(async () => {
4
+ const c = new Client({ host: '127.0.0.1', port: 5432, user: 'postgres', password: 'awshop', database: 'awshop' })
5
+ await c.connect()
6
+ const q = (sql) => c.query(sql).then(r => r.rows)
7
+ const rows = await q(`select node_id, count(*)::int n, min(ts) a, max(ts) b,
8
+ round(extract(epoch from (max(ts)-min(ts)))/greatest(count(*)-1,1),2) avg_gap_s
9
+ from daq_samples where ts > now() - interval '45 minutes'
10
+ group by node_id order by n desc limit 12`)
11
+ console.table(rows)
12
+ const t = await q(`select count(*)::int total, count(distinct node_id)::int nodes from daq_samples where ts > now() - interval '45 minutes'`)
13
+ console.log('total', t)
14
+ await c.end()
15
+ })().catch(e => { console.error(e.message); process.exit(1) })
@@ -0,0 +1,34 @@
1
+ /** 临时:看 twin_gate_evaluate 的 modelUpdate 尾部 + 最近作业来源 */
2
+ import { DatabaseSync } from 'node:sqlite'
3
+ const BASE = 'http://127.0.0.1:3001'
4
+ const ADMIN = { email: 'admin@awshop.local', password: 'Awshop@123!' }
5
+
6
+ const j = async (method, path, body, token) => {
7
+ const r = await fetch(BASE + path, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: body === undefined ? undefined : JSON.stringify(body) })
8
+ return { status: r.status, ...(await r.json().catch(() => ({}))) }
9
+ }
10
+ const token = (await j('POST', '/api/users/login', ADMIN)).data?.token
11
+ const chans = (await j('GET', '/api/workshop/channels', undefined, token)).data ?? []
12
+ const twin = chans.find(c => /闭环优化-hybridtwin/.test(c.name))
13
+ const members = (await j('GET', `/api/workshop/channels/${twin.id}/agents`, undefined, token)).data ?? []
14
+ const lead = members.find(m => m.role === 'lead')
15
+ const models = ((await j('GET', '/api/workshop/aml/models', undefined, token)).data?.models ?? [])
16
+ const prod = models.find(m => m.stage === 'production')
17
+ console.log('production model =', prod?.id, 'ds =', prod?.datasetId)
18
+
19
+ const res = await j('POST', '/api/workshop/agent-tools/invoke', {
20
+ agentId: lead.id,
21
+ tool: 'twin_gate_evaluate',
22
+ args: { rows: 1724, runs: 10, one_step_nrmse: 0.0763, rollout_nrmse: 0.0964, val_test_gap: 0.1072, calibration_rows: 1724, coverage: 1, candidate_trials: [], physics_failure_rate: 0, model_id: prod?.id ?? '' },
23
+ }, token)
24
+ const text = String(res.data?.result?.text ?? '')
25
+ const tail = text.slice(-700)
26
+ console.log('--- gate tail ---')
27
+ console.log(tail)
28
+ console.log('--- parsed keys ---', Object.keys(JSON.parse(text)).join(','))
29
+
30
+ const db = new DatabaseSync('D:/codes/ABO/AgentWorkShop/.aw0746-home/data/workshop.sqlite', { readOnly: true })
31
+ const cols = db.prepare('pragma table_info(aml_jobs)').all().map(c => c.name)
32
+ console.log('\naml_jobs columns:', cols.join(','))
33
+ const pick = ['id', 'status', 'kind', 'created_by', 'change_note', 'created_at'].filter(c => cols.includes(c))
34
+ for (const r of db.prepare(`select ${pick.join(',')} from aml_jobs order by created_at desc limit 6`).all()) console.log(' ', JSON.stringify(r).slice(0, 220))
@@ -0,0 +1,13 @@
1
+ /** 临时:检查 AML 模型 metrics_json 里的 twinEligibility 是否回写 */
2
+ const { DatabaseSync } = require('node:sqlite')
3
+ const db = new DatabaseSync('D:/codes/ABO/AgentWorkShop/.aw0746-home/data/workshop.sqlite', { readOnly: true })
4
+ const rows = db.prepare('select id, stage, dataset_id, experiment_id, metrics_json from aml_models order by created_at desc limit 4').all()
5
+ for (const r of rows) {
6
+ let m = {}
7
+ try { m = JSON.parse(r.metrics_json || '{}') } catch { m = { parse: 'fail' } }
8
+ console.log(`${r.id} stage=${r.stage} ds=${r.dataset_id} exp=${r.experiment_id}`)
9
+ console.log(` twinEligibility=${JSON.stringify(m.twinEligibility ?? null)} oneStep=${m.oneStepTest?.nrmse}`)
10
+ }
11
+ const jobs = db.prepare('select id, status, created_by_kind, created_by, substr(change_note,1,60) note, created_at from aml_jobs order by created_at desc limit 6').all()
12
+ console.log('\njobs:')
13
+ for (const j of jobs) console.log(` ${j.id} ${j.status} by=${j.created_by_kind}/${j.created_by} ${j.created_at} ${j.note}`)
@@ -10,7 +10,9 @@ import { defaultInjectionScene, InjectionGreyboxProvider, smallStepCandidates }
10
10
  import { createTwinSnapshot } from '../../aml/twin/snapshot-service'
11
11
  import { evaluateHybridGates, DEFAULT_ACCEPTANCE_PROFILE } from '../../aml/twin/acceptance'
12
12
  import { issueRecommendationCertificate, runVirtualTrial } from '../../aml/twin/trial-service'
13
- import { sha256 } from '../../aml/twin/contracts'
13
+ import { parseSceneContract, sha256, type SceneContract } from '../../aml/twin/contracts'
14
+ import { getDaqController } from '../../daq/daq-controller'
15
+ import { getAgentNodeBindingRepo } from '../node-bindings.repo'
14
16
  import type { HostToolResult } from '../host-tool-bridge/types'
15
17
  import { recordOps } from '../../ops/ops'
16
18
  import { createTwinRepo } from '../../aml/twin/repo'
@@ -46,17 +48,46 @@ async function persistTwinArtifact(kind: string, id: string, payload: unknown):
46
48
  }
47
49
 
48
50
  export async function toolTwinSceneRead(_agentId: string, args: Record<string, unknown>): Promise<HostToolResult> {
49
- const scene = defaultInjectionScene('twin-scene-tool')
51
+ const defaultScene = defaultInjectionScene('twin-scene-tool')
52
+ const supplied = jsonArg<Record<string, unknown>>(args, 'scene_json')
53
+ const scene = supplied ? parseSceneContract(supplied) : defaultScene
50
54
  const sceneId = String(args.scene_id ?? scene.sceneId)
51
- if (sceneId !== scene.sceneId) return fail(`当前 MVP 仅内置可执行场景 ${scene.sceneId};未知 scene_id=${sceneId} 请先注册 PhysicsModelProvider。`)
52
- return ok(`场景契约 ${scene.sceneId}@${scene.sceneVersion}\n${JSON.stringify(scene, null, 2)}\n\n控制策略:recommendation-only;模型门禁未通过时只能 safe_small_step,禁止直接 DCW。`)
55
+ if (sceneId !== scene.sceneId) return fail(`场景契约未提供: scene_id=${sceneId};请通过 scene_json 注册该场景,或先注册 PhysicsModelProvider。`)
56
+ return ok(`场景契约 ${scene.sceneId}@${scene.sceneVersion}\n${JSON.stringify(scene, null, 2)}\n\n控制策略:recommendation-only;模型门禁未通过时只能 safe_small_step,禁止直接 DCW。\n快照前置:scene 的 observations/states 必须带 nodeId,且与 Agent 已绑定的真实 DAQ 节点一致;内置默认场景不带 nodeId,直接调用只会在 TwinSnapshot 里得到 fresh=false 并在 VirtualTrial 抛 SNAPSHOT_STALE —— 请用 scene_json 注入带 nodeId 的场景契约。`)
57
+ }
58
+
59
+ async function autoDaqSamples(agentId: string, scene: SceneContract, nowMs: number, freshnessMaxMs: number): Promise<Array<{ nodeId: string, at: number, value: number, sequence?: string }>> {
60
+ const required = [...scene.observations, ...scene.states]
61
+ .map(item => item.nodeId)
62
+ .filter((nodeId): nodeId is string => Boolean(nodeId))
63
+ const daqBindings = new Set(getAgentNodeBindingRepo().byAgent(agentId).filter(binding => binding.kind === 'daq').map(binding => binding.nodeId))
64
+ const missingBindings = required.filter(nodeId => !daqBindings.has(nodeId))
65
+ if (missingBindings.length > 0) throw new Error(`AUTO_DAQ_UNBOUND:${missingBindings.join(',')}`)
66
+ const samples: Array<{ nodeId: string, at: number, value: number, sequence?: string }> = []
67
+ for (const nodeId of required) {
68
+ const points = await getDaqController().samples(nodeId, { fromMs: nowMs - freshnessMaxMs, toMs: nowMs, bucketMs: 1000, limit: 64 }) as Array<{ at?: number, value?: number, avg?: number }>
69
+ const latest = points
70
+ .map(point => ({ at: Number(point.at), value: Number(point.value ?? point.avg) }))
71
+ .filter(point => Number.isFinite(point.at) && Number.isFinite(point.value))
72
+ .sort((a, b) => a.at - b.at)
73
+ .at(-1)
74
+ if (!latest) throw new Error(`AUTO_DAQ_SAMPLE_MISSING:${nodeId}`)
75
+ samples.push({ nodeId, at: latest.at, value: latest.value, sequence: `daq:${nodeId}:${latest.at}` })
76
+ }
77
+ return samples
53
78
  }
54
79
 
55
80
  export async function toolTwinSnapshotCreate(agentId: string, args: Record<string, unknown>): Promise<HostToolResult> {
56
81
  try {
57
- const scene = (jsonArg(args, 'scene_json') ?? defaultInjectionScene('twin-snapshot-tool')) as ReturnType<typeof defaultInjectionScene>
82
+ const scene = parseSceneContract(jsonArg(args, 'scene_json') ?? defaultInjectionScene('twin-snapshot-tool'))
58
83
  const twinRepo = createTwinRepo(getAmlRuntime().db)
59
84
  twinRepo.upsertScene(scene, agentId)
85
+ const nowMs = Number(args.now_ms) || Date.now()
86
+ const freshnessMaxMs = Number(args.freshness_max_ms) || 60_000
87
+ const suppliedSamples = jsonArg<Array<{ nodeId: string, at: number, value: number, sequence?: string }>>(args, 'samples', []) ?? []
88
+ const samples = args.auto_daq === true || suppliedSamples.length === 0
89
+ ? await autoDaqSamples(agentId, scene, nowMs, freshnessMaxMs)
90
+ : suppliedSamples
60
91
  const snapshot = createTwinSnapshot({
61
92
  scene,
62
93
  channelId: String(args.channel_id ?? ''),
@@ -65,14 +96,24 @@ export async function toolTwinSnapshotCreate(agentId: string, args: Record<strin
65
96
  controls: jsonArg<Record<string, number>>(args, 'controls', {}) ?? {},
66
97
  states: jsonArg<Record<string, number>>(args, 'states', {}) ?? {},
67
98
  disturbances: jsonArg<Record<string, number>>(args, 'disturbances', {}) ?? {},
68
- samples: jsonArg<Array<{ nodeId: string, at: number, value: number, sequence?: string }>>(args, 'samples', []) ?? [],
69
- nowMs: Number(args.now_ms) || Date.now(),
70
- freshnessMaxMs: Number(args.freshness_max_ms) || 60_000,
99
+ samples,
100
+ nowMs,
101
+ freshnessMaxMs,
71
102
  })
72
103
  twinRepo.insertSnapshot(snapshot)
73
104
  const path = await persistTwinArtifact('snapshots', snapshot.snapshotId, snapshot)
74
105
  recordOps({ actor: agentId, actorName: agentBadgeLabel(agentId), actorKind: 'agent', action: 'aml.twin.snapshot_create', kind: 'aml' as 'system', summary: `创建 TwinSnapshot ${snapshot.snapshotId}`, targetKind: 'aml_twin_snapshot', targetId: snapshot.snapshotId, lineId: scene.lineId, productId: scene.productId, recipeId: scene.recipeId })
75
- return ok(`TwinSnapshot 已创建\n snapshot_id: ${snapshot.snapshotId}\n hash: ${snapshot.snapshotHash}\n fresh: ${snapshot.dataQuality.fresh}\n completeness: ${snapshot.dataQuality.completeness}\n artifact: ${path}`)
106
+ const quality = snapshot.dataQuality
107
+ // 全部必需节点都没有样本(watermark=0)时不是"数据不新鲜",而是场景映射/绑定配置错了:
108
+ // 旧行为只回 fresh=false,调用方要到 VirtualTrial 才看到 SNAPSHOT_STALE(实测踩过)。
109
+ const requiredCount = [...scene.observations, ...scene.states].filter(v => v.nodeId).length
110
+ if (snapshot.daqWatermark <= 0) {
111
+ const missing = requiredCount === 0
112
+ ? '场景 observations/states 没有任何 nodeId(内置默认场景即如此)'
113
+ : `以下节点没有可用样本: ${quality.staleNodeIds.join(', ')}`
114
+ return fail(`TwinSnapshot 创建失败:无可用 DAQ 样本(watermark=0)。${missing}。请用 scene_json 把场景观测/状态映射到已绑定的真实 DAQ 节点,并确认产线在跑。\n snapshot_id: ${snapshot.snapshotId}\n artifact: ${path}`)
115
+ }
116
+ return ok(`TwinSnapshot 已创建\n source: ${args.auto_daq === true || suppliedSamples.length === 0 ? 'bound DAQ' : 'caller samples (legacy/test)'}\n snapshot_id: ${snapshot.snapshotId}\n hash: ${snapshot.snapshotHash}\n fresh: ${quality.fresh}\n completeness: ${quality.completeness}\n stale_nodes: ${quality.staleNodeIds.length ? quality.staleNodeIds.join(', ') : '(none)'}\n artifact: ${path}`)
76
117
  }
77
118
  catch (err) {
78
119
  return fail(`TwinSnapshot 创建失败:${err instanceof Error ? err.message : String(err)}`)
@@ -84,6 +125,13 @@ export async function toolTwinTrialRun(agentId: string, args: Record<string, unk
84
125
  const scene = (jsonArg(args, 'scene_json') ?? defaultInjectionScene('twin-trial-tool')) as ReturnType<typeof defaultInjectionScene>
85
126
  const snapshot = jsonArg<Record<string, unknown>>(args, 'snapshot_json') as never
86
127
  if (!snapshot) return fail('snapshot_json 必填;必须使用当前 DAQ 生成的 TwinSnapshot,禁止 Agent 伪造执行状态。')
128
+ // 形状校验:传半截 JSON / 工具结果信封 / 纯字符串时旧行为是原生 TypeError
129
+ // (Cannot read properties of undefined (reading 'fresh')),调用方看不出该怎么修。
130
+ const snap = snapshot as { dataQuality?: { fresh?: boolean }, snapshotHash?: string, snapshotId?: string }
131
+ if (!snap.dataQuality || typeof snap.dataQuality.fresh !== 'boolean' || !snap.snapshotId) {
132
+ return fail('snapshot_json 不是 TwinSnapshot 工件:缺少 snapshotId/dataQuality。请先调用 twin_snapshot_create,并把其 artifact 文件的 JSON 原文整段回传(不要传工具结果文本或截断片段)。')
133
+ }
134
+ if (!snap.snapshotHash) return fail('snapshot_json 缺少 snapshotHash(快照被篡改或截断),拒绝执行 VirtualTrial。')
87
135
  const provider = new InjectionGreyboxProvider()
88
136
  const baseline = jsonArg<Record<string, number>>(args, 'baseline_controls', {}) ?? {}
89
137
  const candidate = jsonArg<Array<Record<string, number>>>(args, 'candidate_controls', []) ?? []
@@ -147,7 +195,34 @@ export async function toolTwinGateEvaluate(_agentId: string, args: Record<string
147
195
  const result = evaluateHybridGates({
148
196
  rows: Number(args.rows ?? 0), runs: Number(args.runs ?? 0), oneStepTestNrmse: Number(args.one_step_nrmse ?? 1), rolloutTestNrmse: Number(args.rollout_nrmse ?? 1), valTestGap: Number(args.val_test_gap ?? 1), calibrationRows: Number(args.calibration_rows ?? 0), calibrationCoverage: Number(args.coverage ?? 0), candidateTrials: jsonArg(args, 'candidate_trials', []) as never, physicsSolverFailureRate: Number(args.physics_failure_rate ?? 1),
149
197
  }, DEFAULT_ACCEPTANCE_PROFILE)
150
- return ok(JSON.stringify(result, null, 2))
198
+ const modelId = String(args.model_id ?? '').trim()
199
+ let modelUpdate = ''
200
+ if (modelId) {
201
+ const rt = getAmlRuntime()
202
+ const model = rt.repo.model.get(modelId)
203
+ if (model) {
204
+ let metrics: Record<string, unknown> = {}
205
+ try {
206
+ metrics = JSON.parse(model.metricsJson || '{}') as Record<string, unknown>
207
+ }
208
+ catch {
209
+ metrics = {}
210
+ }
211
+ const twinEligibility = {
212
+ gatePassed: result.passed,
213
+ recommendationEligible: result.passed,
214
+ uqPassed: result.checks.filter(c => c.id === 'G7_UQ_OOD').every(c => c.passed),
215
+ oodPassed: result.checks.filter(c => c.id === 'G7_UQ_OOD').every(c => c.passed),
216
+ physicsPassed: result.checks.filter(c => c.id === 'G5_PHYSICS').every(c => c.passed),
217
+ evaluatedAt: new Date().toISOString(),
218
+ }
219
+ metrics.twinEligibility = twinEligibility
220
+ rt.db.prepare('UPDATE aml_models SET metrics_json = ? WHERE id = ?').run(JSON.stringify(metrics), modelId)
221
+ modelUpdate = `\nmodel_id: ${modelId}\nmodel_twin_eligibility: ${JSON.stringify(twinEligibility)}`
222
+ }
223
+ else modelUpdate = `\nmodel_id: ${modelId}\nmodel_twin_eligibility: MODEL_NOT_FOUND`
224
+ }
225
+ return ok(JSON.stringify({ ...result, modelUpdate }, null, 2))
151
226
  }
152
227
  catch (err) {
153
228
  return fail(`Twin Gate 评估失败:${err instanceof Error ? err.message : String(err)}`)
@@ -1,124 +1,140 @@
1
- /**
2
- * 启动单个作业(环境探测 / 参数组装 / 分支到 stub 或真实进程)
3
- * (由 server/services/workshop/aml/job-orchestrator.ts 按职责拆出;内容逐行原文搬运)
4
- */
5
- import type { AmlJobRow } from '../aml.repo'
6
- import type { RunningJob } from './shared'
7
- import { concludeJob, runStub } from './stages'
8
- import { ensureVenv, platformPythonDir } from '../python-runtime'
9
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
10
- import { experimentFail, failPermanentOrRetry, finishRun, lastErrLine, safeParse } from './failure'
11
- import { getAmlRuntime } from '../runtime'
12
- import { hashDatasetDir } from '../dataset-builder'
13
- import { join } from 'node:path'
14
- import { runPython } from './process'
15
- import { state } from './shared'
16
- import { wsStage } from './broadcast'
17
-
18
- export async function startJob(row: AmlJobRow): Promise<void> {
19
- const rt = getAmlRuntime()
20
- const st = state()
21
- const dataset = rt.repo.dataset.get(row.datasetId)
22
- if (!dataset) {
23
- rt.repo.job.fail(row.id, 'failed', '数据集已被删除(永久错误)', new Date().toISOString())
24
- return
25
- }
26
- // 快照完整性校验(防篡改;sha 失配 = 永久错误不重试)
27
- const actualSha = hashDatasetDir(dataset.path, dataset.specJson)
28
- if (actualSha !== dataset.sha256) {
29
- rt.repo.job.fail(row.id, 'failed', `数据集快照校验失败(sha256 不匹配,疑似被改动或损坏):${dataset.id}(永久错误)`, new Date().toISOString())
30
- return
31
- }
32
- const jobDir = join(rt.jobsDir, row.id)
33
- const workspace = join(jobDir, 'workspace')
34
- const artifacts = join(jobDir, 'artifacts')
35
- mkdirSync(workspace, { recursive: true })
36
- mkdirSync(artifacts, { recursive: true })
37
- let trainFile = existsSync(join(workspace, 'train.py')) ? 'train.py' : null
38
- if (!trainFile) {
39
- // REST 内联代码路径:预算袋携带 inlineCode 时落盘(≤500KB,提交入口已限)
40
- const inline = safeParse(row.budget).inlineCode
41
- if (typeof inline === 'string' && inline.length > 0 && inline.length <= 512_000) {
42
- writeFileSync(join(workspace, 'train.py'), inline)
43
- trainFile = 'train.py'
44
- }
45
- else {
46
- rt.repo.job.fail(row.id, 'failed', 'workspace/train.py 不存在(Agent 用 aml_job_submit 携带 code 提交,或 REST 提交时带 code)', new Date().toISOString())
47
- return
48
- }
49
- }
50
-
51
- writeFileSync(join(jobDir, 'job.json'), JSON.stringify({
52
- jobId: row.id,
53
- datasetPath: dataset.path,
54
- workspaceDir: workspace,
55
- params: safeParse(row.budget).params ?? {},
56
- seed: safeParse(row.budget).seed ?? 42,
57
- trainFile,
58
- }, null, 2))
59
- const run: RunningJob = {
60
- row,
61
- jobDir,
62
- child: null,
63
- startedAt: Date.now(),
64
- lastProtocolAt: Date.now(),
65
- lastWsAt: 0,
66
- logLines: [],
67
- protocolBadLines: 0,
68
- killed: false,
69
- }
70
- st.running.set(row.id, run)
71
- rt.repo.job.markStarted(row.id, 'provisioning', 'provision', new Date().toISOString())
72
- wsStage(row, 'provision')
73
-
74
- try {
75
- if (process.env.AML_STUB === '1') {
76
- await runStub(run, artifacts)
77
- return
78
- }
79
- const vpy = await ensureVenv(rt)
80
- if (run.killed) return
81
- rt.repo.job.setState(row.id, 'training', 'train', 5)
82
- wsStage(row, 'train')
83
- // 第一拍:Agent 训练代码(独立进程;崩溃不伤评估器)
84
- const exit1 = await runPython(run, vpy, [join(workspace, trainFile)], workspace)
85
- if (run.killed) return
86
- if (exit1.code !== 0) {
87
- failPermanentOrRetry(run, `训练进程退出(${exit1.code}):${lastErrLine(exit1.errTail)}`, exit1.code)
88
- return
89
- }
90
- if (!existsSync(join(artifacts, 'model.onnx'))) {
91
- rt.repo.job.fail(row.id, 'failed', '训练未产出 artifacts/model.onnx(契约缺失,永久错误不重试)', new Date().toISOString())
92
- experimentFail(run, '契约缺失:model.onnx 未产出')
93
- finishRun(run)
94
- return
95
- }
96
- // 第二拍:平台权威评估
97
- rt.repo.job.setState(row.id, 'evaluating', 'evaluate', 80)
98
- wsStage(row, 'evaluate')
99
- const evalScript = join(platformPythonDir(), 'aml_eval.py')
100
- const exit2 = await runPython(run, vpy, [evalScript, '--job', join(jobDir, 'job.json')], jobDir)
101
- if (run.killed) return
102
- if (exit2.code !== 0) {
103
- rt.repo.job.fail(row.id, 'failed', `评估失败:${lastErrLine(exit2.errTail)}(永久错误不重试)`, new Date().toISOString())
104
- experimentFail(run, `评估失败:${lastErrLine(exit2.errTail)}`)
105
- finishRun(run)
106
- return
107
- }
108
- concludeJob(run, artifacts)
109
- }
110
- catch (err) {
111
- if (!run.killed) {
112
- const msg = err instanceof Error ? err.message : String(err)
113
- const permanent = String((err as { code?: string })?.code ?? '').startsWith('AML_')
114
- if (permanent) {
115
- rt.repo.job.fail(row.id, 'failed', msg, new Date().toISOString())
116
- experimentFail(run, msg)
117
- finishRun(run)
118
- }
119
- else {
120
- failPermanentOrRetry(run, msg, 1)
121
- }
122
- }
123
- }
124
- }
1
+ /**
2
+ * 启动单个作业(环境探测 / 参数组装 / 分支到 stub 或真实进程)
3
+ * (由 server/services/workshop/aml/job-orchestrator.ts 按职责拆出;内容逐行原文搬运)
4
+ */
5
+ import type { AmlJobRow } from '../aml.repo'
6
+ import type { RunningJob } from './shared'
7
+ import { concludeJob, runStub } from './stages'
8
+ import { ensureVenv, platformPythonDir } from '../python-runtime'
9
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
10
+ import { experimentFail, failPermanentOrRetry, finishRun, lastErrLine, safeParse } from './failure'
11
+ import { getAmlRuntime } from '../runtime'
12
+ import { hashDatasetDir } from '../dataset-builder'
13
+ import { join } from 'node:path'
14
+ import { runPython } from './process'
15
+ import { state } from './shared'
16
+ import { wsStage } from './broadcast'
17
+
18
+ export async function startJob(row: AmlJobRow): Promise<void> {
19
+ const rt = getAmlRuntime()
20
+ const st = state()
21
+ const dataset = rt.repo.dataset.get(row.datasetId)
22
+ if (!dataset) {
23
+ rt.repo.job.fail(row.id, 'failed', '数据集已被删除(永久错误)', new Date().toISOString())
24
+ return
25
+ }
26
+ // 快照完整性校验(防篡改;sha 失配 = 永久错误不重试)
27
+ const actualSha = hashDatasetDir(dataset.path, dataset.specJson)
28
+ if (actualSha !== dataset.sha256) {
29
+ rt.repo.job.fail(row.id, 'failed', `数据集快照校验失败(sha256 不匹配,疑似被改动或损坏):${dataset.id}(永久错误)`, new Date().toISOString())
30
+ return
31
+ }
32
+ const jobDir = join(rt.jobsDir, row.id)
33
+ const workspace = join(jobDir, 'workspace')
34
+ const artifacts = join(jobDir, 'artifacts')
35
+ mkdirSync(workspace, { recursive: true })
36
+ mkdirSync(artifacts, { recursive: true })
37
+ let trainFile = existsSync(join(workspace, 'train.py')) ? 'train.py' : null
38
+ if (!trainFile) {
39
+ // REST 内联代码路径:预算袋携带 inlineCode 时落盘(≤500KB,提交入口已限)
40
+ const inline = safeParse(row.budget).inlineCode
41
+ if (typeof inline === 'string' && inline.length > 0 && inline.length <= 512_000) {
42
+ writeFileSync(join(workspace, 'train.py'), inline)
43
+ trainFile = 'train.py'
44
+ }
45
+ else {
46
+ rt.repo.job.fail(row.id, 'failed', 'workspace/train.py 不存在(Agent 用 aml_job_submit 携带 code 提交,或 REST 提交时带 code)', new Date().toISOString())
47
+ return
48
+ }
49
+ }
50
+
51
+ const budget = safeParse(row.budget) as Record<string, unknown>
52
+ writeFileSync(join(jobDir, 'job.json'), JSON.stringify({
53
+ jobId: row.id,
54
+ datasetPath: dataset.path,
55
+ workspaceDir: workspace,
56
+ params: budget.params ?? {},
57
+ seed: budget.seed ?? 42,
58
+ trainFile,
59
+ sceneId: budget.sceneId ?? null,
60
+ sceneVersion: budget.sceneVersion ?? null,
61
+ objectiveId: budget.objectiveId ?? null,
62
+ jobKind: budget.jobKind ?? 'supervised',
63
+ }, null, 2))
64
+ // Hybrid Twin lineage is immutable job input. Persist these sidecars in the
65
+ // isolated workspace so amlkit.load_* can consume them without trusting REST
66
+ // callers or reaching into the live database during training.
67
+ for (const [name, key] of [
68
+ ['physics_manifest.json', 'physicsManifest'],
69
+ ['twin_snapshot.json', 'twinSnapshot'],
70
+ ['objective_profile.json', 'objectiveProfile'],
71
+ ] as const) {
72
+ const value = budget[key]
73
+ if (value && typeof value === 'object') writeFileSync(join(workspace, name), JSON.stringify(value, null, 2))
74
+ }
75
+ const run: RunningJob = {
76
+ row,
77
+ jobDir,
78
+ child: null,
79
+ startedAt: Date.now(),
80
+ lastProtocolAt: Date.now(),
81
+ lastWsAt: 0,
82
+ logLines: [],
83
+ protocolBadLines: 0,
84
+ killed: false,
85
+ }
86
+ st.running.set(row.id, run)
87
+ rt.repo.job.markStarted(row.id, 'provisioning', 'provision', new Date().toISOString())
88
+ wsStage(row, 'provision')
89
+
90
+ try {
91
+ if (process.env.AML_STUB === '1') {
92
+ await runStub(run, artifacts)
93
+ return
94
+ }
95
+ const vpy = await ensureVenv(rt)
96
+ if (run.killed) return
97
+ rt.repo.job.setState(row.id, 'training', 'train', 5)
98
+ wsStage(row, 'train')
99
+ // 第一拍:Agent 训练代码(独立进程;崩溃不伤评估器)
100
+ const exit1 = await runPython(run, vpy, [join(workspace, trainFile)], workspace)
101
+ if (run.killed) return
102
+ if (exit1.code !== 0) {
103
+ failPermanentOrRetry(run, `训练进程退出(${exit1.code}):${lastErrLine(exit1.errTail)}`, exit1.code)
104
+ return
105
+ }
106
+ if (!existsSync(join(artifacts, 'model.onnx'))) {
107
+ rt.repo.job.fail(row.id, 'failed', '训练未产出 artifacts/model.onnx(契约缺失,永久错误不重试)', new Date().toISOString())
108
+ experimentFail(run, '契约缺失:model.onnx 未产出')
109
+ finishRun(run)
110
+ return
111
+ }
112
+ // 第二拍:平台权威评估
113
+ rt.repo.job.setState(row.id, 'evaluating', 'evaluate', 80)
114
+ wsStage(row, 'evaluate')
115
+ const evalScript = join(platformPythonDir(), 'aml_eval.py')
116
+ const exit2 = await runPython(run, vpy, [evalScript, '--job', join(jobDir, 'job.json')], jobDir)
117
+ if (run.killed) return
118
+ if (exit2.code !== 0) {
119
+ rt.repo.job.fail(row.id, 'failed', `评估失败:${lastErrLine(exit2.errTail)}(永久错误不重试)`, new Date().toISOString())
120
+ experimentFail(run, `评估失败:${lastErrLine(exit2.errTail)}`)
121
+ finishRun(run)
122
+ return
123
+ }
124
+ concludeJob(run, artifacts)
125
+ }
126
+ catch (err) {
127
+ if (!run.killed) {
128
+ const msg = err instanceof Error ? err.message : String(err)
129
+ const permanent = String((err as { code?: string })?.code ?? '').startsWith('AML_')
130
+ if (permanent) {
131
+ rt.repo.job.fail(row.id, 'failed', msg, new Date().toISOString())
132
+ experimentFail(run, msg)
133
+ finishRun(run)
134
+ }
135
+ else {
136
+ failPermanentOrRetry(run, msg, 1)
137
+ }
138
+ }
139
+ }
140
+ }