From 1bfa240cb09f00715f1b1186512c3d636d65a8bc Mon Sep 17 00:00:00 2001 From: Asfmq <2696428814@qq.com> Date: Fri, 31 Jul 2026 01:34:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(all):=20=E6=BA=90=E7=B2=BE=E5=BA=A6?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E4=BD=93=E7=B3=BB=E3=80=81=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E5=8F=AF=E8=A7=82=E6=B5=8B=E5=8F=B0=E3=80=81=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E5=81=9C=E7=94=A8=E7=AE=A1=E7=90=86=E4=B8=8E=E7=99=BD?= =?UTF-8?q?=E5=90=8D=E5=8D=95=E5=BD=92=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心变更: 1. GridAxisValue 源精度命名 - 新增 GridAxisValue 类型,携带 f64 数值 + YAML 源书写文本(Deref 透明兼容算术) - config.rs 绕过 serde_yaml 归一化,逐 token 捕获轴值原文(logg: 5.0 → g5.0) - runner/executor/scheduler 全链路改用 DB TEXT 列权威 point_name, 修复 REAL 列回读丢精度导致的 model_name 错配 2. 工作流执行可观测台 - 新增 stats/progress/points 三组 API(进度时间序列、经验速率 ETA、 停滞预警、逐点明细分页、收敛性热力图数据) - 新增 workflow_progress_snapshots 表 + tasks/grid_points 耗时列 - runner 携带 last_iter/worst_depth/n_depths 进 conv.json - 前端新增 hash 路由、工作流详情页(概览/网格点/收敛分析三 Tab)、YAML 编辑器 3. 节点停用/启用管理 - 新增 disabled 状态 + disable/enable API;停用节点保持心跳但停止分发, worker 空闲待命而非退出;移除 revoke API,token 失效统一走重发覆盖; 移除 host_name 字段 4. 白名单结果归档 - 新增 result_filter 模块,只归档有语义产物,丢弃 Tlusty 中间单元(~2MB/模型) - executor 原子写入归档 + 200 点 LRU 上限 5. 历史数据导入 - sync_seeds 重写为 import_results:经 /admin/import_seed 标记 converged + 按新版命名迁移产物树 6. 部署与目录重规划 - data/results→seeds、data/archive→result + migrate_data_dirs.sh - deploy.sh 增强(SSH 复用、Profile、远程 env);Dockerfile 瘦身 7. 文档同步更新 api/database/architecture/deployment --- .env.example | 9 +- .gitignore | 8 +- .omc/project-memory.json | 207 ++ .../865f1115-942b-4cc4-81f6-17a611773eb2.json | 8 + Cargo.lock | 66 +- Cargo.toml | 2 +- Dockerfile.node | 18 +- Dockerfile.server | 15 +- README.md | 36 +- crates/common/Cargo.toml | 2 +- crates/common/src/config.rs | 209 +- crates/common/src/gen_input5.rs | 28 +- crates/common/src/lib.rs | 1 + crates/common/src/models.rs | 433 +++- crates/common/src/result_filter.rs | 199 ++ crates/common/src/runner.rs | 112 +- crates/common/src/seed_finder.rs | 12 +- crates/mq/src/sqlite_queue.rs | 139 +- crates/node/Cargo.toml | 3 +- crates/node/src/executor.rs | 362 ++++ crates/node/src/reporter.rs | 6 +- crates/node/src/worker.rs | 108 +- crates/server/src/api/admin.rs | 109 +- crates/server/src/api/mod.rs | 34 +- crates/server/src/api/node.rs | 7 +- crates/server/src/api/seed.rs | 2 +- crates/server/src/api/status.rs | 3 +- crates/server/src/api/task.rs | 194 +- crates/server/src/api/workflow.rs | 350 +++- crates/server/src/db.rs | 1128 +++++++++-- crates/server/src/main.rs | 77 +- crates/server/src/scheduler.rs | 117 +- crates/server/tests/api_tests.rs | 1803 +++++++++++++++-- crates/server/tests/wf_migration_isolation.rs | 199 ++ dashboard/__tab_test.mjs | 91 + dashboard/index.html | 123 +- dashboard/src/api.js | 79 +- dashboard/src/components/modal.js | 88 +- dashboard/src/components/nodesTable.js | 238 ++- dashboard/src/components/toast.js | 35 +- dashboard/src/components/wfActions.js | 78 + dashboard/src/components/workflows.js | 129 +- dashboard/src/components/yamlEditor.js | 303 +++ dashboard/src/main.js | 342 +--- dashboard/src/router.js | 44 + dashboard/src/state.js | 46 +- dashboard/src/style.css | 1180 ++++++++++- dashboard/src/utils.js | 16 + dashboard/src/views/home.js | 176 ++ dashboard/src/views/workflowDetail.js | 1085 ++++++++++ dashboard/vite.config.js | 2 +- deploy.env.d/node.env.example | 30 + deploy.env.d/server.env.example | 24 + docker-compose.yml | 29 +- docs/api.md | 342 +++- docs/architecture.md | 80 +- docs/contributing.md | 2 +- docs/database.md | 93 +- docs/deployment.md | 57 +- docs/troubleshooting.md | 4 +- docs/workflow_detail_design.md | 388 ++++ hosts.ini | 18 + package-lock.json | 513 +++++ package.json | 5 + scripts/deploy.sh | 600 +++++- scripts/migrate_data_dirs.sh | 107 + scripts/push_import_results.sh | 273 +++ .../{sync_seeds => import_results}/Cargo.toml | 4 +- tools/import_results/README.md | 109 + tools/import_results/src/main.rs | 958 +++++++++ tools/sync_seeds/README.md | 27 - tools/sync_seeds/src/main.rs | 210 -- workflows/sdB_cno.yaml | 6 +- 73 files changed, 12332 insertions(+), 1608 deletions(-) create mode 100644 .omc/project-memory.json create mode 100644 .omc/sessions/865f1115-942b-4cc4-81f6-17a611773eb2.json create mode 100644 crates/common/src/result_filter.rs create mode 100644 crates/server/tests/wf_migration_isolation.rs create mode 100644 dashboard/__tab_test.mjs create mode 100644 dashboard/src/components/wfActions.js create mode 100644 dashboard/src/components/yamlEditor.js create mode 100644 dashboard/src/router.js create mode 100644 dashboard/src/utils.js create mode 100644 dashboard/src/views/home.js create mode 100644 dashboard/src/views/workflowDetail.js create mode 100644 deploy.env.d/node.env.example create mode 100644 deploy.env.d/server.env.example create mode 100644 docs/workflow_detail_design.md create mode 100644 hosts.ini create mode 100644 package-lock.json create mode 100644 package.json create mode 100755 scripts/migrate_data_dirs.sh create mode 100755 scripts/push_import_results.sh rename tools/{sync_seeds => import_results}/Cargo.toml (87%) create mode 100644 tools/import_results/README.md create mode 100644 tools/import_results/src/main.rs delete mode 100644 tools/sync_seeds/README.md delete mode 100644 tools/sync_seeds/src/main.rs diff --git a/.env.example b/.env.example index 0e1f45f..a34d50a 100644 --- a/.env.example +++ b/.env.example @@ -38,8 +38,8 @@ DCTS_PORT=8090 # 任务队列数据库文件路径 (MQ SQLite 数据库) # DCTS_QUEUE_DB_PATH=data/dcts_queue.db -# 网格模型计算结果文件保存根目录 -# DCTS_RESULTS_DIR=data/results +# server 端种子库目录(node 上报/导入的收敛种子 .7+conv.json 落盘于此,供远程 node 下载热启动) +# DCTS_SEEDS_DIR=data/seeds # 数据库自动备份目录(每日备份 + 7 天保留期自动清理)。默认 data/backups。 # 注意:生产部署建议与 DCTS_DB_PATH 位于同一持久化卷,避免备份落到临时层。 @@ -64,3 +64,8 @@ DCTS_SERVER_URL=http://127.0.0.1:8090 # 节点计算 Worker Slot 隔离工作沙盒目录 (默认: data/work) # DCTS_WORK_DIR=data/work + +# node 端完整计算结果归档目录:任务上报成功后、沙盒清理前,把完整科学产物 +# (.spec/.cont/.iden/.7/各阶段快照/.6/.err/.log/conv.json 等)拷贝至此。 +# 超过 200 个网格点子目录时按 LRU 删最旧。旧名 DCTS_ARCHIVE_DIR 向后兼容回退。 +# DCTS_RESULT_DIR=data/result diff --git a/.gitignore b/.gitignore index 85bdda3..1337d49 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,9 @@ # Generated by Cargo & DCTS # Rust Cargo build artifacts /target/ -.env +*.env # Database files -/data/*.db -/data/*.db-wal -/data/*.db-shm -/data/dcts.db* -/data/dcts_queue.db* +/data/ # Dynamic computation outputs & execution sandboxes /data/results/ diff --git a/.omc/project-memory.json b/.omc/project-memory.json new file mode 100644 index 0000000..8aca049 --- /dev/null +++ b/.omc/project-memory.json @@ -0,0 +1,207 @@ +{ + "version": "1.0.0", + "lastScanned": 1785390240373, + "projectRoot": "/home/fmq/program/tlusty/tl208-s54/dcts", + "techStack": { + "languages": [ + { + "name": "Rust", + "version": null, + "confidence": "high", + "markers": [ + "Cargo.toml" + ] + } + ], + "frameworks": [ + { + "name": "axum", + "version": null, + "category": "backend" + } + ], + "packageManager": "cargo", + "runtime": null + }, + "build": { + "buildCommand": "cargo build", + "testCommand": "cargo test", + "lintCommand": "cargo clippy", + "devCommand": "cargo run", + "scripts": {} + }, + "conventions": { + "namingStyle": null, + "importStyle": null, + "testPattern": null, + "fileOrganization": null + }, + "structure": { + "isMonorepo": false, + "workspaces": [], + "mainDirectories": [ + "assets", + "docs", + "scripts" + ], + "gitBranches": { + "defaultBranch": "main", + "branchingStrategy": null + } + }, + "customNotes": [], + "directoryMap": { + "assets": { + "path": "assets", + "purpose": "Static assets", + "fileCount": 3, + "lastAccessed": 1785390240368, + "keyFiles": [ + "gfVIS99.dat", + "synspec_static", + "tlusty_static" + ] + }, + "crates": { + "path": "crates", + "purpose": null, + "fileCount": 0, + "lastAccessed": 1785390240369, + "keyFiles": [] + }, + "dashboard": { + "path": "dashboard", + "purpose": null, + "fileCount": 5, + "lastAccessed": 1785390240369, + "keyFiles": [ + "index.html", + "package-lock.json", + "package.json", + "vite.config.js" + ] + }, + "data": { + "path": "data", + "purpose": "Data files", + "fileCount": 6, + "lastAccessed": 1785390240370, + "keyFiles": [ + "dcts.db", + "dcts.db-shm", + "dcts.db-wal", + "dcts_queue.db", + "dcts_queue.db-shm" + ] + }, + "deploy.env.d": { + "path": "deploy.env.d", + "purpose": null, + "fileCount": 7, + "lastAccessed": 1785390240370, + "keyFiles": [ + "node-dckj-linux-01.env", + "node-dckj-win-01.env", + "node-huawei-linux-01.env", + "node-local-docker.env", + "node.env.example" + ] + }, + "docs": { + "path": "docs", + "purpose": "Documentation", + "fileCount": 9, + "lastAccessed": 1785390240370, + "keyFiles": [ + "EXPERIENCE.md", + "PIPELINE.md", + "api.md", + "architecture.md", + "contributing.md" + ] + }, + "scripts": { + "path": "scripts", + "purpose": "Build/utility scripts", + "fileCount": 3, + "lastAccessed": 1785390240370, + "keyFiles": [ + "deploy.sh", + "migrate_data_dirs.sh", + "push_import_results.sh" + ] + }, + "target": { + "path": "target", + "purpose": null, + "fileCount": 2, + "lastAccessed": 1785390240370, + "keyFiles": [ + "CACHEDIR.TAG" + ] + }, + "tools": { + "path": "tools", + "purpose": null, + "fileCount": 0, + "lastAccessed": 1785390240370, + "keyFiles": [] + }, + "workflows": { + "path": "workflows", + "purpose": null, + "fileCount": 1, + "lastAccessed": 1785390240371, + "keyFiles": [ + "sdB_cno.yaml" + ] + }, + "assets/data": { + "path": "assets/data", + "purpose": "Data files", + "fileCount": 132, + "lastAccessed": 1785390240372, + "keyFiles": [ + "CIA_H2H.dat", + "CIA_H2H2.dat", + "CIA_H2He.dat" + ] + }, + "dashboard/dist": { + "path": "dashboard/dist", + "purpose": "Distribution/build output", + "fileCount": 1, + "lastAccessed": 1785390240372, + "keyFiles": [ + "index.html" + ] + }, + "dashboard/node_modules": { + "path": "dashboard/node_modules", + "purpose": "Dependencies", + "fileCount": 1, + "lastAccessed": 1785390240372, + "keyFiles": [] + }, + "dashboard/src": { + "path": "dashboard/src", + "purpose": "Source code", + "fileCount": 4, + "lastAccessed": 1785390240373, + "keyFiles": [ + "api.js", + "main.js", + "state.js" + ] + }, + "data/seeds": { + "path": "data/seeds", + "purpose": "Database seeds", + "fileCount": 0, + "lastAccessed": 1785390240373, + "keyFiles": [] + } + }, + "hotPaths": [], + "userDirectives": [] +} \ No newline at end of file diff --git a/.omc/sessions/865f1115-942b-4cc4-81f6-17a611773eb2.json b/.omc/sessions/865f1115-942b-4cc4-81f6-17a611773eb2.json new file mode 100644 index 0000000..f20e103 --- /dev/null +++ b/.omc/sessions/865f1115-942b-4cc4-81f6-17a611773eb2.json @@ -0,0 +1,8 @@ +{ + "session_id": "865f1115-942b-4cc4-81f6-17a611773eb2", + "ended_at": "2026-07-30T05:44:26.934Z", + "reason": "prompt_input_exit", + "agents_spawned": 0, + "agents_completed": 0, + "modes_used": [] +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index cbbb487..c0a9398 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -563,16 +563,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "gethostname" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" -dependencies = [ - "rustix 0.38.44", - "windows-targets", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -907,6 +897,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "import_results" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "common", + "regex", + "reqwest", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -969,12 +975,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1110,7 +1110,6 @@ dependencies = [ "clap", "common", "dotenvy", - "gethostname", "mq", "reqwest", "serde", @@ -1477,19 +1476,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", -] - [[package]] name = "rustix" version = "1.1.4" @@ -1499,7 +1485,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys 0.12.1", + "linux-raw-sys", "windows-sys 0.61.2", ] @@ -1814,22 +1800,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_seeds" -version = "0.1.0" -dependencies = [ - "anyhow", - "clap", - "common", - "reqwest", - "serde", - "serde_json", - "tokio", - "tracing", - "tracing-subscriber", - "uuid", -] - [[package]] name = "sync_wrapper" version = "1.0.2" @@ -1895,7 +1865,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] diff --git a/Cargo.toml b/Cargo.toml index 3ca06d1..7f227bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = [ "crates/mq", "crates/server", "crates/node", - "tools/sync_seeds", + "tools/import_results", ] [workspace.dependencies] diff --git a/Dockerfile.node b/Dockerfile.node index e1820d5..68bc5d8 100644 --- a/Dockerfile.node +++ b/Dockerfile.node @@ -20,25 +20,22 @@ COPY assets/tlusty_static assets/synspec_static ./assets/ RUN cargo build --release -p node && \ cp /app/target/release/node /usr/local/bin/dcts-node -# ─── Stage 2: Fortran 运行环境镜像 ─────────────────────────────────────────── +# ─── Stage 2: 精简 Debian 运行环境镜像 (包含完整 glibc + libmvec + libgfortran) ─ FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends \ - gfortran \ - liblapack-dev \ - libblas-dev \ + libgfortran5 \ ca-certificates \ - curl \ - procps \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* /var/cache/apt/* +# 创建非 root 账号管理运行 RUN groupadd -g 65532 dcts && useradd -u 65532 -g dcts -s /bin/bash dcts WORKDIR /app COPY --from=node-builder /usr/local/bin/dcts-node /app/ -RUN mkdir -p /app/data/runtime /app/data/work /app/logs /app/assets && \ +RUN mkdir -p /app/data /app/data/logs /app/data/runtime /app/data/work /app/data/result /app/assets && \ chown -R dcts:dcts /app USER dcts @@ -48,8 +45,11 @@ ENV DCTS_MAX_SLOTS=4 ENV DCTS_HEARTBEAT_SEC=15 ENV DCTS_RUNTIME_DIR=/app/data/runtime ENV DCTS_WORK_DIR=/app/data/work +# node 完整计算结果归档目录(光谱/连续谱/各阶段产物)。旧名 DCTS_ARCHIVE_DIR 向后兼容回退。 +ENV DCTS_RESULT_DIR=/app/data/result ENV DCTS_ASSETS_DIR=/app/assets +ENV LOG_DIR=/app/data/logs -VOLUME ["/app/data/work", "/app/logs"] +VOLUME ["/app/data"] ENTRYPOINT ["/app/dcts-node"] diff --git a/Dockerfile.server b/Dockerfile.server index 0967775..e0caf3e 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -34,7 +34,8 @@ ENV SKIP_DASHBOARD_BUILD=1 COPY Cargo.toml Cargo.lock ./ COPY crates/ ./crates/ COPY tools/ ./tools/ -COPY assets/tlusty_static assets/synspec_static ./assets/ +COPY workflows/ ./workflows/ + COPY --from=frontend-builder /app/dashboard/dist ./dashboard/dist RUN cargo build --release -p server && \ @@ -58,8 +59,9 @@ WORKDIR /app COPY --from=backend-builder /usr/local/bin/dcts-server /app/ COPY --from=frontend-builder /app/dashboard/dist ./dashboard/dist +COPY workflows/ ./workflows/ -RUN mkdir -p /app/data /app/data/results /app/logs /app/assets && \ +RUN mkdir -p /app/data /app/data/logs /app/data/seeds /app/assets && \ chown -R dcts:dcts /app USER dcts @@ -69,15 +71,16 @@ EXPOSE 8090 ENV DCTS_PORT=8090 ENV DCTS_DB_PATH=/app/data/dcts.db ENV DCTS_QUEUE_DB_PATH=/app/data/dcts_queue.db -ENV DCTS_RESULTS_DIR=/app/data/results +ENV DCTS_SEEDS_DIR=/app/data/seeds ENV DCTS_BACKUP_DIR=/app/data/backups ENV DCTS_ASSETS_DIR=/app/assets +ENV LOG_DIR=/app/data/logs -VOLUME ["/app/data", "/app/logs"] +VOLUME ["/app/data"] -# 健康检查走不走鉴权的 /healthz(启用 admin/enrollment token 后 /api/status 返回 401, +# 健康检查走不走鉴权的 /healthz(启用 admin token 后 /api/status 返回 401, # 会令容器被误判不健康而反复重启)。与 docker-compose.yml 的 healthcheck 保持一致。 HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ - CMD wget -q --spider http://localhost:8090/healthz || exit 1 + CMD wget -q --spider http://127.0.0.1:8090/healthz || exit 1 ENTRYPOINT ["/app/dcts-server"] diff --git a/README.md b/README.md index 307bde9..d9c6a2c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ cargo build --release 编译产物位于 `target/release/`: - `server`:调度与 API 主服务端 - `node`:计算节点后台进程 -- `sync_seeds`:种子同步管理工具 +- `import_results`:历史计算结果导入工具(旧版单机网格结果 → 标记已完成 + 迁移产物树) ### 2. 构建前端与启动服务端 (Master & Web Dashboard) 首先编译前端全域实时网格可视图看班: @@ -58,28 +58,31 @@ DCTS 采用**分层鉴权**模型,公网部署务必按下表配置凭据。 | 主体 | 环境变量 | 用途 | 持有方式 | | :--- | :--- | :--- | :--- | -| **Admin** | `DCTS_ADMIN_TOKEN` | Dashboard 登录、工作流 CRUD、起停计算 | 人工,Dashboard 输入 | -| **Enrollment** | `DCTS_ENROLLMENT_TOKEN` | 节点首次注册领取专属 token | 部署脚本/人工 | -| **Node** | _(服务端自动颁发)_ | 心跳、领任务、上报、下载数据 | 节点本地 `.node_token` 文件(权限 600) | +| **Admin** | `DCTS_ADMIN_TOKEN` | Dashboard 登录、工作流 CRUD、起停计算、节点审批授权 | 人工,Dashboard 输入 | +| **Node** | _(服务端审批后自动颁发)_ | 心跳、领任务、上报、下载数据 | 节点本地 `.node_token` 文件(权限 600) | -> **兼容**:旧变量 `DCTS_AUTH_TOKEN` 仍有效,自动同时充当 Admin + Enrollment 凭据(建议迁移到上面两个独立变量)。 +> **兼容**:旧变量 `DCTS_AUTH_TOKEN` 仍有效,自动回退用作 Admin 凭据(建议迁移到 `DCTS_ADMIN_TOKEN`)。 -### 节点注册流程(L2) +### 节点注册流程(免凭据申请 + 管理员审批) -1. 启动时优先读取本地 `runtime/.node_token`;不存在则用 `DCTS_ENROLLMENT_TOKEN` 调 `/api/node/register`。 -2. 服务端注册成功后**颁发该节点专属 token**(仅返回一次,DB 只存 SHA-256 hash),节点持久化到 `.node_token`。 -3. 后续所有请求携带专属 token;服务端按 token 反查 `node_id` 鉴权。 -4. **吊销/重发**:通过 Dashboard「节点凭据管理」面板或下方管理 API 操作。 +1. 节点启动时优先读取本地 `runtime/.node_token`;不存在则**免凭据**向 `/api/node/register` 提交注册申请,进入 `pending_approval` 待审批状态。 +2. 管理员在 Dashboard「节点管理」面板点击【同意接入】后,服务端**颁发该节点专属 token**(仅返回一次,DB 只存 SHA-256 hash)。 +3. 节点轮询 `/api/node/check_status` 取回专属 token 并持久化到 `.node_token`(权限 600)。 +4. 后续所有请求携带专属 token;服务端按 token 反查 `node_id` 鉴权。 +5. **重发/停用**:通过 Dashboard「节点凭据管理」面板或下方管理 API 操作。 ### 节点凭据管理 API(Admin 角色) | 方法 | 路径 | 说明 | | :--- | :--- | :--- | -| GET | `/api/admin/nodes` | 列出全部节点及凭据状态(在线/token 有效/吊销/颁发时间) | -| POST | `/api/admin/nodes/:node_id/revoke` | 吊销指定节点 token(立即失效,幂等) | -| POST | `/api/admin/nodes/:node_id/reissue` | 重新颁发 token,返回新明文(旧 token 失效) | +| GET | `/api/admin/nodes` | 列出全部节点及凭据状态(在线/token 有效/颁发时间) | +| POST | `/api/admin/nodes/:node_id/approve` | 同意待审批节点的接入申请并颁发 token | +| POST | `/api/admin/nodes/:node_id/reject` | 拒绝待审批节点的接入申请 | +| POST | `/api/admin/nodes/:node_id/reissue` | 重新颁发 token,返回新明文(旧 token 立即失效) | +| POST | `/api/admin/nodes/:node_id/disable` | 停用节点(保持在线但不再分发任务,可恢复) | +| POST | `/api/admin/nodes/:node_id/enable` | 重新启用被停用的节点 | -所有端点要求 Admin token(`Authorization: Bearer `)。被攻陷节点持有的 node token 无权访问这些端点,因此吊销/重发始终是管理员主动行为。 +所有端点要求 Admin token(`Authorization: Bearer `)。被攻陷节点持有的 node token 无权访问这些端点,因此重发/停用始终是管理员主动行为。 ### 公网部署清单 @@ -89,9 +92,8 @@ openssl rand -hex 32 ``` ```env -# .env(服务端 + 节点共享此文件时各自读取所需变量) +# .env(服务端配置;计算节点无需任何凭据,免凭据申请后由管理员审批授权) DCTS_ADMIN_TOKEN=<强随机值> -DCTS_ENROLLMENT_TOKEN=<强随机值> ``` **TLS 反代**(推荐 Caddy,自动 HTTPS): @@ -122,7 +124,7 @@ docker compose --profile public up -d --build | [`node`](crates/node/README.md) | Binary | Worker 节点 Daemon 进程,负责任务抢占、自适应环境预热、计算链执行与产物汇报 | [README](crates/node/README.md) | | [`mq`](crates/mq/README.md) | Library | 基于 SQLite 构建的高可靠事务型分布式任务队列引擎 | [README](crates/mq/README.md) | | [`dashboard`](dashboard/index.html) | Web UI | 基于 Vite 与原生高交互前端语系创写的分层式恒星网格任务可观测可视化控表空间 | [说明详情](dashboard/package.json) | -| [`sync_seeds`](tools/sync_seeds/README.md) | Tool CLI | 离线 / 增量种子数据文件(`.7`)高效率同步工具 | [README](tools/sync_seeds/README.md) | +| [`import_results`](tools/import_results/README.md) | Tool CLI | 历史计算结果导入工具(旧版单机网格结果 → 标记已完成 + 迁移产物树) | [README](tools/import_results/README.md) | --- diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index fad46c7..714f3dc 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -21,6 +21,6 @@ reqwest.workspace = true tokio.workspace = true [features] -default = ["embed-binaries"] +default = [] embed-binaries = [] diff --git a/crates/common/src/config.rs b/crates/common/src/config.rs index e523488..cec69f2 100644 --- a/crates/common/src/config.rs +++ b/crates/common/src/config.rs @@ -1,15 +1,143 @@ +use crate::models::GridAxisValue; use anyhow::{Context, Result}; +use regex::Regex; use serde::{Deserialize, Serialize}; use std::path::Path; +use std::sync::OnceLock; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GridAxesConfig { - pub teff: Vec, - pub logg: Vec, - pub loghe: Vec, - pub logc: Vec, - pub logn: Vec, - pub logo: Vec, + pub teff: Vec, + pub logg: Vec, + pub loghe: Vec, + pub logc: Vec, + pub logn: Vec, + pub logo: Vec, +} + +/// 解析单个标量 token 为 `GridAxisValue`:保留 YAML 源书写原文(`5.0`→`"5.0"`, +/// `20000`→`"20000"`,`-2`→`"-2"`),同时取其 f64 数值。 +fn axis_value_from_token(tok: &str) -> Option { + let tok = tok.trim().trim_matches(','); + // 允许的标量形式:可选符号 + 数字(含小数、科学计数)。剔除引号/非法字符。 + if tok.is_empty() { + return None; + } + let cleaned = tok.trim_matches(|c| c == '"' || c == '\''); + if cleaned.parse::().is_ok() { + Some(GridAxisValue::from_text(cleaned)) + } else { + None + } +} + +/// 从 YAML 文本中按轴名提取**源精度原文 token**,构造 `GridAxesConfig`。 +/// +/// # 为什么需要它 +/// `serde_yaml` 的公开 API 在解析时会把纯标量 `5.0` 解析为 `visit_f64(5.0)`、`20000` +/// 解析为 `visit_i64`,**丢失原始书写文本**——而 `model_name()` 必须严格忠于源精度 +/// (`logg: 5.0` → `g5.0`,不是 `g5`),否则与旧版 Python `gen_input5.model_name` +/// 对不上、迁移失败。本函数直接扫 YAML 文本的 `grid:` 块,逐 token 捕获原文,绕过 +/// serde_yaml 的类型归一化。 +/// +/// 支持两种块格式(与 run_grid.py / DCTS 配置一致): +/// - 流式:`logg: [5.0, 6.0]` +/// - 块式:`logg:\n - 5.0\n - 6.0` +fn parse_grid_axes_raw(yaml: &str) -> Option { + let axes_re = { + static RE: OnceLock = OnceLock::new(); + // 捕获轴名与该行 `key:` 之后的内容(流式 `[...]` 或空),供后续按行解析块式。 + RE.get_or_init(|| Regex::new(r"^\s*(teff|logg|loghe|logc|logn|logo)\s*:\s*(.*)$").unwrap()) + }; + + let mut axes: std::collections::HashMap<&str, Vec> = + std::collections::HashMap::new(); + let mut current_axis: Option<&str> = None; // 块式 `- value` 续行归属 + let lines: Vec<&str> = yaml.lines().collect(); + + // 是否已进入 `grid:` 块(grid 块之外的同名键不误捕,尽管实际不会重名)。 + let mut in_grid = false; + + for (i, raw) in lines.iter().enumerate() { + let line = raw.split('#').next().unwrap_or("").trim_end(); + let stripped = line.trim_start(); + if stripped.is_empty() { + continue; + } + // 顶层键:grid / 其它。仅顶层(无缩进)键切换 in_grid。 + if !raw.starts_with(' ') && !raw.starts_with('\t') { + in_grid = stripped.starts_with("grid:"); + current_axis = None; + continue; + } + if !in_grid { + continue; + } + + // 块式列表续行:` - 5.0` + if let Some(rest) = stripped.strip_prefix("- ") { + if let Some(axis) = current_axis { + if let Some(v) = axis_value_from_token(rest) { + axes.entry(axis).or_default().push(v); + } + } + continue; + } + + // 轴定义行:` logg: [5.0, 6.0]` 或 ` logg:`(块式,值在下几行) + if let Some(caps) = axes_re.captures(line) { + let axis = caps.get(1).unwrap().as_str(); + let tail = caps.get(2).unwrap().as_str().trim(); + current_axis = Some(axis); + if tail.starts_with('[') { + // 流式:`[5.0, 6.0]` —— 可能在单行内闭合,也可能跨行(本配置不会跨行)。 + let inner = tail.trim_start_matches('[').split(']').next().unwrap_or(""); + for tok in inner.split(',') { + if let Some(v) = axis_value_from_token(tok) { + axes.entry(axis).or_default().push(v); + } + } + current_axis = None; // 流式在本行闭合,不再续行 + } + // tail 为空 → 块式,等后续 `- value` 行(current_axis 已设) + } + // 跨行未闭合的流式列表(grid 块极少如此)不单独处理;YAML 规范允许但本配置不使用。 + let _ = i; + } + + // 六轴齐全才算解析成功;任一缺失回退 None(调用方走纯 serde 数值路径)。 + let get = |k: &str| axes.get(k).cloned().filter(|v| !v.is_empty()); + Some(GridAxesConfig { + teff: get("teff")?, + logg: get("logg")?, + loghe: get("loghe")?, + logc: get("logc")?, + logn: get("logn")?, + logo: get("logo")?, + }) +} + +impl GridConfig { + pub fn load_from_file(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read config file: {}", path.display()))?; + GridConfig::from_yaml_str(&content) + .with_context(|| format!("Failed to parse YAML config: {}", path.display())) + } + + /// 解析 YAML 配置,**优先用源精度原文重建 grid 轴**。 + /// + /// 两步: + /// 1. `serde_yaml` 解析整个 `GridConfig`(chain/synspec 等正常字段,grid 轴为数值)。 + /// 2. 若能从原文捕到六轴 token,用源精度 `GridAxisValue` 覆盖 grid 字段; + /// 捕不到(格式异常)则保留 serde 数值结果(命名精度回退,不阻断解析)。 + pub fn from_yaml_str(yaml: &str) -> Result { + let mut cfg: GridConfig = serde_yaml::from_str(yaml)?; + if let Some(raw_axes) = parse_grid_axes_raw(yaml) { + cfg.grid = raw_axes; + } + Ok(cfg) + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -99,6 +227,11 @@ pub struct GridConfig { pub resume: bool, #[serde(default = "default_true")] pub seed_step_fallback: bool, + /// **已弃用的死字段**:旧版 Python 工具链遗留,无任何代码读取(实际目录以 + /// `ServerConfig.seeds_dir` / `DCTS_SEEDS_DIR` 为准)。仅因 `deny_unknown_fields` + /// 必须能解析而保留。workflow YAML 里仍可写(如 `results: data/seeds`)但被忽略。 + #[deprecated(note = "死字段,实际目录以 DCTS_SEEDS_DIR 为准")] + #[serde(default)] pub results: Option, #[serde(default)] pub itek_fallback: Vec, @@ -125,22 +258,16 @@ fn default_true() -> bool { true } -impl GridConfig { - pub fn load_from_file(path: &Path) -> Result { - let content = std::fs::read_to_string(path) - .with_context(|| format!("Failed to read config file: {}", path.display()))?; - let cfg: GridConfig = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML config: {}", path.display()))?; - Ok(cfg) - } -} - #[derive(Clone, Serialize, Deserialize)] pub struct ServerConfig { pub bind_addr: String, pub db_path: String, pub queue_db_path: String, - pub results_dir: String, + /// server 端种子库目录:node 上报/历史导入收敛后,落地 `/conv.json` + + /// `/.7` 于此,供 `download_seed` 端点给远程 node 热启动下载。 + /// **永不清理**(种子是 SeedStep 热启动的必需资源,删除会导致已收敛点重算)。 + /// 默认 "data/seeds",可经 DCTS_SEEDS_DIR 覆盖(回退读旧 DCTS_RESULTS_DIR)。 + pub seeds_dir: String, /// 数据库备份目录(每日自动备份落盘位置)。默认 "data/backups",可经 DCTS_BACKUP_DIR 覆盖。 pub backup_dir: String, pub grid_config: String, @@ -152,9 +279,6 @@ pub struct ServerConfig { /// Admin 凭据(Dashboard 登录用)。优先 DCTS_ADMIN_TOKEN,回退旧变量 DCTS_AUTH_TOKEN。 #[serde(default)] pub admin_token: Option, - /// 兼容字段:保留以判断「是否启用鉴权」与旧中间件逻辑。取 admin_token 的值。 - #[serde(default)] - pub auth_token: Option, /// 应急开关:DCTS_AUTH_DISABLE=1 时跳过全部鉴权(仅本地调试,默认关闭)。 #[serde(default)] pub auth_disabled: bool, @@ -167,7 +291,7 @@ impl std::fmt::Debug for ServerConfig { .field("bind_addr", &self.bind_addr) .field("db_path", &self.db_path) .field("queue_db_path", &self.queue_db_path) - .field("results_dir", &self.results_dir) + .field("seeds_dir", &self.seeds_dir) .field("backup_dir", &self.backup_dir) .field("grid_config", &self.grid_config) .field("stale_sec", &self.stale_sec) @@ -178,10 +302,6 @@ impl std::fmt::Debug for ServerConfig { "admin_token", &self.admin_token.as_ref().map(|_| "***REDACTED***"), ) - .field( - "auth_token", - &self.auth_token.as_ref().map(|_| "***REDACTED***"), - ) .field("auth_disabled", &self.auth_disabled) .finish() } @@ -200,8 +320,10 @@ impl Default for ServerConfig { let db_path = std::env::var("DCTS_DB_PATH").unwrap_or_else(|_| "data/dcts.db".to_string()); let queue_db_path = std::env::var("DCTS_QUEUE_DB_PATH") .unwrap_or_else(|_| "data/dcts_queue.db".to_string()); - let results_dir = - std::env::var("DCTS_RESULTS_DIR").unwrap_or_else(|_| "data/results".to_string()); + // 种子库目录:优先 DCTS_SEEDS_DIR,回退旧 DCTS_RESULTS_DIR(已弃用,保留兼容)。 + let seeds_dir = std::env::var("DCTS_SEEDS_DIR") + .or_else(|_| std::env::var("DCTS_RESULTS_DIR")) + .unwrap_or_else(|_| "data/seeds".to_string()); let backup_dir = std::env::var("DCTS_BACKUP_DIR").unwrap_or_else(|_| "data/backups".to_string()); let grid_config = std::env::var("DCTS_GRID_CONFIG") @@ -226,13 +348,10 @@ impl Default for ServerConfig { .ok() .filter(|s| !s.is_empty()) .or_else(|| legacy_token.clone()); - if legacy_token.is_some() - && (std::env::var("DCTS_ADMIN_TOKEN").is_err() - || std::env::var("DCTS_ENROLLMENT_TOKEN").is_err()) - { + if legacy_token.is_some() && std::env::var("DCTS_ADMIN_TOKEN").is_err() { tracing::warn!( - "检测到旧的 DCTS_AUTH_TOKEN,已自动用作 admin/enrollment 凭据。\ - 建议迁移到 DCTS_ADMIN_TOKEN(管理)与 DCTS_ENROLLMENT_TOKEN(节点注册)" + "检测到旧的 DCTS_AUTH_TOKEN,已自动回退用作 admin 凭据。\ + 建议迁移到 DCTS_ADMIN_TOKEN" ); } @@ -245,19 +364,11 @@ impl Default for ServerConfig { ); } - // auth_token 兼容字段:用于 main.rs 判断「是否启用鉴权中间件」。 - // 启用条件 = 显式配置了 admin 或 enrollment 凭据,且未应急关闭。 - let auth_token = if auth_disabled { - None - } else { - admin_token.clone() - }; - Self { bind_addr: format!("0.0.0.0:{}", port), db_path, queue_db_path, - results_dir, + seeds_dir, backup_dir, grid_config, stale_sec, @@ -265,7 +376,6 @@ impl Default for ServerConfig { mq_type, rabbitmq_url, admin_token, - auth_token, auth_disabled, } } @@ -278,6 +388,13 @@ pub struct NodeConfig { pub max_slots: usize, pub runtime_dir: String, pub work_dir: String, + /// node 端完整计算结果归档目录:任务上报成功后、沙盒清理前,把完整科学产物 + /// (.spec/.cont/.iden/.7/各阶段快照/.6/.err/.log/conv.json 等)拷贝至此, + /// 避免随沙盒删除而丢失。与 server 的 `seeds` 目录区分:此处存的是**完整产物** + /// (光谱/连续谱/各阶段大气快照等),seeds 只存最小种子集(.7+conv.json)。 + /// 默认 "data/result",可经 DCTS_RESULT_DIR 覆盖(回退读旧 DCTS_ARCHIVE_DIR)。 + /// 超过 MAX_RESULT_MODELS 个网格点子目录时按 LRU 删除最旧的。 + pub result_dir: String, pub heartbeat_sec: u64, } @@ -289,6 +406,7 @@ impl std::fmt::Debug for NodeConfig { .field("max_slots", &self.max_slots) .field("runtime_dir", &self.runtime_dir) .field("work_dir", &self.work_dir) + .field("result_dir", &self.result_dir) .field("heartbeat_sec", &self.heartbeat_sec) .finish() } @@ -318,6 +436,10 @@ impl Default for NodeConfig { let runtime_dir = std::env::var("DCTS_RUNTIME_DIR").unwrap_or_else(|_| "data/runtime".to_string()); let work_dir = std::env::var("DCTS_WORK_DIR").unwrap_or_else(|_| "data/work".to_string()); + // 结果归档目录:优先 DCTS_RESULT_DIR,回退旧 DCTS_ARCHIVE_DIR(已弃用,保留兼容)。 + let result_dir = std::env::var("DCTS_RESULT_DIR") + .or_else(|_| std::env::var("DCTS_ARCHIVE_DIR")) + .unwrap_or_else(|_| "data/result".to_string()); let heartbeat_sec = std::env::var("DCTS_HEARTBEAT_SEC") .ok() .and_then(|v| v.parse::().ok()) @@ -329,6 +451,7 @@ impl Default for NodeConfig { max_slots, runtime_dir, work_dir, + result_dir, heartbeat_sec, } } diff --git a/crates/common/src/gen_input5.rs b/crates/common/src/gen_input5.rs index 26a0e3a..7ca72d4 100644 --- a/crates/common/src/gen_input5.rs +++ b/crates/common/src/gen_input5.rs @@ -196,21 +196,21 @@ pub fn make_input5( // Atoms block let mut atom_rows: Vec<(i32, String)> = vec![ - (2, "0.".to_string()), // 1 H - (2, fmt_abn(params.loghe)), // 2 He - (0, "0.".to_string()), // 3 Li - (0, "0.".to_string()), // 4 Be - (0, "0.".to_string()), // 5 B + (2, "0.".to_string()), // 1 H + (2, fmt_abn(*params.loghe)), // 2 He + (0, "0.".to_string()), // 3 Li + (0, "0.".to_string()), // 4 Be + (0, "0.".to_string()), // 5 B ]; if has_c { - atom_rows.push((2, fmt_abn(params.logc))); // 6 C + atom_rows.push((2, fmt_abn(*params.logc))); // 6 C } if has_n { - atom_rows.push((2, fmt_abn(params.logn))); // 7 N + atom_rows.push((2, fmt_abn(*params.logn))); // 7 N } if has_o { - atom_rows.push((2, fmt_abn(params.logo))); // 8 O + atom_rows.push((2, fmt_abn(*params.logo))); // 8 O } let natoms = @@ -271,12 +271,12 @@ mod tests { #[test] fn test_make_input5() { let params = GridPointParams { - teff: 35000.0, - logg: 5.5, - loghe: -1.0, - logc: -2.0, - logn: -2.0, - logo: -2.0, + teff: 35000.0.into(), + logg: 5.5.into(), + loghe: (-1.0).into(), + logc: (-2.0).into(), + logn: (-2.0).into(), + logo: (-2.0).into(), }; let input5 = make_input5(¶ms, "F", "F", "cno", 100); assert!(input5.contains("35000.0 5.5")); diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 28f87f2..7f617bc 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -6,5 +6,6 @@ pub mod gen_input5; pub mod logging; pub mod models; pub mod nst_writer; +pub mod result_filter; pub mod runner; pub mod seed_finder; diff --git a/crates/common/src/models.rs b/crates/common/src/models.rs index 026e28d..49af03c 100644 --- a/crates/common/src/models.rs +++ b/crates/common/src/models.rs @@ -1,19 +1,122 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use std::ops::Deref; use uuid::Uuid; -/// 6D grid point parameter specification -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct GridPointParams { - pub teff: f64, - pub logg: f64, - pub loghe: f64, - pub logc: f64, - pub logn: f64, - pub logo: f64, +/// 网格轴值:同时携带**数值**(供算术/排序/DB REAL 列)与**源书写文本**(供命名)。 +/// +/// # 为什么需要它 +/// `f64` 无法区分 YAML 里 `5.0`(带小数)与 `5`(省略小数)——两者解析后都是 `5.0_f64`。 +/// 而 `model_name()` 必须严格忠于源精度("配置里多少位小数就多少位"),否则 Rust 名 +/// (`t20000_g5_...`) 与 Python `gen_input5.model_name` 名 (`t20000_g5.0_...`) 对不上, +/// 旧版单机网格数据无法迁移到本系统。 +/// +/// 本类型在反序列化时通过 `deserialize_any` 的 `visit_str` 捕获 YAML/JSON 标量原文, +/// 把它一路携带到 `model_name()` 直接拼接;算术则通过 `Deref` 透明转发到数值, +/// 绝大多数 `params.teff` 算术调用点无需改动。 +/// +/// # serde 行为 +/// - **反序列化**:优先抓原文(`5.0`→text=`"5.0"`,`20000`→text=`"20000"`);纯数值来源 +/// (DB REAL 列回读、JSON 数值 payload)无原文时用数值兜底文本(仅占位,该路径应使用 +/// 存储的 `name` 列而非 `model_name()` 重推)。 +/// - **序列化**:输出为纯 `f64` 数值,保证 JSON payload / 旧消费者无感。 +#[derive(Debug, Clone, PartialEq)] +pub struct GridAxisValue { + value: f64, + text: Box, } -fn fmt_num(val: f64) -> String { +impl GridAxisValue { + /// 构造:用给定数值,文本由数值生成(兜底路径:DB 回读 / 程序内构造)。 + pub fn from_value(value: f64) -> Self { + let text = format_float_minimal(value).into(); + Self { value, text } + } + + /// 构造:用给定文本,数值由文本解析(反序列化主路径,信任源精度)。 + pub(crate) fn from_text(text: &str) -> Self { + let value = text.parse::().unwrap_or(f64::NAN); + Self { + value, + text: text.into(), + } + } + + /// 数值(算术用)。 + pub fn value(&self) -> f64 { + self.value + } + + /// 源书写文本(命名用)。 + pub fn text(&self) -> &str { + &self.text + } +} + +/// `Deref` 到 `f64`:所有 `params.teff` 形式的算术调用点(排序、量化、求和等) +/// 无需显式 `.value()` 即可继续工作,把跨 crate 改动量压到最低。 +impl Deref for GridAxisValue { + type Target = f64; + fn deref(&self) -> &f64 { + &self.value + } +} + +/// 排序委托数值,保证调度排序语义不变。 +impl PartialOrd for GridAxisValue { + fn partial_cmp(&self, other: &Self) -> Option { + self.value.partial_cmp(&other.value) + } +} + +/// 算术运算全部委托内部 f64,使本类型在数值计算上与 f64 完全等价。 +/// 结果一律退化为纯 f64——轴值只在其作为网格轴字段时才需保留源文本, +/// 参与运算后的中间结果无需再保留精度(也不会用于命名)。 +/// 借用场景(如 `&GridPointParams` 字段相减)请用 `.value()` 显式取值。 +macro_rules! impl_arith { + ($trait:ident, $method:ident) => { + impl std::ops::$trait for GridAxisValue { + type Output = f64; + fn $method(self, rhs: Self) -> f64 { + self.value.$method(rhs.value) + } + } + impl std::ops::$trait for GridAxisValue { + type Output = f64; + fn $method(self, rhs: f64) -> f64 { + self.value.$method(rhs) + } + } + }; +} +impl_arith!(Add, add); +impl_arith!(Sub, sub); +impl_arith!(Mul, mul); +impl_arith!(Div, div); + +/// `Display` 委托数值(用于 `format!("{}", params.x)` 这类数值展示场景, +/// 如 gen_input5 的 `{:.1f}` teff/logg 格式化)。命名场景请用 `.text()`。 +impl std::fmt::Display for GridAxisValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.value, f) + } +} + +impl From for GridAxisValue { + fn from(value: f64) -> Self { + Self::from_value(value) + } +} + +impl From for GridAxisValue { + fn from(value: i32) -> Self { + Self::from_value(value as f64) + } +} + +/// 把 f64 格式化为最简可解析文本(兜底路径用;源精度路径不走这里)。 +/// 整数取整无小数,非整数去尾零。 +fn format_float_minimal(val: f64) -> String { let rounded = (val * 1e6).round() / 1e6; if (rounded - rounded.round()).abs() < 1e-6 { format!("{:.0}", rounded.round()) @@ -25,17 +128,85 @@ fn fmt_num(val: f64) -> String { } } +impl Serialize for GridAxisValue { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_f64(self.value) + } +} + +impl<'de> Deserialize<'de> for GridAxisValue { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct AxisVisitor; + + impl<'de> serde::de::Visitor<'de> for AxisVisitor { + type Value = GridAxisValue; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a numeric grid axis value") + } + + // 主路径:YAML/JSON 标量原文(`5.0`、`20000`、`-2`、`"-4"`)。 + fn visit_str(self, v: &str) -> Result { + Ok(GridAxisValue::from_text(v)) + } + + fn visit_string(self, v: String) -> Result { + Ok(GridAxisValue::from_text(&v)) + } + + // 兜底路径:纯数值来源(无原文)。先尝试解析原 deserializer 文本不可得, + // 这里只能从数值反推文本(占位;该路径应用存储的 name 列)。 + fn visit_f64(self, v: f64) -> Result { + Ok(GridAxisValue::from_value(v)) + } + + fn visit_i64(self, v: i64) -> Result { + // 整数来源:文本取整无损(如 JSON 里 `20000`),保留无小数形式。 + Ok(GridAxisValue::from_text(&v.to_string())) + } + + fn visit_u64(self, v: u64) -> Result { + Ok(GridAxisValue::from_text(&v.to_string())) + } + } + + // deserialize_any:serde_yaml 的标量会先尝试以 str 形式投递给 visit_str, + // 从而捕获原文;纯数值 deserializer(如 serde_json 的 f64 字段)会走数值分支。 + deserializer.deserialize_any(AxisVisitor) + } +} + +/// 6D grid point parameter specification +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct GridPointParams { + pub teff: GridAxisValue, + pub logg: GridAxisValue, + pub loghe: GridAxisValue, + pub logc: GridAxisValue, + pub logn: GridAxisValue, + pub logo: GridAxisValue, +} + impl GridPointParams { - /// Generates canonical model name string e.g. "t35000_g5.5_he-1_c-2_n-2_o-2" + /// 生成规范模型名,**严格忠于各轴的源书写精度**:直接拼接 YAML 原文。 + /// + /// 例:YAML 中 `teff: [20000]`、`logg: [5.0]`、丰度 `[-2]` → `t20000_g5.0_he-2_c-2_n-2_o-2`。 + /// 这与旧版 Python `gen_input5.model_name` 逐字符一致,保证旧数据可迁移。 pub fn model_name(&self) -> String { format!( "t{}_g{}_he{}_c{}_n{}_o{}", - fmt_num(self.teff), - fmt_num(self.logg), - fmt_num(self.loghe), - fmt_num(self.logc), - fmt_num(self.logn), - fmt_num(self.logo) + self.teff.text(), + self.logg.text(), + self.loghe.text(), + self.logc.text(), + self.logn.text(), + self.logo.text(), ) } @@ -44,7 +215,7 @@ impl GridPointParams { /// 注:此数值专门用于网格调度中的 Wave 难度分级与保序分组(对数和越小代表重元素丰度越低, /// 通常在大气模型计算中更容易收敛,作为冷启动基准)。 pub fn cno_sum(&self) -> f64 { - self.logc + self.logn + self.logo + *self.logc + *self.logn + *self.logo } } @@ -109,6 +280,10 @@ pub struct TaskSpec { /// 旧数据反序列化时缺省为 None。 #[serde(default)] pub workflow_name: Option, + /// 网格点所属 wave(难度分批),用于队列内按难度优先出队,恢复"先易后难积累种子"语义。 + /// 旧 payload 反序列化时缺省为 0。 + #[serde(default)] + pub wave: i32, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -149,7 +324,6 @@ pub struct TaskReport { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NodeRegisterRequest { pub node_id: String, - pub host_name: String, pub max_slots: i32, } @@ -166,7 +340,6 @@ pub struct NodeHeartbeatRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NodeInfo { pub node_id: String, - pub host_name: String, pub max_slots: i32, pub active_slots: i32, pub status: String, @@ -197,6 +370,16 @@ pub struct StageSummary { pub best_max_relc: Option, pub elapsed_sec: f64, pub note: Option, + /// 末次迭代序号(fort.9 解析所得);NITER=0 的 grey 阶段为 null。 + /// 收敛难度直接指标:17 次迭代收敛 vs 顶着 NITER 上限勉强收敛稳定性迥异。 + #[serde(default)] + pub last_iter: Option, + /// 收敛最差的深度点编号(|maximum| 最大处),诊断定位用。 + #[serde(default)] + pub worst_depth: Option, + /// 深度点总数,诊断用。 + #[serde(default)] + pub n_depths: Option, } /// Full execution summary for a grid point @@ -213,6 +396,9 @@ pub struct ModelSummary { pub synspec_rc: Option, pub synspec_error: Option, pub synspec_sec: Option, + /// 单点总墙钟耗时(秒)。极旧版 conv.json 可能缺此字段,default 0.0 兜底 + /// (展示层把 ≤0 视为"无数据");现版 run_one.py 总是写入。 + #[serde(default)] pub elapsed_sec: f64, pub note: Option, } @@ -221,30 +407,213 @@ pub struct ModelSummary { mod tests { use super::*; + /// StageSummary 新增迭代诊断字段的向后兼容: + /// 旧 conv.json(无 last_iter/worst_depth/n_depths)必须能正常反序列化为 null, + /// 新写入的 conv.json 往返保真。 + #[test] + fn test_stage_summary_iter_fields_backward_compat() { + let legacy = r#"{ + "label": "nl", "chmax": 0.001, "lte": "F", "converged": true, + "best_max_relc": 0.0005, "elapsed_sec": 64.0, "note": null + }"#; + let st: StageSummary = serde_json::from_str(legacy).unwrap(); + assert_eq!(st.last_iter, None); + assert_eq!(st.worst_depth, None); + assert_eq!(st.n_depths, None); + + let full = StageSummary { + label: "nl".to_string(), + chmax: Some(0.001), + lte: "F".to_string(), + converged: true, + best_max_relc: Some(0.0005), + elapsed_sec: 64.0, + note: None, + last_iter: Some(17), + worst_depth: Some(1), + n_depths: Some(50), + }; + let round: StageSummary = + serde_json::from_str(&serde_json::to_string(&full).unwrap()).unwrap(); + assert_eq!(round.last_iter, Some(17)); + assert_eq!(round.worst_depth, Some(1)); + assert_eq!(round.n_depths, Some(50)); + } + + /// 旧版 Python run_one.py 写出的 conv.json 必须能完整解析为 ModelSummary + /// (历史结果迁移链路 import_results → /api/admin/import_seed 的兼容性命门)。 + /// + /// 载荷严格复刻 run_one.py 的真实输出形态:stage 含 `itek_attempts`/`final` 嵌套 + /// dict、`note`、可选 `best_max_relc`,顶层含 `final_chmax`/`synspec_*`/`seed` 等。 + /// Rust StageSummary 未声明的字段(itek_attempts/final)应被 serde 静默忽略。 + #[test] + fn test_model_summary_parses_python_legacy_conv_json() { + let legacy = r#"{ + "name": "t20000_g5.0_he-2_c-4_n-4_o-4", + "params": {"teff": 20000.0, "logg": 5.0, "loghe": -2.0, "logc": -4.0, "logn": -4.0, "logo": -4.0}, + "stages": [ + {"label": "lte", "chmax": null, "lte": "T", + "itek_attempts": [{"itek": null, "rc": 0, "converged": true, "max_relc": 0.0, + "note": "NITER=0 grey start (no iterations)"}], + "converged": true, + "final": {"itek": null, "rc": 0, "converged": true, "max_relc": 0.0, + "note": "NITER=0 grey start (no iterations)"}, + "best_max_relc": 0.0, "elapsed_sec": 2.1}, + {"label": "nc", "chmax": null, "lte": "F", + "itek_attempts": [{"itek": null, "rc": 0, "converged": false, "max_relc": 0.957, + "worst_depth": 1, "last_iter": 10, "n_depths": 50}], + "converged": false, + "final": {"itek": null, "rc": 0, "converged": false, "max_relc": 0.957, + "worst_depth": 1, "last_iter": 10, "n_depths": 50}, + "note": "accepted as seed (convergence not required)", + "best_max_relc": 0.957, "elapsed_sec": 62.4}, + {"label": "nl", "chmax": null, "lte": "F", + "itek_attempts": [{"itek": null, "rc": 0, "converged": true, "max_relc": 0.0069, + "worst_depth": 1, "last_iter": 17, "n_depths": 50}], + "converged": true, + "final": {"itek": null, "rc": 0, "converged": true, "max_relc": 0.0069, + "worst_depth": 1, "last_iter": 17, "n_depths": 50}, + "best_max_relc": 0.0069, "elapsed_sec": 650.2} + ], + "converged": true, + "final_max_relc": 0.0069, + "final_chmax": null, + "seed": null, + "atmosphere_has_nan": false, + "synspec_rc": 0, + "synspec_sec": 3.1, + "elapsed_sec": 715.0 +}"#; + let s: ModelSummary = serde_json::from_str(legacy).expect("旧版 conv.json 应可解析"); + assert_eq!(s.name, "t20000_g5.0_he-2_c-4_n-4_o-4"); + assert_eq!(*s.params.teff, 20000.0); + assert!(s.converged); + assert!(!s.atmosphere_has_nan); + assert_eq!(s.final_max_relc, Some(0.0069)); + assert_eq!(s.elapsed_sec, 715.0); + assert_eq!(s.synspec_rc, Some(0)); + assert_eq!(s.stages.len(), 3); + assert_eq!(s.stages[0].label, "lte"); + assert_eq!(s.stages[0].lte, "T"); + assert!(s.stages[0].converged); + assert_eq!(s.stages[2].label, "nl"); + assert_eq!(s.stages[2].best_max_relc, Some(0.0069)); + assert_eq!(s.stages[2].elapsed_sec, 650.2); + // 旧版把迭代诊断嵌在 final/itek_attempts 里(非扁平字段)→ 扁平字段为 None, + // 但原始诊断仍随 conv.json 原文落盘(import_seed 存原始 summary_json),无数据丢失。 + assert_eq!(s.stages[2].last_iter, None); + assert_eq!(s.stages[2].worst_depth, None); + } + + /// 极旧 conv.json 变体(无 elapsed_sec 字段)也应可解析(default 0.0 兜底), + /// 使历史迁移不因缺耗时字段而整点跳过。 + #[test] + fn test_model_summary_tolerates_missing_elapsed() { + let minimal = r#"{ + "name": "t20000_g5.0_he-2_c-4_n-4_o-4", + "params": {"teff": 20000.0, "logg": 5.0, "loghe": -2.0, "logc": -4.0, "logn": -4.0, "logo": -4.0}, + "stages": [], + "converged": true, + "final_max_relc": 0.001, + "seed": null, + "atmosphere_has_nan": false +}"#; + let s: ModelSummary = serde_json::from_str(minimal).expect("缺 elapsed_sec 应可解析"); + assert_eq!(s.elapsed_sec, 0.0); + assert_eq!(s.synspec_rc, None); + assert_eq!(s.final_chmax, None); + } + #[test] fn test_model_name_formatting_and_decimal_precision() { let p1 = GridPointParams { - teff: 35000.0, - logg: 5.5, - loghe: -1.0, - logc: -2.0, - logn: -2.0, - logo: -2.0, + teff: 35000.0.into(), + logg: 5.5.into(), + loghe: (-1.0).into(), + logc: (-2.0).into(), + logn: (-2.0).into(), + logo: (-2.0).into(), }; assert_eq!(p1.model_name(), "t35000_g5.5_he-1_c-2_n-2_o-2"); let p2 = GridPointParams { - teff: 35000.0, - logg: 5.25, - loghe: -1.5, - logc: -2.75, - logn: -2.0, - logo: -1.25, + teff: 35000.0.into(), + logg: 5.25.into(), + loghe: (-1.5).into(), + logc: (-2.75).into(), + logn: (-2.0).into(), + logo: (-1.25).into(), }; assert_eq!(p2.model_name(), "t35000_g5.25_he-1.5_c-2.75_n-2_o-1.25"); assert_ne!(p1.model_name(), p2.model_name()); } + /// 回归:YAML 源书写的整数小数位必须原样保留(`logg: 5.0` → `g5.0`,不是 `g5`)。 + /// 这是旧版单机网格数据迁移的命门——Python `gen_input5.model_name` 用 `g{:.1f}`, + /// 故 `t20000_g5.0_he-2_c-4_n-4_o-4` 才是正确名。`f64` 无法区分 `5.0` 与 `5`, + /// 必须由 `GridAxisValue` 的源文本携带。 + #[test] + fn test_model_name_preserves_source_decimal_precision() { + // 模拟 serde_yaml 解析 grid: { teff: [20000], logg: [5.0], loghe: [-2], ... } + // 每个轴值通过 from_text(visit_str 路径)构造,保留原文。 + let p = GridPointParams { + teff: GridAxisValue::from_text("20000"), + logg: GridAxisValue::from_text("5.0"), + loghe: GridAxisValue::from_text("-2"), + logc: GridAxisValue::from_text("-4"), + logn: GridAxisValue::from_text("-4"), + logo: GridAxisValue::from_text("-4"), + }; + assert_eq!(p.model_name(), "t20000_g5.0_he-2_c-4_n-4_o-4"); + // 数值正确解析(算术/排序不受影响) + assert!((p.teff.value() - 20000.0).abs() < 1e-9); + assert!((p.logg.value() - 5.0).abs() < 1e-9); + assert!((p.cno_sum() - (-12.0)).abs() < 1e-9); + } + + /// 端到端:`GridConfig::from_yaml_str` 解析真实 config(含 `grid:` 块),确认 + /// `logg: 5.0` 的源精度原文被捕获、命名逐字符等于 Python `gen_input5.model_name`。 + /// + /// 这是数据迁移的命门:旧版单机网格目录名是 `t20000_g5.0_...`,DCTS 必须产出同名。 + /// 注:纯 `serde_yaml::from_str` 会把 `5.0` 归一化为 `visit_f64` 丢失原文,故走 + /// `from_yaml_str` 的源文本捕获路径(见 config.rs `parse_grid_axes_raw`)。 + #[test] + fn test_grid_axis_value_preserves_yaml_source_via_serde() { + use crate::config::GridConfig; + // 两种块格式都覆盖:流式 + 块式。 + let yaml_flow = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n"; + let yaml_block = "grid:\n teff:\n - 20000\n logg:\n - 5.0\n loghe:\n - -2\n logc:\n - -4\n logn:\n - -4\n logo:\n - -4\n"; + + for (label, yaml) in [("flow", yaml_flow), ("block", yaml_block)] { + let cfg = + GridConfig::from_yaml_str(yaml).unwrap_or_else(|_| panic!("{} 解析失败", label)); + let pt = GridPointParams { + teff: cfg.grid.teff[0].clone(), + logg: cfg.grid.logg[0].clone(), + loghe: cfg.grid.loghe[0].clone(), + logc: cfg.grid.logc[0].clone(), + logn: cfg.grid.logn[0].clone(), + logo: cfg.grid.logo[0].clone(), + }; + assert_eq!( + pt.model_name(), + "t20000_g5.0_he-2_c-4_n-4_o-4", + "{} 格式应保留源精度", + label + ); + } + } + + /// `Deref` 让算术调用点无感:可直接比较/运算。 + #[test] + fn test_grid_axis_value_deref_to_f64() { + let v: GridAxisValue = 5.0.into(); + assert!((*v - 5.0).abs() < 1e-9); + assert!(*v > 4.0); + let sum = *v + 1.0_f64; + assert!((sum - 6.0).abs() < 1e-9); + } + #[test] fn test_grid_point_status_display_and_conversion() { assert_eq!(GridPointStatus::Pending.to_string(), "pending"); diff --git a/crates/common/src/result_filter.rs b/crates/common/src/result_filter.rs new file mode 100644 index 0000000..c14f9ca --- /dev/null +++ b/crates/common/src/result_filter.rs @@ -0,0 +1,199 @@ +//! 归档白名单:决定沙盒里的哪些文件值得进入持久归档目录(`data/result//`)。 +//! +//! node 端实时结果归档(`executor::save_result_artifacts`)与离线迁移工具(`import_results`) +//! 共用本模块的 [`is_result_worthy`],确保两条路径的结果归档口径一致、不会随时间漂移。 +//! +//! ## 设计原则 +//! +//! 归档目标只保留**有语义价值的产物**,丢弃 Tlusty/Synspec 运行时产生的中间工作单元 +//! (`fort.1/2/3/13/14/18/22/42/44/50/57/69/82/95` 等)。旧版用「拷贝所有普通文件」的 +//! catch-all 策略,把这些无语义单元也搬进了归档,浪费磁盘(每个模型约 2MB / 4.8MB)。 +//! +//! ## 保留的文件名形态(`model_name` 为网格点权威名,如 `t20000_g5.0_he-2_c-4_n-4_o-4`) +//! +//! 1. **裸名保留**(有独立语义,不以 model_name 为前缀): +//! `conv.json`、`fort.8`(synspec 输入大气)、`fort.55`(synspec 控制卡) +//! 2. **科学核心**:`.7`、`.spec`、`.cont`、`.iden`、`.log` +//! 3. **阶段快照**:`.