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,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
|
|
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(
|
|
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'))
|
|
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
|
|
69
|
-
nowMs
|
|
70
|
-
freshnessMaxMs
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
rt.repo.job.setState(row.id, '
|
|
98
|
-
wsStage(row, '
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
if (run.killed) return
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
+
}
|